1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 13:19:39 +02:00

Bulk transaction deletion (#845)

* Clean up transaction show view, add delete button

* Clean up tailwind global styles, add switch

* Bulk deletion controller and tests

* Normalize translations

* Add bulk deletion button and form
This commit is contained in:
Zach Gollwitzer 2024-06-07 16:56:30 -04:00 committed by GitHub
parent 115f792198
commit d3f9be15f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 225 additions and 95 deletions

View file

@ -20,7 +20,7 @@
}
th {
@apply whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900;
@apply whitespace-nowrap px-2 text-left text-sm font-semibold text-gray-900 py-3.5;
}
tbody {
@ -28,22 +28,22 @@
}
td {
@apply px-2 py-2 text-sm text-gray-500 whitespace-nowrap;
@apply whitespace-nowrap px-2 py-2 text-sm text-gray-500;
}
}
.form-field {
@apply relative border border-alpha-black-100 bg-white rounded-md shadow-xs;
@apply focus-within:shadow-none focus-within:border-gray-900 focus-within:ring-4 focus-within:ring-gray-100;
@apply relative rounded-md border bg-white border-alpha-black-100 shadow-xs;
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
}
.form-field__label {
@apply px-3 pt-2 pb-0 block text-xs text-gray-500;
@apply block px-3 pt-2 pb-0 text-xs text-gray-500;
}
.form-field__input {
@apply px-3 pb-2 pt-1 text-sm w-full bg-transparent border-none opacity-100;
@apply focus:outline-none focus:ring-0 focus:opacity-100;
@apply w-full border-none bg-transparent px-3 pt-1 pb-2 text-sm opacity-100;
@apply focus:opacity-100 focus:outline-none focus:ring-0;
@apply placeholder-shown:opacity-50;
@apply disabled:opacity-50;
}
@ -53,7 +53,7 @@
}
.form-field__submit {
@apply w-full p-3 text-center text-white bg-black rounded-lg cursor-pointer hover:bg-gray-700;
@apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
}
input:checked + label + .toggle-switch-dot {
@ -65,7 +65,7 @@
}
[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;
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
}
[type='checkbox'].maybe-checkbox--dark {
@ -75,6 +75,12 @@
[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");
}
.maybe-switch {
@apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer;
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out;
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
}
}
/* Small, single purpose classes that should take precedence over other styles */

View file

@ -52,6 +52,21 @@ class TransactionsController < ApplicationController
redirect_to transactions_url, notice: t(".success")
end
def bulk_delete
destroyed = Current.family.transactions.destroy_by(id: bulk_delete_params[:transaction_ids])
redirect_to transactions_url, notice: t(".success", count: destroyed.count)
end
def bulk_update
transactions = Current.family.transactions.where(id: bulk_update_params[:transaction_ids])
updates = bulk_update_params.except(:transaction_ids)
if transactions.update_all(bulk_update_params.except(:transaction_ids).to_h)
redirect_to transactions_url, notice: t(".success", count: transactions.count)
else
render :index, status: :unprocessable_entity, notice: t(".failure")
end
end
private
def set_transaction
@ -70,11 +85,19 @@ class TransactionsController < ApplicationController
params[:transaction][:nature].to_s.inquiry
end
def bulk_delete_params
params.require(:bulk_delete).permit(transaction_ids: [])
end
def bulk_update_params
params.require(:bulk_update).permit(:category_id, :excluded, :currency, tag_ids: [], transaction_ids: [])
end
def search_params
params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: [])
end
def transaction_params
params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id, tag_ids: [], taggings_attributes: [ :id, :tag_id, :_destroy ])
params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id, tag_ids: [])
end
end

View file

