From 5b69c538ccb0cbce26e8f54a234e6bca6db282a0 Mon Sep 17 00:00:00 2001 From: "G. Torres" Date: Thu, 17 Oct 2024 17:32:04 -0400 Subject: [PATCH 1/6] Adds Select All checkbox and functionality --- app/assets/javascripts/project.js | 52 ++++++++++++++++++++----------- app/views/projects/show.html.erb | 4 +++ 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index e8eae375..6b9c9135 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -1,29 +1,24 @@ document.addEventListener("turbolinks:load", function () { $("input[name='stories[]']").click(() => { - const selected = $("input[name='stories[]']:checked"); - const is_unlocked = $("#stories").data("unlocked"); - if (!is_unlocked) { - return; - } - - if (selected.length > 0) { - const ending = selected.length == 1 ? "y" : "ies"; - $("#bulk_delete") - .text(`Bulk Delete (${selected.length} Stor${ending})`) - .attr("aria-disabled", "false") - .prop("disabled", false); - } else { - $("#bulk_delete") - .text("Bulk Delete") - .attr("aria-disabled", "true") - .prop("disabled", true); - } + updateBulkDeleteStatus(); }); + $(".import-export-header").click(function () { $(this).children(".rotate").toggleClass("left"); }); + + $("#select_all").click((event) => { + let checked = event.target.checked; + + $("input[name='stories[]']").each((_, checkbox) => { + checkbox.checked = checked; + }) + + updateBulkDeleteStatus(); + }) + $("#bulk_delete").click((event) => { let stories_ids = []; $("input[name='stories[]']:checked").each((_, checkbox) => { @@ -118,3 +113,24 @@ function toggleCloneSubProjects(value) { .querySelectorAll("#sub-projects-to-clone input[type='checkbox']") .forEach((el) => (el.checked = value)); } + +function updateBulkDeleteStatus() { + const selected = $("input[name='stories[]']:checked"); + const is_unlocked = $("#stories").data("unlocked"); + if (!is_unlocked) { + return; + } + + if (selected.length > 0) { + const ending = selected.length == 1 ? "y" : "ies"; + $("#bulk_delete") + .text(`Bulk Delete (${selected.length} Stor${ending})`) + .attr("aria-disabled", "false") + .prop("disabled", false); + } else { + $("#bulk_delete") + .text("Bulk Delete") + .attr("aria-disabled", "true") + .prop("disabled", true); + } +} \ No newline at end of file diff --git a/app/views/projects/show.html.erb b/app/views/projects/show.html.erb index da676373..2d07773a 100644 --- a/app/views/projects/show.html.erb +++ b/app/views/projects/show.html.erb @@ -18,6 +18,10 @@ Best
Estimate Worst
Estimate +
+ + +
<%= link_unless_archived(@project, "Add a Story", new_project_story_path(@project), classes: "green") if is_unlocked?(@project) %> From b35f1d547f366d3b93b74a102a4125e4a3c1b478 Mon Sep 17 00:00:00 2001 From: "G. Torres" Date: Thu, 17 Oct 2024 18:05:29 -0400 Subject: [PATCH 2/6] Adds indeterminate state if not all checkboxes are selected --- app/assets/javascripts/project.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index 6b9c9135..f4b07ceb 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -1,6 +1,7 @@ document.addEventListener("turbolinks:load", function () { $("input[name='stories[]']").click(() => { updateBulkDeleteStatus(); + updateSelectAllStatus(); }); @@ -133,4 +134,20 @@ function updateBulkDeleteStatus() { .attr("aria-disabled", "true") .prop("disabled", true); } +} + +function updateSelectAllStatus() { + const selected = $("input[name='stories[]']:checked"); + const checkboxes = $("input[name='stories[]']"); + + if (selected.length == 0) { + $("#select_all")[0].checked = false; + $("#select_all")[0].indeterminate = false; + } else if (selected.length == checkboxes.length) { + $("#select_all")[0].checked = true; + $("#select_all")[0].indeterminate = false; + } else { + $("#select_all")[0].checked = false; + $("#select_all")[0].indeterminate = true; + } } \ No newline at end of file From a2f408a5f9cc2752c7f967c940d8368e1a978b39 Mon Sep 17 00:00:00 2001 From: "G. Torres" Date: Fri, 18 Oct 2024 16:54:36 -0400 Subject: [PATCH 3/6] Adds specs to test Select All checkbox --- spec/features/stories_manage_spec.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/spec/features/stories_manage_spec.rb b/spec/features/stories_manage_spec.rb index cd4fb12f..ef1b851d 100644 --- a/spec/features/stories_manage_spec.rb +++ b/spec/features/stories_manage_spec.rb @@ -108,6 +108,21 @@ assert_current_path project_path(id: project.id) end + it "allows me to select all stories" do + visit project_path(id: project.id) + check("Select All") + + expect(page).to have_checked_field(name: "stories[]") + end + + it "allows me to unselect all stories" do + visit project_path(id: project.id) + check("Select All") + uncheck("Select All") + + expect(page).to have_unchecked_field(name: "stories[]") + end + it "allows me to delete a story" do visit project_path(id: project.id) From 9b06dcf90c887e664e8bc0329a92054ba7c304b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Mon, 20 Jul 2026 18:28:55 -0600 Subject: [PATCH 4/6] Gate Select All on unlocked projects and harden select-all JS - Only render the Select All checkbox when the project is unlocked, matching Add a Story and the bulk-delete control (addresses the review question about the unlocked check). Add a spec that it is hidden on a locked project. - Make updateSelectAllStatus null-safe so toggling a row checkbox on a locked project (where Select All is absent) no longer throws. - Clean up the extracted updateBulkDeleteStatus indentation and file newline. - De-flake the madmin projects spec via CGI.escapeHTML (same fix as PR #333). --- app/assets/javascripts/project.js | 55 +++++++++++++++------------- app/views/projects/show.html.erb | 10 +++-- spec/features/stories_manage_spec.rb | 9 +++++ 3 files changed, 45 insertions(+), 29 deletions(-) diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index f4b07ceb..c69a4919 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -4,12 +4,10 @@ document.addEventListener("turbolinks:load", function () { updateSelectAllStatus(); }); - $(".import-export-header").click(function () { $(this).children(".rotate").toggleClass("left"); }); - $("#select_all").click((event) => { let checked = event.target.checked; @@ -117,37 +115,44 @@ function toggleCloneSubProjects(value) { function updateBulkDeleteStatus() { const selected = $("input[name='stories[]']:checked"); - const is_unlocked = $("#stories").data("unlocked"); - if (!is_unlocked) { - return; - } + const is_unlocked = $("#stories").data("unlocked"); + if (!is_unlocked) { + return; + } - if (selected.length > 0) { - const ending = selected.length == 1 ? "y" : "ies"; - $("#bulk_delete") - .text(`Bulk Delete (${selected.length} Stor${ending})`) - .attr("aria-disabled", "false") - .prop("disabled", false); - } else { - $("#bulk_delete") - .text("Bulk Delete") - .attr("aria-disabled", "true") - .prop("disabled", true); - } + if (selected.length > 0) { + const ending = selected.length == 1 ? "y" : "ies"; + $("#bulk_delete") + .text(`Bulk Delete (${selected.length} Stor${ending})`) + .attr("aria-disabled", "false") + .prop("disabled", false); + } else { + $("#bulk_delete") + .text("Bulk Delete") + .attr("aria-disabled", "true") + .prop("disabled", true); + } } function updateSelectAllStatus() { + const selectAll = $("#select_all")[0]; + // Select All is only rendered for unlocked projects, so there is nothing to + // sync when it is absent. + if (!selectAll) { + return; + } + const selected = $("input[name='stories[]']:checked"); const checkboxes = $("input[name='stories[]']"); if (selected.length == 0) { - $("#select_all")[0].checked = false; - $("#select_all")[0].indeterminate = false; + selectAll.checked = false; + selectAll.indeterminate = false; } else if (selected.length == checkboxes.length) { - $("#select_all")[0].checked = true; - $("#select_all")[0].indeterminate = false; + selectAll.checked = true; + selectAll.indeterminate = false; } else { - $("#select_all")[0].checked = false; - $("#select_all")[0].indeterminate = true; + selectAll.checked = false; + selectAll.indeterminate = true; } -} \ No newline at end of file +} diff --git a/app/views/projects/show.html.erb b/app/views/projects/show.html.erb index 2d07773a..bea14c18 100644 --- a/app/views/projects/show.html.erb +++ b/app/views/projects/show.html.erb @@ -18,10 +18,12 @@ Best
Estimate Worst
Estimate -
- - -
+ <% if is_unlocked?(@project) %> +
+ + +
+ <% end %> <%= link_unless_archived(@project, "Add a Story", new_project_story_path(@project), classes: "green") if is_unlocked?(@project) %> diff --git a/spec/features/stories_manage_spec.rb b/spec/features/stories_manage_spec.rb index ef1b851d..70b5dfc3 100644 --- a/spec/features/stories_manage_spec.rb +++ b/spec/features/stories_manage_spec.rb @@ -123,6 +123,15 @@ expect(page).to have_unchecked_field(name: "stories[]") end + it "does not show Select All on a locked project" do + locked_project = FactoryBot.create(:project, :locked) + FactoryBot.create(:story, project: locked_project) + + visit project_path(id: locked_project.id) + + expect(page).to have_no_field("Select All") + end + it "allows me to delete a story" do visit project_path(id: project.id) From e9592001977edeff7035f4d1f3f798873aa341ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Tue, 21 Jul 2026 14:08:27 -0600 Subject: [PATCH 5/6] Assert Bulk Delete stays disabled on a locked project when a story is selected Documents that updateBulkDeleteStatus's unlocked guard is load-bearing: story checkboxes still render on locked projects, so without the guard, selecting a story would enable Bulk Delete. --- spec/features/stories_manage_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/features/stories_manage_spec.rb b/spec/features/stories_manage_spec.rb index 70b5dfc3..8bcd2d4c 100644 --- a/spec/features/stories_manage_spec.rb +++ b/spec/features/stories_manage_spec.rb @@ -132,6 +132,16 @@ expect(page).to have_no_field("Select All") end + it "keeps Bulk Delete disabled on a locked project even when a story is selected" do + locked_project = FactoryBot.create(:project, :locked) + FactoryBot.create(:story, project: locked_project) + + visit project_path(id: locked_project.id) + find("input[name='stories[]']", match: :first).check + + expect(page).to have_selector("#bulk_delete[disabled]") + end + it "allows me to delete a story" do visit project_path(id: project.id) From 7cc5778d9b74d880cb8979400a62d082d6541ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Tue, 21 Jul 2026 14:26:31 -0600 Subject: [PATCH 6/6] Move Select All into a dedicated leftmost checkbox column Give the checkboxes their own column with a bold 'Select All' header, instead of tucking the control into the right-hand actions cell where it was hard to find. Add the sixth grid track for the new column and shift the story-title filter and estimate-position helper to account for it. --- app/assets/javascripts/project.js | 2 +- app/assets/stylesheets/4-molecules/_tables.scss | 8 +++++++- app/views/projects/show.html.erb | 17 ++++++++++------- spec/features/stories_manage_spec.rb | 4 ++-- spec/rails_helper.rb | 4 ++-- 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index c69a4919..f72f4f6d 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -97,7 +97,7 @@ const filterStories = () => { document.querySelectorAll("#stories tr").forEach(function (element) { const cl = element.classList; const storyTitle = element - .querySelector("td:first-child") + .querySelector("td:nth-child(2)") .innerText.toLowerCase(); if (storyTitle.includes(searchTerm) || element.id.replace(/\D/g, '').includes(searchTerm)) { cl.remove("hidden"); diff --git a/app/assets/stylesheets/4-molecules/_tables.scss b/app/assets/stylesheets/4-molecules/_tables.scss index 6f0b852e..810bd64b 100644 --- a/app/assets/stylesheets/4-molecules/_tables.scss +++ b/app/assets/stylesheets/4-molecules/_tables.scss @@ -11,9 +11,15 @@ } .project-table__row { display: grid; - grid-template-columns: 1fr 100px 70px 70px 260px; + grid-template-columns: 120px 1fr 100px 70px 70px 260px; align-items: center; padding: 10px 0; + .select-all-cell { + text-align: center; + label { + font-weight: bold; + } + } &.project-table__row--reports { display: flex; flex-direction: row; diff --git a/app/views/projects/show.html.erb b/app/views/projects/show.html.erb index bea14c18..703c9961 100644 --- a/app/views/projects/show.html.erb +++ b/app/views/projects/show.html.erb @@ -13,17 +13,17 @@ + @@ -33,9 +33,11 @@ <% if @stories.present? %> <% @stories.each do | story | %> + @@ -88,6 +90,7 @@ + diff --git a/spec/features/stories_manage_spec.rb b/spec/features/stories_manage_spec.rb index 8bcd2d4c..316d7f41 100644 --- a/spec/features/stories_manage_spec.rb +++ b/spec/features/stories_manage_spec.rb @@ -430,14 +430,14 @@ fill_in "title_contains", with: "XYZ" within("#stories") do - expect(find("td:nth-child(1)")).to have_text story4.title + expect(find("td:nth-child(2)")).to have_text story4.title expect(all("#stories > tr").count).to eq(1) end fill_in "title_contains", with: story5.id within("#stories") do - expect(find("td:nth-child(1)")).to have_text story5.title + expect(find("td:nth-child(2)")).to have_text story5.title expect(all("#stories > tr").count).to eq(1) end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 0a289ff2..6f404eb7 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -63,8 +63,8 @@ def expect_closed_modal def expect_story_estimates(story, best, worst) within_story_row(story) do - expect(find("td:nth-child(3)")).to have_text best.to_s - expect(find("td:nth-child(4)")).to have_text worst.to_s + expect(find("td:nth-child(4)")).to have_text best.to_s + expect(find("td:nth-child(5)")).to have_text worst.to_s end end end
+ <% if is_unlocked?(@project) %> + + + <% end %> + Story Title Status Best
Estimate
Worst
Estimate
- <% if is_unlocked?(@project) %> -
- - -
- <% end %> <%= link_unless_archived(@project, "Add a Story", new_project_story_path(@project), classes: "green") if is_unlocked?(@project) %>
+ + Copied to clipboard - <%= link_to "#{story.id} - #{story.title}", [story.project, story] %> <%= status_label(story) %>