mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-24 07:39:39 +02:00
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
This commit is contained in:
parent
e4ac5c87e4
commit
115f792198
11 changed files with 256 additions and 8 deletions
|
@ -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 */
|
||||
|
|
101
app/javascript/controllers/bulk_select_controller.js
Normal file
101
app/javascript/controllers/bulk_select_controller.js
Normal file
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@
|
|||
<% else %>
|
||||
<div class="space-y-6">
|
||||
<% transactions.group_by(&:date).each do |date, transactions| %>
|
||||
<%= transactions_group(date, transactions) %>
|
||||
<%= transactions_group(date, transactions, "accounts/transactions/transaction") %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
17
app/views/accounts/transactions/_transaction.html.erb
Normal file
17
app/views/accounts/transactions/_transaction.html.erb
Normal file
|
@ -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 %>
|
||||
<div class="col-span-4">
|
||||
<%= render "transactions/name", transaction: transaction %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3">
|
||||
<%= render "transactions/categories/badge", category: transaction.category %>
|
||||
</div>
|
||||
|
||||
<%= link_to transaction.account.name,
|
||||
account_path(transaction.account),
|
||||
class: ["col-span-3 hover:underline"] %>
|
||||
|
||||
<div class="col-span-2 ml-auto">
|
||||
<%= render "transactions/amount", transaction: transaction %>
|
||||
</div>
|
||||
<% end %>
|
17
app/views/transactions/_date_group.html.erb
Normal file
17
app/views/transactions/_date_group.html.erb
Normal file
|
@ -0,0 +1,17 @@
|
|||
<%# locals: (date:, transactions:) %>
|
||||
<div class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
|
||||
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<div class="flex pl-0.5 items-center gap-4">
|
||||
<%= 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}" %>
|
||||
</div>
|
||||
|
||||
<%= tag.span format_money(-transactions.sum(&:amount_money)) %>
|
||||
</div>
|
||||
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
|
||||
<%= render transactions %>
|
||||
</div>
|
||||
</div>
|
17
app/views/transactions/_selection_bar.html.erb
Normal file
17
app/views/transactions/_selection_bar.html.erb
Normal file
|
@ -0,0 +1,17 @@
|
|||
<div class="fixed bottom-6 z-10 flex items-center justify-between rounded-xl bg-gray-900 px-4 text-sm text-white w-[420px] py-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= check_box_tag "transaction_selection", 1, true, class: "maybe-checkbox maybe-checkbox--dark", data: { action: "bulk-select#deselectAll" } %>
|
||||
|
||||
<p data-bulk-select-target="selectionBarText"></p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 text-gray-500">
|
||||
<%= 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 %>
|
||||
</div>
|
||||
</div>
|
|
@ -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 %>
|
||||
<div class="col-span-4">
|
||||
<div class="col-span-4 flex items-center gap-4">
|
||||
<%= 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 %>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,23 +1,32 @@
|
|||
<div class="space-y-4">
|
||||
|
||||
<%= render "header" %>
|
||||
|
||||
<%= render partial: "transactions/summary", locals: { totals: @totals } %>
|
||||
|
||||
<div id="transactions" class="bg-white rounded-xl border border-alpha-black-25 shadow-xs p-4 space-y-4">
|
||||
<div id="transactions" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".transaction") %>" 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? %>
|
||||
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
|
||||
<%= render "selection_bar" %>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 bg-gray-25 rounded-xl px-5 py-3 text-xs uppercase font-medium text-gray-500 items-center mb-4">
|
||||
<div class="pl-0.5 col-span-4 flex items-center gap-4">
|
||||
<%= check_box_tag "selection_transaction",
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
data: { action: "bulk-select#togglePageSelection" } %>
|
||||
<p class="col-span-4">transaction</p>
|
||||
</div>
|
||||
|
||||
<p class="col-span-3 pl-4">category</p>
|
||||
<p class="col-span-3">account</p>
|
||||
<p class="col-span-2 justify-self-end">amount</p>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<% @transactions.group_by(&:date).each do |date, transactions| %>
|
||||
<%= transactions_group(date, transactions) %>
|
||||
<%= render partial: "date_group", locals: { date:, transactions: } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ]
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue