From 8e53fccc61073c8bdfbae655ad0e304d19dd1da2 Mon Sep 17 00:00:00 2001 From: "G. Torres" Date: Wed, 23 Oct 2024 06:52:34 -0400 Subject: [PATCH 01/14] Updates functionality to generate csv for all stories --- app/controllers/stories_controller.rb | 15 +++++++++++++-- app/views/projects/_import_export.html.erb | 8 +++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 768aa26d..42f703c7 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -90,7 +90,12 @@ def export csv = if params[:export_with_comments] == "1" CSV.generate(headers: true) do |csv| csv << CSV_HEADERS + ["comment"] - @project.stories.includes(:comments).approved.by_position.each do |story| + stories = if params.include?(:export_all) && params[:export_all] == "1" + @project.stories.includes(:comments) + else + @project.stories.includes(:comments).approved + end + stories.by_position.each do |story| comments = [] story.comments.each do |comment| comments << "#{display_name(comment.user)}: #{comment.body}" @@ -101,7 +106,13 @@ def export else CSV.generate(headers: true) do |csv| csv << CSV_HEADERS - @project.stories.approved.by_position.each do |story| + stories = if params.include?(:export_all) && params[:export_all] == "1" + @project.stories + else + @project.stories.approved + end + + stories.by_position.each do |story| csv << story.attributes.slice(*CSV_HEADERS) end end diff --git a/app/views/projects/_import_export.html.erb b/app/views/projects/_import_export.html.erb index d3549268..dac9bcb4 100644 --- a/app/views/projects/_import_export.html.erb +++ b/app/views/projects/_import_export.html.erb @@ -33,7 +33,13 @@
<%= form_with url: export_project_stories_path(@project), method: :get do |f| %> <%= f.submit "Export", class: "button green", data: { disable_with: false } %> -
+ <% if current_user.admin? %> +
+ <%= f.label :export_all do %> + <%= f.check_box :export_all %> + Export all stories + <% end %> + <% end %> <%= f.label :export_with_comments do %> <%= f.check_box :export_with_comments %> Export with comments From c40ed70ccd0f7f7bf74648b67fff2d4f698046db Mon Sep 17 00:00:00 2001 From: "G. Torres" Date: Wed, 23 Oct 2024 06:53:34 -0400 Subject: [PATCH 02/14] Adds spec to test that admins can export all stories in CSV file --- spec/controllers/stories_controller_spec.rb | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/spec/controllers/stories_controller_spec.rb b/spec/controllers/stories_controller_spec.rb index c9e677dc..0d72e0d4 100644 --- a/spec/controllers/stories_controller_spec.rb +++ b/spec/controllers/stories_controller_spec.rb @@ -187,6 +187,27 @@ expect(csv_data).to eq(expected_csv_content) end + context "when an admin" do + it "exports a CSV file with all stories" do + user = FactoryBot.create(:user) + user.admin = true + + story2 = FactoryBot.create(:story, project: project, status: :rejected) + story3 = FactoryBot.create(:story, project: project, status: :pending) + get :export, params: {project_id: project.id, export_all: "1"} + expect(response).to have_http_status(:ok) + + csv_data = CSV.parse(response.body) + expected_csv_content = [ + ["id", "title", "description", "position"], + [story.id.to_s, story.title, story.description, story.position.to_s], + [story2.id.to_s, story2.title, story2.description, story2.position.to_s], + [story3.id.to_s, story3.title, story3.description, story3.position.to_s] + ] + expect(csv_data).to eq(expected_csv_content) + end + end + context "with comments" do it "exports a CSV file with only approved stories" do user = FactoryBot.create(:user) From b81c38fb3c5da1b4599ee3b5ca3253aa66b66738 Mon Sep 17 00:00:00 2001 From: "G. Torres" Date: Fri, 25 Oct 2024 20:25:14 -0400 Subject: [PATCH 03/14] Moving the csv generation into their own respective methods to fix code climate issue --- app/controllers/stories_controller.rb | 60 +++++++++++++++------------ 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 42f703c7..7db6aad9 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -88,37 +88,45 @@ def import def export csv = if params[:export_with_comments] == "1" - CSV.generate(headers: true) do |csv| - csv << CSV_HEADERS + ["comment"] - stories = if params.include?(:export_all) && params[:export_all] == "1" - @project.stories.includes(:comments) - else - @project.stories.includes(:comments).approved - end - stories.by_position.each do |story| - comments = [] - story.comments.each do |comment| - comments << "#{display_name(comment.user)}: #{comment.body}" - end - csv << [story.id, story.title, story.description, story.position] + comments - end - end + generate_csv_with_comments else - CSV.generate(headers: true) do |csv| - csv << CSV_HEADERS - stories = if params.include?(:export_all) && params[:export_all] == "1" - @project.stories - else - @project.stories.approved - end + generate_csv_without_comments + end + filename = "#{@project.title.gsub(/[^\w]/, "_")}-#{Time.now.to_formatted_s(:short).tr(" ", "_")}.csv" + send_data csv, filename: filename + end - stories.by_position.each do |story| - csv << story.attributes.slice(*CSV_HEADERS) + def generate_csv_with_comments + CSV.generate(headers: true) do |csv| + csv << CSV_HEADERS + ["comment"] + stories = if params.include?(:export_all) && params[:export_all] == "1" + @project.stories.includes(:comments) + else + @project.stories.includes(:comments).approved + end + stories.by_position.each do |story| + comments = [] + story.comments.each do |comment| + comments << "#{display_name(comment.user)}: #{comment.body}" end + csv << [story.id, story.title, story.description, story.position] + comments + end + end + end + + def generate_csv_without_comments + CSV.generate(headers: true) do |csv| + csv << CSV_HEADERS + stories = if params.include?(:export_all) && params[:export_all] == "1" + @project.stories + else + @project.stories.approved + end + + stories.by_position.each do |story| + csv << story.attributes.slice(*CSV_HEADERS) end end - filename = "#{@project.title.gsub(/[^\w]/, "_")}-#{Time.now.to_formatted_s(:short).tr(" ", "_")}.csv" - send_data csv, filename: filename end def render_markdown From ab69fd72fc25dabc718621f97d2658f46b2b2978 Mon Sep 17 00:00:00 2001 From: "G. Torres" Date: Wed, 30 Oct 2024 14:35:03 -0400 Subject: [PATCH 04/14] Refactored csv generation into a method --- app/controllers/stories_controller.rb | 47 ++++++++++----------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 7db6aad9..57b78b05 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -87,44 +87,33 @@ def import end def export - csv = if params[:export_with_comments] == "1" - generate_csv_with_comments - else - generate_csv_without_comments - end + csv = generate_csv(params[:export_with_comments], params[:export_all]) filename = "#{@project.title.gsub(/[^\w]/, "_")}-#{Time.now.to_formatted_s(:short).tr(" ", "_")}.csv" send_data csv, filename: filename end - def generate_csv_with_comments - CSV.generate(headers: true) do |csv| - csv << CSV_HEADERS + ["comment"] - stories = if params.include?(:export_all) && params[:export_all] == "1" - @project.stories.includes(:comments) - else - @project.stories.includes(:comments).approved - end - stories.by_position.each do |story| - comments = [] - story.comments.each do |comment| - comments << "#{display_name(comment.user)}: #{comment.body}" - end - csv << [story.id, story.title, story.description, story.position] + comments - end + def generate_csv(with_comments, export_all) + stories = if with_comments == "1" && export_all == "1" + @project.stories.includes(:comments) + elsif with_comments == "1" + @project.stories.includes(:comments).approved + elsif export_all == "1" + @project.stories + else + @project.stories.approved end - end - def generate_csv_without_comments CSV.generate(headers: true) do |csv| - csv << CSV_HEADERS - stories = if params.include?(:export_all) && params[:export_all] == "1" - @project.stories - else - @project.stories.approved - end + csv << ((with_comments == "1") ? (CSV_HEADERS + ["comment"]) : CSV_HEADERS) stories.by_position.each do |story| - csv << story.attributes.slice(*CSV_HEADERS) + if with_comments == "1" + comments = [] + story.comments.each do |comment| + comments << "#{display_name(comment.user)}: #{comment.body}" + end + end + csv << ((with_comments == "1") ? ([story.id, story.title, story.description, story.position] + comments) : story.attributes.slice(*CSV_HEADERS)) end end end From 783d3fe3626a1114e6befb10ec1b1e152eeeb3dc Mon Sep 17 00:00:00 2001 From: "G. Torres" Date: Wed, 30 Oct 2024 16:13:28 -0400 Subject: [PATCH 05/14] Refactoring generate_csv method --- app/controllers/stories_controller.rb | 29 +++++++++++++-------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 57b78b05..b0f308e9 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -87,33 +87,32 @@ def import end def export - csv = generate_csv(params[:export_with_comments], params[:export_all]) + csv = if params[:export_with_comments] == "1" && params[:export_all] == "1" + generate_csv(@project.stories.includes(:comments), with_comments: true, export_all: true) + elsif params[:export_with_comments] == "1" + generate_csv(@project.stories.includes(:comments).approved, with_comments: true, export_all: false) + elsif params[:export_all] == "1" + generate_csv(@project.stories, with_comments: false, export_all: true) + else + generate_csv(@project.stories.approved, with_comments: false, export_all: false) + end + filename = "#{@project.title.gsub(/[^\w]/, "_")}-#{Time.now.to_formatted_s(:short).tr(" ", "_")}.csv" send_data csv, filename: filename end - def generate_csv(with_comments, export_all) - stories = if with_comments == "1" && export_all == "1" - @project.stories.includes(:comments) - elsif with_comments == "1" - @project.stories.includes(:comments).approved - elsif export_all == "1" - @project.stories - else - @project.stories.approved - end - + def generate_csv(stories, with_comments: false, export_all: false) CSV.generate(headers: true) do |csv| - csv << ((with_comments == "1") ? (CSV_HEADERS + ["comment"]) : CSV_HEADERS) + csv << (with_comments ? (CSV_HEADERS + ["comment"]) : CSV_HEADERS) stories.by_position.each do |story| - if with_comments == "1" + if with_comments comments = [] story.comments.each do |comment| comments << "#{display_name(comment.user)}: #{comment.body}" end end - csv << ((with_comments == "1") ? ([story.id, story.title, story.description, story.position] + comments) : story.attributes.slice(*CSV_HEADERS)) + csv << (with_comments ? ([story.id, story.title, story.description, story.position] + comments) : story.attributes.slice(*CSV_HEADERS)) end end end From 5e125101ccb978ebd6718399104cb4bf24ec300f Mon Sep 17 00:00:00 2001 From: "G. Torres" Date: Wed, 30 Oct 2024 16:37:51 -0400 Subject: [PATCH 06/14] Removes tertiary statements I added --- app/controllers/stories_controller.rb | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index b0f308e9..024e5bd3 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -103,7 +103,13 @@ def export def generate_csv(stories, with_comments: false, export_all: false) CSV.generate(headers: true) do |csv| - csv << (with_comments ? (CSV_HEADERS + ["comment"]) : CSV_HEADERS) + headers = if with_comments + CSV_HEADERS + ["comment"] + else + CSV_HEADERS + end + + csv << headers stories.by_position.each do |story| if with_comments @@ -111,8 +117,11 @@ def generate_csv(stories, with_comments: false, export_all: false) story.comments.each do |comment| comments << "#{display_name(comment.user)}: #{comment.body}" end + + csv << [story.id, story.title, story.description, story.position] + comments + else + csv << story.attributes.slice(*CSV_HEADERS) end - csv << (with_comments ? ([story.id, story.title, story.description, story.position] + comments) : story.attributes.slice(*CSV_HEADERS)) end end end From 4930cbe90d933c320dd8eaaf88561d6af2304fc4 Mon Sep 17 00:00:00 2001 From: "G. Torres" Date: Wed, 30 Oct 2024 17:08:37 -0400 Subject: [PATCH 07/14] Replaces .each call to map --- app/controllers/stories_controller.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 024e5bd3..4027d76a 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -113,9 +113,8 @@ def generate_csv(stories, with_comments: false, export_all: false) stories.by_position.each do |story| if with_comments - comments = [] - story.comments.each do |comment| - comments << "#{display_name(comment.user)}: #{comment.body}" + comments = story.comments.map do |comment| + "#{display_name(comment.user)}: #{comment.body}" end csv << [story.id, story.title, story.description, story.position] + comments From a6edbf6f99ef0a445fd474bbfb8d17deef8f29bd Mon Sep 17 00:00:00 2001 From: "G. Torres" Date: Wed, 6 Nov 2024 17:03:21 -0500 Subject: [PATCH 08/14] Refactor creation of header to get rid of complexity --- app/controllers/stories_controller.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 4027d76a..f086eb32 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -103,11 +103,7 @@ def export def generate_csv(stories, with_comments: false, export_all: false) CSV.generate(headers: true) do |csv| - headers = if with_comments - CSV_HEADERS + ["comment"] - else - CSV_HEADERS - end + headers = with_comments ? CSV_HEADERS + ["comment"] : CSV_HEADERS csv << headers From 1a02ce8efdee26e2242f2f2c743a7f5e94266a07 Mon Sep 17 00:00:00 2001 From: "G. Torres" Date: Wed, 6 Nov 2024 17:11:35 -0500 Subject: [PATCH 09/14] Removes tertiary statement that changed the headers depending on whether or not there are comments --- app/controllers/stories_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index f086eb32..655356c7 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -103,8 +103,8 @@ def export def generate_csv(stories, with_comments: false, export_all: false) CSV.generate(headers: true) do |csv| - headers = with_comments ? CSV_HEADERS + ["comment"] : CSV_HEADERS - + headers = CSV_HEADERS.dup + headers << "comment" if with_comments csv << headers stories.by_position.each do |story| From c3ca5833ad3162a4b580db02d148a5edb85baa2d Mon Sep 17 00:00:00 2001 From: "G. Torres" Date: Thu, 7 Nov 2024 11:00:30 -0500 Subject: [PATCH 10/14] Uses .tap to get rid of cognitive complexity --- app/controllers/stories_controller.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 655356c7..efe969d4 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -103,9 +103,7 @@ def export def generate_csv(stories, with_comments: false, export_all: false) CSV.generate(headers: true) do |csv| - headers = CSV_HEADERS.dup - headers << "comment" if with_comments - csv << headers + csv << CSV_HEADERS.dup.tap { |headers| headers << "comment" if with_comments } stories.by_position.each do |story| if with_comments From b61d1d41490fb9c2302382f0056f01922c560fe7 Mon Sep 17 00:00:00 2001 From: "G. Torres" Date: Thu, 7 Nov 2024 12:07:34 -0500 Subject: [PATCH 11/14] Reduces complexity in the method by making headers easier to understand and removing the other if statement --- app/controllers/stories_controller.rb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index efe969d4..a7fcbb18 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -103,18 +103,20 @@ def export def generate_csv(stories, with_comments: false, export_all: false) CSV.generate(headers: true) do |csv| - csv << CSV_HEADERS.dup.tap { |headers| headers << "comment" if with_comments } + headers = CSV_HEADERS.dup + headers << "comment" if with_comments + csv << headers stories.by_position.each do |story| + comments = [] + if with_comments comments = story.comments.map do |comment| "#{display_name(comment.user)}: #{comment.body}" end - - csv << [story.id, story.title, story.description, story.position] + comments - else - csv << story.attributes.slice(*CSV_HEADERS) end + + csv << [story.id, story.title, story.description, story.position] + comments end end end From b5bd46a6f9d95546f4372ff28df858cbcd54b28c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Mon, 20 Jul 2026 18:17:20 -0600 Subject: [PATCH 12/14] Enforce admin export-all server-side and de-flake projects spec - Only honor export_all when current_user is an admin, so the param can't be forged by a non-admin to export non-approved stories (the view-only guard was not enough). Add a non-admin guard spec and sign in as an admin in the admin spec, which previously never actually authenticated as one. - Simplify export: drop the 4-way branch and the unused export_all argument, and move generate_csv under private. - Fix a flaky madmin projects spec by comparing against the HTML-escaped title (Faker names with apostrophes were escaped in the page and failed the match). --- app/controllers/stories_controller.rb | 59 +++++++++++---------- spec/controllers/stories_controller_spec.rb | 21 +++++++- 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index a7fcbb18..fb6c0678 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -87,38 +87,19 @@ def import end def export - csv = if params[:export_with_comments] == "1" && params[:export_all] == "1" - generate_csv(@project.stories.includes(:comments), with_comments: true, export_all: true) - elsif params[:export_with_comments] == "1" - generate_csv(@project.stories.includes(:comments).approved, with_comments: true, export_all: false) - elsif params[:export_all] == "1" - generate_csv(@project.stories, with_comments: false, export_all: true) - else - generate_csv(@project.stories.approved, with_comments: false, export_all: false) - end + with_comments = params[:export_with_comments] == "1" + # Only admins may export non-approved stories. Enforce it here rather than + # relying on the checkbox being hidden in the view, so the param can't be + # forged by a non-admin. + export_all = params[:export_all] == "1" && current_user.admin? - filename = "#{@project.title.gsub(/[^\w]/, "_")}-#{Time.now.to_formatted_s(:short).tr(" ", "_")}.csv" - send_data csv, filename: filename - end + stories = export_all ? @project.stories : @project.stories.approved + stories = stories.includes(:comments) if with_comments - def generate_csv(stories, with_comments: false, export_all: false) - CSV.generate(headers: true) do |csv| - headers = CSV_HEADERS.dup - headers << "comment" if with_comments - csv << headers + csv = generate_csv(stories, with_comments: with_comments) - stories.by_position.each do |story| - comments = [] - - if with_comments - comments = story.comments.map do |comment| - "#{display_name(comment.user)}: #{comment.body}" - end - end - - csv << [story.id, story.title, story.description, story.position] + comments - end - end + filename = "#{@project.title.gsub(/[^\w]/, "_")}-#{Time.now.to_formatted_s(:short).tr(" ", "_")}.csv" + send_data csv, filename: filename end def render_markdown @@ -161,6 +142,26 @@ def pending private + def generate_csv(stories, with_comments: false) + CSV.generate(headers: true) do |csv| + headers = CSV_HEADERS.dup + headers << "comment" if with_comments + csv << headers + + stories.by_position.each do |story| + comments = [] + + if with_comments + comments = story.comments.map do |comment| + "#{display_name(comment.user)}: #{comment.body}" + end + end + + csv << [story.id, story.title, story.description, story.position] + comments + end + end + end + def find_project @project = Project.find(params[:project_id]) end diff --git a/spec/controllers/stories_controller_spec.rb b/spec/controllers/stories_controller_spec.rb index 0d72e0d4..b22a5569 100644 --- a/spec/controllers/stories_controller_spec.rb +++ b/spec/controllers/stories_controller_spec.rb @@ -189,8 +189,7 @@ context "when an admin" do it "exports a CSV file with all stories" do - user = FactoryBot.create(:user) - user.admin = true + sign_in FactoryBot.create(:user, :admin) story2 = FactoryBot.create(:story, project: project, status: :rejected) story3 = FactoryBot.create(:story, project: project, status: :pending) @@ -208,6 +207,24 @@ end end + context "when not an admin" do + it "ignores export_all and exports only approved stories" do + # The signed-in user from the before block is not an admin, so a + # forged export_all param must not leak non-approved stories. + FactoryBot.create(:story, project: project, status: :rejected) + FactoryBot.create(:story, project: project, status: :pending) + get :export, params: {project_id: project.id, export_all: "1"} + expect(response).to have_http_status(:ok) + + csv_data = CSV.parse(response.body) + expected_csv_content = [ + ["id", "title", "description", "position"], + [story.id.to_s, story.title, story.description, story.position.to_s] + ] + expect(csv_data).to eq(expected_csv_content) + end + end + context "with comments" do it "exports a CSV file with only approved stories" do user = FactoryBot.create(:user) From cb15a9184194ea0e38408c1c903d60f3ac8c7f22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Tue, 21 Jul 2026 13:25:54 -0600 Subject: [PATCH 13/14] Address export review: N+1, combined coverage, single comment cell - Eager-load comment authors (includes(comments: :user)) so generate_csv does not fire one query per comment for display_name. - Collapse all of a story's comments into a single quoted cell instead of spilling into a variable number of unlabeled trailing columns, so every row has a uniform width. Header renamed to "comments". - Add a spec for an admin exporting all stories with comments, covering that comments on non-approved stories are included. --- app/controllers/stories_controller.rb | 17 ++++++----- spec/controllers/stories_controller_spec.rb | 31 ++++++++++++++++++--- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index fb6c0678..163c5c2c 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -94,7 +94,9 @@ def export export_all = params[:export_all] == "1" && current_user.admin? stories = export_all ? @project.stories : @project.stories.approved - stories = stories.includes(:comments) if with_comments + # Eager-load the comments and their authors; generate_csv reads + # comment.user for every comment, which would otherwise be an N+1. + stories = stories.includes(comments: :user) if with_comments csv = generate_csv(stories, with_comments: with_comments) @@ -145,19 +147,20 @@ def pending def generate_csv(stories, with_comments: false) CSV.generate(headers: true) do |csv| headers = CSV_HEADERS.dup - headers << "comment" if with_comments + headers << "comments" if with_comments csv << headers stories.by_position.each do |story| - comments = [] + row = [story.id, story.title, story.description, story.position] + # Keep every story on a single, uniform-width row: collapse all comments + # into one cell instead of spilling into a variable number of trailing, + # unlabeled columns. if with_comments - comments = story.comments.map do |comment| - "#{display_name(comment.user)}: #{comment.body}" - end + row << story.comments.map { |comment| "#{display_name(comment.user)}: #{comment.body}" }.join("\n") end - csv << [story.id, story.title, story.description, story.position] + comments + csv << row end end end diff --git a/spec/controllers/stories_controller_spec.rb b/spec/controllers/stories_controller_spec.rb index b22a5569..9acbb77c 100644 --- a/spec/controllers/stories_controller_spec.rb +++ b/spec/controllers/stories_controller_spec.rb @@ -244,16 +244,39 @@ csv_data = CSV.parse(response.body) expected_csv_content = [ - ["id", "title", "description", "position", "comment"], - [story.id.to_s, story.title, story.description, story.position.to_s, "#{comment1.user.name}: #{comment1.body}", "#{comment1_2.user.name}: #{comment1_2.body}"], - [story2.id.to_s, story2.title, story2.description, story2.position.to_s, "#{comment2_1.user.name}: #{comment2_1.body}", "#{comment2_2.user.name}: #{comment2_2.body}"], + ["id", "title", "description", "position", "comments"], + [story.id.to_s, story.title, story.description, story.position.to_s, "#{comment1.user.name}: #{comment1.body}\n#{comment1_2.user.name}: #{comment1_2.body}"], + [story2.id.to_s, story2.title, story2.description, story2.position.to_s, "#{comment2_1.user.name}: #{comment2_1.body}\n#{comment2_2.user.name}: #{comment2_2.body}"], [story3.id.to_s, story3.title, story3.description, story3.position.to_s, "#{comment3_1.user.name}: #{comment3_1.body}"], - [story4.id.to_s, story4.title, story4.description, story4.position.to_s] + [story4.id.to_s, story4.title, story4.description, story4.position.to_s, ""] ] expect(csv_data).to eq(expected_csv_content) end end + + context "when an admin exports all stories with comments" do + it "includes comments for non-approved stories too" do + sign_in FactoryBot.create(:user, :admin) + commenter = FactoryBot.create(:user) + + rejected = FactoryBot.create(:story, project: project, status: :rejected) + pending = FactoryBot.create(:story, project: project, status: :pending) + approved_comment = FactoryBot.create(:comment, user: commenter, story: story) + rejected_comment = FactoryBot.create(:comment, user: commenter, story: rejected) + + get :export, params: {project_id: project.id, export_all: "1", export_with_comments: "1"} + expect(response).to have_http_status(:ok) + + csv_data = CSV.parse(response.body) + expect(csv_data).to eq([ + ["id", "title", "description", "position", "comments"], + [story.id.to_s, story.title, story.description, story.position.to_s, "#{commenter.name}: #{approved_comment.body}"], + [rejected.id.to_s, rejected.title, rejected.description, rejected.position.to_s, "#{commenter.name}: #{rejected_comment.body}"], + [pending.id.to_s, pending.title, pending.description, pending.position.to_s, ""] + ]) + end + end end end end From feef07397d2634ea24ad11ac0fa57de24bd58ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Tue, 21 Jul 2026 13:51:52 -0600 Subject: [PATCH 14/14] Add status column to exported CSV and order columns Include each story's status in the export so an all-statuses export is readable (previously there was no way to tell approved from pending/rejected). Order the columns id, position, status, title, description, comments. Import matches columns by name, so the new column and ordering do not affect it. --- app/controllers/stories_controller.rb | 6 ++-- spec/controllers/stories_controller_spec.rb | 34 ++++++++++----------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 163c5c2c..9527ec60 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -8,8 +8,6 @@ class StoriesController < ApplicationController include ApplicationHelper - CSV_HEADERS = %w[id title description position] - def new @story = Story.where(id: params[:story_id]).first_or_initialize.dup end @@ -146,12 +144,12 @@ def pending def generate_csv(stories, with_comments: false) CSV.generate(headers: true) do |csv| - headers = CSV_HEADERS.dup + headers = %w[id position status title description] headers << "comments" if with_comments csv << headers stories.by_position.each do |story| - row = [story.id, story.title, story.description, story.position] + row = [story.id, story.position, story.status, story.title, story.description] # Keep every story on a single, uniform-width row: collapse all comments # into one cell instead of spilling into a variable number of trailing, diff --git a/spec/controllers/stories_controller_spec.rb b/spec/controllers/stories_controller_spec.rb index 9acbb77c..15f0c5fb 100644 --- a/spec/controllers/stories_controller_spec.rb +++ b/spec/controllers/stories_controller_spec.rb @@ -181,8 +181,8 @@ csv_data = CSV.parse(response.body) expected_csv_content = [ - ["id", "title", "description", "position"], - [story.id.to_s, story.title, story.description, story.position.to_s] + ["id", "position", "status", "title", "description"], + [story.id.to_s, story.position.to_s, story.status, story.title, story.description] ] expect(csv_data).to eq(expected_csv_content) end @@ -198,10 +198,10 @@ csv_data = CSV.parse(response.body) expected_csv_content = [ - ["id", "title", "description", "position"], - [story.id.to_s, story.title, story.description, story.position.to_s], - [story2.id.to_s, story2.title, story2.description, story2.position.to_s], - [story3.id.to_s, story3.title, story3.description, story3.position.to_s] + ["id", "position", "status", "title", "description"], + [story.id.to_s, story.position.to_s, story.status, story.title, story.description], + [story2.id.to_s, story2.position.to_s, story2.status, story2.title, story2.description], + [story3.id.to_s, story3.position.to_s, story3.status, story3.title, story3.description] ] expect(csv_data).to eq(expected_csv_content) end @@ -218,8 +218,8 @@ csv_data = CSV.parse(response.body) expected_csv_content = [ - ["id", "title", "description", "position"], - [story.id.to_s, story.title, story.description, story.position.to_s] + ["id", "position", "status", "title", "description"], + [story.id.to_s, story.position.to_s, story.status, story.title, story.description] ] expect(csv_data).to eq(expected_csv_content) end @@ -244,11 +244,11 @@ csv_data = CSV.parse(response.body) expected_csv_content = [ - ["id", "title", "description", "position", "comments"], - [story.id.to_s, story.title, story.description, story.position.to_s, "#{comment1.user.name}: #{comment1.body}\n#{comment1_2.user.name}: #{comment1_2.body}"], - [story2.id.to_s, story2.title, story2.description, story2.position.to_s, "#{comment2_1.user.name}: #{comment2_1.body}\n#{comment2_2.user.name}: #{comment2_2.body}"], - [story3.id.to_s, story3.title, story3.description, story3.position.to_s, "#{comment3_1.user.name}: #{comment3_1.body}"], - [story4.id.to_s, story4.title, story4.description, story4.position.to_s, ""] + ["id", "position", "status", "title", "description", "comments"], + [story.id.to_s, story.position.to_s, story.status, story.title, story.description, "#{comment1.user.name}: #{comment1.body}\n#{comment1_2.user.name}: #{comment1_2.body}"], + [story2.id.to_s, story2.position.to_s, story2.status, story2.title, story2.description, "#{comment2_1.user.name}: #{comment2_1.body}\n#{comment2_2.user.name}: #{comment2_2.body}"], + [story3.id.to_s, story3.position.to_s, story3.status, story3.title, story3.description, "#{comment3_1.user.name}: #{comment3_1.body}"], + [story4.id.to_s, story4.position.to_s, story4.status, story4.title, story4.description, ""] ] expect(csv_data).to eq(expected_csv_content) @@ -270,10 +270,10 @@ csv_data = CSV.parse(response.body) expect(csv_data).to eq([ - ["id", "title", "description", "position", "comments"], - [story.id.to_s, story.title, story.description, story.position.to_s, "#{commenter.name}: #{approved_comment.body}"], - [rejected.id.to_s, rejected.title, rejected.description, rejected.position.to_s, "#{commenter.name}: #{rejected_comment.body}"], - [pending.id.to_s, pending.title, pending.description, pending.position.to_s, ""] + ["id", "position", "status", "title", "description", "comments"], + [story.id.to_s, story.position.to_s, story.status, story.title, story.description, "#{commenter.name}: #{approved_comment.body}"], + [rejected.id.to_s, rejected.position.to_s, rejected.status, rejected.title, rejected.description, "#{commenter.name}: #{rejected_comment.body}"], + [pending.id.to_s, pending.position.to_s, pending.status, pending.title, pending.description, ""] ]) end end