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? %>
+
+ <%= render "selection_bar" %>
+
+
-
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