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:
parent
115f792198
commit
d3f9be15f1
8 changed files with 225 additions and 95 deletions
|
@ -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 */
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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 %>
|
||||
<%= 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>
|
||||
|
|
|
@ -1,18 +1,22 @@
|
|||
<%= drawer do %>
|
||||
<h3 class="font-medium mb-1">
|
||||
<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>
|
||||
<span class="text-sm text-gray-500"><%= @transaction.date.strftime("%A %d %B") %></span>
|
||||
|
||||
<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>
|
||||
<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" %>
|
||||
|
@ -20,48 +24,29 @@
|
|||
<%= 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>
|
||||
</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(".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" 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>
|
||||
</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>
|
||||
|
||||
<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="mb-2">
|
||||
<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),
|
||||
|
@ -72,10 +57,49 @@
|
|||
},
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
</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 %>
|
||||
</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>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ]
|
||||
|
||||
|
|
|
@ -97,13 +97,16 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
|
|||
|
||||
test "incomes are negative" do
|
||||
assert_difference("Transaction.count") do
|
||||
post transactions_url, params: { transaction: {
|
||||
post transactions_url, params: {
|
||||
transaction: {
|
||||
nature: "income",
|
||||
account_id: @transaction.account_id,
|
||||
amount: @transaction.amount,
|
||||
currency: @transaction.currency,
|
||||
date: @transaction.date,
|
||||
name: @transaction.name } }
|
||||
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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue