feat: Add duplicate button when a transaction is selected (#1123)

* feat: Add duplicate button when a transaction is selected

* feat: add merchant field

* feat: add duplicate transaction btn 2
This commit is contained in:
Renzo
2026-03-15 17:05:01 +01:00
committed by GitHub
parent 3ac19bae2e
commit 581d3684b2
6 changed files with 99 additions and 1 deletions

View File

@@ -5,7 +5,11 @@ class TransactionsController < ApplicationController
before_action :store_params!, only: :index
def new
prefill_params_from_duplicate!
super
apply_duplicate_attributes!
@income_categories = Current.family.categories.incomes.alphabetically
@expense_categories = Current.family.categories.expenses.alphabetically
@categories = Current.family.categories.alphabetically
end
@@ -306,6 +310,35 @@ class TransactionsController < ApplicationController
end
private
def duplicate_source
return @duplicate_source if defined?(@duplicate_source)
@duplicate_source = if params[:duplicate_entry_id].present?
source = Current.family.entries.find_by(id: params[:duplicate_entry_id])
source if source&.transaction?
end
end
def prefill_params_from_duplicate!
return unless duplicate_source
params[:nature] ||= duplicate_source.amount.negative? ? "inflow" : "outflow"
params[:account_id] ||= duplicate_source.account_id.to_s
end
def apply_duplicate_attributes!
return unless duplicate_source
@entry.assign_attributes(
name: duplicate_source.name,
amount: duplicate_source.amount.abs,
currency: duplicate_source.currency,
notes: duplicate_source.notes
)
@entry.entryable.assign_attributes(
category_id: duplicate_source.entryable.category_id,
merchant_id: duplicate_source.entryable.merchant_id
)
@entry.entryable.tag_ids = duplicate_source.entryable.tag_ids
end
def set_entry_for_unlock
transaction = Current.family.transactions.find(params[:id])
@entry = transaction.entry

View File

@@ -8,6 +8,7 @@ export default class extends Controller {
"selectionBar",
"selectionBarText",
"bulkEditDrawerHeader",
"duplicateLink",
];
static values = {
singularLabel: String,
@@ -135,6 +136,18 @@ export default class extends Controller {
this.selectionBarTarget.classList.toggle("hidden", count === 0);
this.selectionBarTarget.querySelector("input[type='checkbox']").checked =
count > 0;
if (this.hasDuplicateLinkTarget) {
this.duplicateLinkTarget.classList.toggle("hidden", count !== 1);
if (count === 1) {
const url = new URL(
this.duplicateLinkTarget.href,
window.location.origin,
);
url.searchParams.set("duplicate_entry_id", this.selectedIdsValue[0]);
this.duplicateLinkTarget.href = url.toString();
}
}
}
_pluralizedResourceName() {

View File

@@ -30,6 +30,11 @@
<%= render DS::Disclosure.new(title: t(".details")) do %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :merchant_id,
Current.family.available_merchants.alphabetically,
:id, :name,
{ include_blank: t(".none"),
label: t(".merchant_label") } %>
<%= ef.select :tag_ids,
Current.family.tags.alphabetically.pluck(:name, :id),
{

View File

@@ -7,9 +7,20 @@
<div class="flex items-center gap-1 text-secondary">
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
<%= link_to new_transaction_path,
class: "p-1.5 group/duplicate hover:bg-inverse flex items-center justify-center rounded-md hidden",
title: t("transactions.selection_bar.duplicate"),
data: {
turbo_frame: "modal",
bulk_select_target: "duplicateLink"
} do %>
<%= icon "copy", class: "group-hover/duplicate:text-inverse" %>
<% end %>
<%= link_to new_transactions_bulk_update_path,
class: "p-1.5 group/edit hover:bg-inverse flex items-center justify-center rounded-md",
title: "Edit",
title: t("transactions.selection_bar.edit"),
data: { turbo_frame: "bulk_transaction_edit_drawer" } do %>
<%= icon "pencil-line", class: "group-hover/edit:text-inverse" %>
<% end %>

View File

@@ -2,6 +2,9 @@
en:
transactions:
unknown_name: Unknown transaction
selection_bar:
duplicate: Duplicate
edit: Edit
form:
account: Account
account_prompt: Select an Account
@@ -13,6 +16,7 @@ en:
description_placeholder: Describe transaction
expense: Expense
income: Income
merchant_label: Merchant
none: (none)
note_label: Notes
note_placeholder: Enter a note

View File

@@ -309,6 +309,38 @@ end
assert_not entry.protected_from_sync?
end
test "new with duplicate_entry_id pre-fills form from source transaction" do
@entry.reload
get new_transaction_url(duplicate_entry_id: @entry.id)
assert_response :success
assert_select "input[name='entry[name]'][value=?]", @entry.name
assert_select "input[type='number'][name='entry[amount]']" do |elements|
assert_equal sprintf("%.2f", @entry.amount.abs), elements.first["value"]
end
assert_select "input[type='hidden'][name='entry[entryable_attributes][merchant_id]']"
end
test "new with invalid duplicate_entry_id renders empty form" do
get new_transaction_url(duplicate_entry_id: -1)
assert_response :success
assert_select "input[name='entry[name]']" do |elements|
assert_nil elements.first["value"]
end
end
test "new with duplicate_entry_id from another family does not prefill form" do
other_family = families(:empty)
other_account = other_family.accounts.create!(name: "Other", balance: 0, currency: "USD", accountable: Depository.new)
other_entry = create_transaction(account: other_account, name: "Should not leak", amount: 50)
get new_transaction_url(duplicate_entry_id: other_entry.id)
assert_response :success
assert_select "input[name='entry[name]']" do |elements|
assert_nil elements.first["value"]
end
end
test "unlock clears import_locked flag" do
family = families(:empty)
sign_in users(:empty)