@ -18,6 +18,12 @@ export default class extends Controller {
document.removeEventListener("turbo:load", this.#updateView)
}
submitBulkDeletionRequest(e) {
const form = e.target.closest("form");
this.#addHiddenFormInputsForSelectedIds(form, "bulk_delete[transaction_ids][]", this.selectedIdsValue)
form.requestSubmit()
}
togglePageSelection(e) {
if (e.target.checked) {
this.#selectAll()
@ -54,6 +60,16 @@ export default class extends Controller {
this.#updateView()
}
#addHiddenFormInputsForSelectedIds(form, paramName, transactionIds) {
transactionIds.forEach(id => {
const input = document.createElement("input");
input.type = 'hidden'
input.name = paramName
input.value = id
form.appendChild(input)
})
}
#rowsForGroup(group) {
return this.rowTargets.filter(row => group.contains(row))
}

View file

@ -10,8 +10,10 @@
<%= 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" %>
<%= form_with url: bulk_delete_transactions_path, builder: ActionView::Helpers::FormBuilder, data: { turbo_confirm: true } do %>
<button type="button" data-action="bulk-select#submitBulkDeletionRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
</button>
<% end %>
</div>
</div>

View file

@ -1,81 +1,105 @@
<%= drawer do %>
<h3 class="font-medium mb-1">
<span class="text-2xl"><%= format_money @transaction.amount_money %></span>
<span class="text-lg text-gray-500"><%= @transaction.currency %></span>
</h3>
<span class="text-sm text-gray-500"><%= @transaction.date.strftime("%A %d %B") %></span>
<div>
<header class="mb-4 space-y-1">
<h3 class="font-medium">
<span class="text-2xl"><%= format_money @transaction.amount_money %></span>
<span class="text-lg text-gray-500"><%= @transaction.currency %></span>
</h3>
<details class="group" open>
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-4 group-open:mb-2">
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
Overview
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
</div>
</summary>
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
<div class="space-y-2">
<%= f.date_field :date, label: "Date", max: Date.today, "data-auto-submit-form-target": "auto" %>
<%= f.collection_select :category_id, Current.family.transaction_categories, :id, :name, { prompt: "Select a category", label: "Category", class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account", class: "text-gray-500" }, { class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled" } %>
</div>
<% end %>
</details>
<details class="group" open>
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
Description
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
</div>
</summary>
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
<%= f.text_field :name, label: "Name", "data-auto-submit-form-target": "auto" %>
<% end %>
</details>
<details class="group" open>
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
<span>Settings</span>
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
</div>
</summary>
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
<label class="flex items-center cursor-pointer justify-between mx-3">
<%= f.check_box :excluded, class: "sr-only peer", "data-auto-submit-form-target": "auto" %>
<div class="flex flex-col justify-center text-sm w-[340px] py-3">
<span class="text-gray-900 mb-1">Exclude from analytics</span>
<span class="text-gray-500">This excludes the transaction from any in-app features or analytics.</span>
<span class="text-sm text-gray-500"><%= @transaction.date.strftime("%A %d %B") %></span>
</header>
<div class="space-y-2">
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".overview") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-6">
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
<div class="space-y-2">
<%= f.date_field :date, label: "Date", max: Date.today, "data-auto-submit-form-target": "auto" %>
<%= f.collection_select :category_id, Current.family.transaction_categories, :id, :name, { prompt: "Select a category", label: "Category", class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account", class: "text-gray-500" }, { class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled" } %>
</div>
<% end %>
</div>
<div class="relative w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-100 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
<% end %>
</details>
<details class="group" open>
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 mb-2">
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
<span>Additional</span>
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
</div>
</summary>
</details>
<div class="mb-2">
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
<%= f.select :tag_ids,
options_for_select(Current.family.tags.alphabetically.pluck(:name, :id), @transaction.tag_ids),
{
multiple: true,
label: t(".select_tags"),
class: "placeholder:text-gray-500"
},
"data-auto-submit-form-target": "auto" %>
<% end %>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".description") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-6">
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
<%= f.text_field :name, label: "Name", "data-auto-submit-form-target": "auto" %>
<% end %>
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".additional") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-6 space-y-2">
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
<%= f.select :tag_ids,
options_for_select(Current.family.tags.alphabetically.pluck(:name, :id), @transaction.tag_ids),
{
multiple: true,
label: t(".select_tags"),
class: "placeholder:text-gray-500"
},
"data-auto-submit-form-target": "auto" %>
<% end %>
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
<%= f.text_area :notes, label: "Notes", placeholder: "Enter a note", "data-auto-submit-form-target": "auto" %>
<% end %>
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".settings") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-6">
<%= form_with model: @transaction, html: { class: "p-3", data: { controller: "auto-submit-form" } } do |f| %>
<div class="flex cursor-pointer items-center justify-between">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
</div>
<div class="relative inline-block select-none">
<%= f.check_box :excluded, class: "sr-only peer", "data-auto-submit-form-target": "auto" %>
<label for="transaction_excluded" class="maybe-switch"></label>
</div>
</div>
<% end %>
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".delete_title") %></h4>
<p class="text-gray-500"><%= t(".delete_subtitle") %></p>
</div>
<%= button_to t(".delete"),
transaction_path(@transaction),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
data: { turbo_confirm: true, turbo_frame: "_top" } %>
</div>
</div>
</details>
</div>
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
<%= f.text_area :notes, label: "Notes", placeholder: "Enter a note", "data-auto-submit-form-target": "auto" %>
<% end %>
</details>
</div>
<% end %>

