From d3f9be15f17edaf5ca978125cf31af36c744242c Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 7 Jun 2024 16:56:30 -0400 Subject: [PATCH] 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 --- .../stylesheets/application.tailwind.css | 24 ++- app/controllers/transactions_controller.rb | 25 ++- .../controllers/bulk_select_controller.js | 16 ++ .../transactions/_selection_bar.html.erb | 6 +- app/views/transactions/show.html.erb | 174 ++++++++++-------- config/locales/views/transactions/en.yml | 16 ++ config/routes.rb | 3 + .../transactions_controller_test.rb | 56 +++++- 8 files changed, 225 insertions(+), 95 deletions(-) diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index e8b37c80..93ebbb55 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -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 */ diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index bc12c677..b00212dc 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -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 diff --git a/app/javascript/controllers/bulk_select_controller.js b/app/javascript/controllers/bulk_select_controller.js index c5ff2c1c..124ddf9c 100644 --- a/app/javascript/controllers/bulk_select_controller.js +++ b/app/javascript/controllers/bulk_select_controller.js @@ -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)) } diff --git a/app/views/transactions/_selection_bar.html.erb b/app/views/transactions/_selection_bar.html.erb index d3264ec8..4e42d7dc 100644 --- a/app/views/transactions/_selection_bar.html.erb +++ b/app/views/transactions/_selection_bar.html.erb @@ -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 %> + <% end %> diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index a4649eb4..1436ce46 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -1,81 +1,105 @@ <%= drawer do %> -

- <%= format_money @transaction.amount_money %> - <%= @transaction.currency %> -

- <%= @transaction.date.strftime("%A %d %B") %> +
+
+

+ <%= format_money @transaction.amount_money %> + <%= @transaction.currency %> +

-
- -
- 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") %> -
-
- <%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %> -
- <%= 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" } %> -
- <% end %> -
-
- -
- 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") %> -
-
- <%= 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 %> -
-
- -
- Settings - <%= 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") %> -
-
- <%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %> -
+ +
+
+ +

<%= t(".overview") %>

+ <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> +
+ +
+ <%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %> +
+ <%= 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" } %> +
+ <% end %>
-
- - <% end %> -
-
- -
- Additional - <%= 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") %> -
-
+
-
- <%= 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 %> +
+ +

<%= t(".description") %>

+ <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> +
+ +
+ <%= 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 %> +
+
+ +
+ +

<%= t(".additional") %>

+ <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> +
+ +
+ <%= 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 %> +
+
+ +
+ +

<%= t(".settings") %>

+ <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> +
+ +
+ + <%= form_with model: @transaction, html: { class: "p-3", data: { controller: "auto-submit-form" } } do |f| %> +
+
+

<%= t(".exclude_title") %>

+

<%= t(".exclude_subtitle") %>

+
+ +
+ <%= f.check_box :excluded, class: "sr-only peer", "data-auto-submit-form-target": "auto" %> + +
+
+ <% end %> + +
+
+

<%= t(".delete_title") %>

+

<%= t(".delete_subtitle") %>

+
+ + <%= 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" } %> +
+
+
- - <%= 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 %> - +
<% end %> diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index f7469940..08c99e3d 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 56d9987c..974229ef 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 ] diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb index 839e38c6..94d36aff 100644 --- a/test/controllers/transactions_controller_test.rb +++ b/test/controllers/transactions_controller_test.rb @@ -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