From 115f792198bf97729807b5dac1b4386753188423 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 7 Jun 2024 12:44:06 -0400 Subject: [PATCH] Add bulk selection UI controls (#840) * Add bulk selection UI * Handle bulk selection with Stimulus controller instead of session * Update tests * Remove stale routes * Remove old system test helper methods --- .../stylesheets/application.tailwind.css | 16 +++ .../controllers/bulk_select_controller.js | 101 ++++++++++++++++++ app/views/accounts/_transactions.html.erb | 2 +- .../transactions/_transaction.html.erb | 17 +++ app/views/transactions/_date_group.html.erb | 17 +++ .../transactions/_selection_bar.html.erb | 17 +++ app/views/transactions/_transaction.html.erb | 6 +- app/views/transactions/index.html.erb | 17 ++- config/locales/views/transactions/en.yml | 2 + config/routes.rb | 2 - test/system/transactions_test.rb | 67 ++++++++++++ 11 files changed, 256 insertions(+), 8 deletions(-) create mode 100644 app/javascript/controllers/bulk_select_controller.js create mode 100644 app/views/accounts/transactions/_transaction.html.erb create mode 100644 app/views/transactions/_date_group.html.erb create mode 100644 app/views/transactions/_selection_bar.html.erb diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index bb7a7a90..e8b37c80 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -59,6 +59,22 @@ input:checked + label + .toggle-switch-dot { transform: translateX(100%); } + + [type='checkbox'].maybe-checkbox { + @apply rounded-sm; + } + + [type='checkbox'].maybe-checkbox--light { + @apply border-alpha-black-200 checked:bg-gray-900 checked:hover:bg-gray-500 checked:ring-gray-900 focus-visible:ring-gray-900 focus:ring-gray-900; + } + + [type='checkbox'].maybe-checkbox--dark { + @apply ring-gray-900 checked:text-white; + } + + [type='checkbox'].maybe-checkbox--dark:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); + } } /* Small, single purpose classes that should take precedence over other styles */ diff --git a/app/javascript/controllers/bulk_select_controller.js b/app/javascript/controllers/bulk_select_controller.js new file mode 100644 index 00000000..c5ff2c1c --- /dev/null +++ b/app/javascript/controllers/bulk_select_controller.js @@ -0,0 +1,101 @@ +import {Controller} from "@hotwired/stimulus" + +// Connects to data-controller="bulk-select" +export default class extends Controller { + static targets = ["row", "group", "selectionBar", "selectionBarText"] + static values = { + resource: String, + selectedIds: {type: Array, default: []} + } + + connect() { + document.addEventListener("turbo:load", this.#updateView) + + this.#updateView() + } + + disconnect() { + document.removeEventListener("turbo:load", this.#updateView) + } + + togglePageSelection(e) { + if (e.target.checked) { + this.#selectAll() + } else { + this.deselectAll() + } + } + + toggleGroupSelection(e) { + const group = this.groupTargets.find(group => group.contains(e.target)) + + this.#rowsForGroup(group).forEach(row => { + if (e.target.checked) { + this.#addToSelection(row.dataset.id) + } else { + this.#removeFromSelection(row.dataset.id) + } + }) + } + + toggleRowSelection(e) { + if (e.target.checked) { + this.#addToSelection(e.target.dataset.id) + } else { + this.#removeFromSelection(e.target.dataset.id) + } + } + + deselectAll() { + this.selectedIdsValue = [] + } + + selectedIdsValueChanged() { + this.#updateView() + } + + #rowsForGroup(group) { + return this.rowTargets.filter(row => group.contains(row)) + } + + #addToSelection(idToAdd) { + this.selectedIdsValue = Array.from( + new Set([...this.selectedIdsValue, idToAdd]) + ) + } + + #removeFromSelection(idToRemove) { + this.selectedIdsValue = this.selectedIdsValue.filter(id => id !== idToRemove) + } + + #selectAll() { + this.selectedIdsValue = this.rowTargets.map(t => t.dataset.id) + } + + #updateView = () => { + this.#updateSelectionBar() + this.#updateGroups() + this.#updateRows() + } + + #updateSelectionBar() { + const count = this.selectedIdsValue.length + this.selectionBarTextTarget.innerText = `${count} ${this.resourceValue}${count === 1 ? "" : "s"} selected` + this.selectionBarTarget.hidden = count === 0 + this.selectionBarTarget.querySelector("input[type='checkbox']").checked = count > 0 + } + + #updateGroups() { + this.groupTargets.forEach(group => { + const rows = this.rowTargets.filter(row => group.contains(row)) + const groupSelected = rows.every(row => this.selectedIdsValue.includes(row.dataset.id)) + group.querySelector("input[type='checkbox']").checked = groupSelected + }) + } + + #updateRows() { + this.rowTargets.forEach(row => { + row.checked = this.selectedIdsValue.includes(row.dataset.id) + }) + } +} diff --git a/app/views/accounts/_transactions.html.erb b/app/views/accounts/_transactions.html.erb index c60eef76..4f90c021 100644 --- a/app/views/accounts/_transactions.html.erb +++ b/app/views/accounts/_transactions.html.erb @@ -13,7 +13,7 @@ <% else %>
<% transactions.group_by(&:date).each do |date, transactions| %> - <%= transactions_group(date, transactions) %> + <%= transactions_group(date, transactions, "accounts/transactions/transaction") %> <% end %>
<% end %> diff --git a/app/views/accounts/transactions/_transaction.html.erb b/app/views/accounts/transactions/_transaction.html.erb new file mode 100644 index 00000000..c117eb3b --- /dev/null +++ b/app/views/accounts/transactions/_transaction.html.erb @@ -0,0 +1,17 @@ +<%= turbo_frame_tag dom_id(transaction), class: "grid grid-cols-12 items-center text-gray-900 py-4 text-sm font-medium px-4" do %> +
+ <%= render "transactions/name", transaction: transaction %> +
+ +
+ <%= render "transactions/categories/badge", category: transaction.category %> +
+ + <%= link_to transaction.account.name, + account_path(transaction.account), + class: ["col-span-3 hover:underline"] %> + +
+ <%= render "transactions/amount", transaction: transaction %> +
+<% end %> diff --git a/app/views/transactions/_date_group.html.erb b/app/views/transactions/_date_group.html.erb new file mode 100644 index 00000000..c54d5d97 --- /dev/null +++ b/app/views/transactions/_date_group.html.erb @@ -0,0 +1,17 @@ +<%# locals: (date:, transactions:) %> +
+
+
+ <%= check_box_tag "#{date}_transactions_selection", + class: "maybe-checkbox maybe-checkbox--light", + id: "selection_transaction_#{date}", + data: { action: "bulk-select#toggleGroupSelection" } %> + <%= tag.span "#{date.strftime('%b %d, %Y')} ยท #{transactions.size}" %> +
+ + <%= tag.span format_money(-transactions.sum(&:amount_money)) %> +
+
+ <%= render transactions %> +
+
diff --git a/app/views/transactions/_selection_bar.html.erb b/app/views/transactions/_selection_bar.html.erb new file mode 100644 index 00000000..d3264ec8 --- /dev/null +++ b/app/views/transactions/_selection_bar.html.erb @@ -0,0 +1,17 @@ +
+
+ <%= check_box_tag "transaction_selection", 1, true, class: "maybe-checkbox maybe-checkbox--dark", data: { action: "bulk-select#deselectAll" } %> + +

+
+ +
+ <%= button_to "#", disabled: true, class: "cursor-not-allowed p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md", title: "Edit" do %> + <%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %> + <% end %> + + <%= button_to "#", disabled: true, class: "cursor-not-allowed p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md", title: "Delete" do %> + <%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %> + <% end %> +
+
diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index 667ba49d..c31074cf 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -1,5 +1,9 @@ <%= turbo_frame_tag dom_id(transaction), class: "grid grid-cols-12 items-center text-gray-900 py-4 text-sm font-medium px-4" do %> -
+
+ <%= check_box_tag dom_id(transaction, "selection"), + class: "maybe-checkbox maybe-checkbox--light", + data: { id: transaction.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %> + <%= render "transactions/name", transaction: transaction %>
diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index b624e2d5..61d8115d 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -1,23 +1,32 @@
- <%= render "header" %> <%= render partial: "transactions/summary", locals: { totals: @totals } %> -
+
" class="bg-white rounded-xl border border-alpha-black-25 shadow-xs p-4 space-y-4"> <%= render partial: "transactions/searches/search", locals: { transactions: @transactions } %> <% if @transactions.present? %> + +
-

transaction

+
+ <%= check_box_tag "selection_transaction", + class: "maybe-checkbox maybe-checkbox--light", + data: { action: "bulk-select#togglePageSelection" } %> +

transaction

+
+

category

account

amount

<% @transactions.group_by(&:date).each do |date, transactions| %> - <%= transactions_group(date, transactions) %> + <%= render partial: "date_group", locals: { date:, transactions: } %> <% end %>
<% else %> diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index f61664f1..f7469940 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -66,6 +66,8 @@ en: edit_categories: Edit categories edit_imports: Edit imports import: Import + index: + transaction: transaction merchants: create: success: New merchant created successfully diff --git a/config/routes.rb b/config/routes.rb index 4afc8266..56d9987c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -43,8 +43,6 @@ Rails.application.routes.draw do resources :transactions do collection do - match "search" => "transactions#search", via: %i[ get post ] - scope module: :transactions, as: :transaction do resources :rows, only: %i[ show update ] diff --git a/test/system/transactions_test.rb b/test/system/transactions_test.rb index 6eb3d975..fa3d8c89 100644 --- a/test/system/transactions_test.rb +++ b/test/system/transactions_test.rb @@ -4,6 +4,7 @@ class TransactionsTest < ApplicationSystemTestCase setup do sign_in @user = users(:family_admin) + @latest_transactions = @user.family.transactions.ordered.limit(20).to_a @test_category = @user.family.transaction_categories.create! name: "System Test Category" @test_merchant = @user.family.transaction_merchants.create! name: "System Test Merchant" @target_txn = @user.family.accounts.first.transactions.create! \ @@ -91,4 +92,70 @@ class TransactionsTest < ApplicationSystemTestCase assert_selector "#" + dom_id(@user.family.transactions.ordered.first), count: 1 end + + test "can select and deselect entire page of transactions" do + all_transactions_checkbox.check + assert_selection_count(number_of_transactions_on_page) + all_transactions_checkbox.uncheck + assert_selection_count(0) + end + + test "can select and deselect groups of transactions" do + date_transactions_checkbox(12.days.ago.to_date).check + assert_selection_count(3) + date_transactions_checkbox(12.days.ago.to_date).uncheck + assert_selection_count(0) + end + + test "can select and deselect individual transactions" do + transaction_checkbox(@latest_transactions.first).check + assert_selection_count(1) + transaction_checkbox(@latest_transactions.second).check + assert_selection_count(2) + transaction_checkbox(@latest_transactions.second).uncheck + assert_selection_count(1) + end + + test "outermost group always overrides inner selections" do + transaction_checkbox(@latest_transactions.first).check + assert_selection_count(1) + all_transactions_checkbox.check + assert_selection_count(number_of_transactions_on_page) + transaction_checkbox(@latest_transactions.first).uncheck + assert_selection_count(number_of_transactions_on_page - 1) + date_transactions_checkbox(12.days.ago.to_date).uncheck + assert_selection_count(number_of_transactions_on_page - 4) + all_transactions_checkbox.uncheck + assert_selection_count(0) + end + + private + + def number_of_transactions_on_page + page_size = 50 + + [ @user.family.transactions.count, page_size ].min + end + + def all_transactions_checkbox + find("#selection_transaction") + end + + def date_transactions_checkbox(date) + find("#selection_transaction_#{date}") + end + + def transaction_checkbox(transaction) + find("#" + dom_id(transaction, "selection")) + end + + def assert_selection_count(count) + if count == 0 + assert_no_selector("#transaction-selection-bar") + else + within "#transaction-selection-bar" do + assert_text "#{count} transaction#{count == 1 ? "" : "s"} selected" + end + end + end end