View file

@ -1,6 +1,11 @@
---
en:
transactions:
bulk_delete:
success: "%{count} transactions deleted"
bulk_update:
failure: Could not update transactions
success: "%{count} transactions updated"
categories:
create:
success: New transaction category created successfully
@ -96,6 +101,17 @@ en:
update:
success: Merchant updated successfully
show:
additional: Additional
delete: Delete
delete_subtitle: This permanently deletes the transaction, affects your historical
balances, and cannot be undone.
delete_title: Delete transaction
description: Description
exclude_subtitle: This excludes the transaction from any in-app features or
analytics.
exclude_title: Exclude transaction
overview: Overview
select_tags: Select one or more tags
settings: Settings
update:
success: Transaction updated successfully

View file

@ -43,6 +43,9 @@ Rails.application.routes.draw do
resources :transactions do
collection do
post "bulk_delete"
post "bulk_update"
scope module: :transactions, as: :transaction do
resources :rows, only: %i[ show update ]

View file

@ -97,13 +97,16 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
test "incomes are negative" do
assert_difference("Transaction.count") do
post transactions_url, params: { transaction: {
nature: "income",
account_id: @transaction.account_id,
amount: @transaction.amount,
currency: @transaction.currency,
date: @transaction.date,
name: @transaction.name } }
post transactions_url, params: {
transaction: {
nature: "income",
account_id: @transaction.account_id,
amount: @transaction.amount,
currency: @transaction.currency,
date: @transaction.date,
name: @transaction.name
}
}
end
assert_redirected_to transactions_url
@ -122,7 +125,8 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
amount: @transaction.amount,
currency: @transaction.currency,
date: @transaction.date,
name: @transaction.name
name: @transaction.name,
tag_ids: [ Tag.first.id, Tag.second.id ]
}
}
@ -138,4 +142,40 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to transactions_url
assert_enqueued_with(job: AccountSyncJob)
end
test "can destroy many transactions at once" do
delete_count = 10
assert_difference("Transaction.count", -delete_count) do
post bulk_delete_transactions_url, params: { bulk_delete: { transaction_ids: @recent_transactions.first(delete_count).pluck(:id) } }
end
assert_redirected_to transactions_url
assert_equal "10 transactions deleted", flash[:notice]
end
test "can update many transactions at once" do
transactions = @user.family.transactions.ordered.limit(20)
transactions.each do |transaction|
transaction.update! excluded: false, currency: "USD", category_id: Transaction::Category.first.id
end
post bulk_update_transactions_url, params: {
bulk_update: {
transaction_ids: transactions.map(&:id),
excluded: true,
currency: "CAD",
category_id: Transaction::Category.second.id
}
}
assert_redirected_to transactions_url
assert_equal "#{transactions.count} transactions updated", flash[:notice]
transactions.reload.each do |transaction|
assert transaction.excluded
assert_equal "CAD", transaction.currency
assert_equal Transaction::Category.second, transaction.category
end
end
end