feat(transactions): add delete button to the transaction edit modal (#697)
## Why Until now the only way to delete a transaction was the row's three-dots menu in the list. Many users click a transaction to open the edit modal and look for a delete option there — but there wasn't one. This adds a **Delete** button inside the edit modal, with the same confirmation step. ## What - Adds a destructive **Delete** button to the transaction edit modal footer (`edit-transaction-dialog.tsx`), shown only in **edit** mode. - Clicking it closes the modal and opens the **existing** delete-confirmation dialog — the same `AlertDialog` (and manual-account "update balance" checkbox) already used by the three-dots menu. No new delete logic, confirmation UI, or copy was introduced. - Wired via a new optional `onDelete` prop, passed as `setDeleteTransaction` from the two parents that render the edit modal (`transaction-list.tsx`, `pages/transactions/index.tsx`). Because `transaction-list` also backs the Accounts and Budgets pages, the button appears consistently everywhere the edit modal opens. ## Reviews applied Ran technical + product reviews on the diff. Applied: added `dark:hover:bg-destructive/20` to match the sibling delete button's dark-mode hover. Dropped (with reason): premature shared-component extraction, pre-existing cross-file duplication, and pre-existing "no toast on delete failure" — all out of scope for this change. ## QA Real browser QA (Playwright) against the running app on real data: - Delete button appears in the edit modal (verified on an imported transaction). - Delete → confirmation dialog opens and the edit modal closes. - **Cancel** returns to the list; page stays fully interactive (`body` pointer-events `auto` — no stuck Radix scroll-lock). - **Confirm** removes the row from the list and soft-deletes it in the DB (verified `deleted_at` was set). Restored afterward. - Page remains interactive after the stacked-dialog flow (reopened another transaction cleanly). Unit tests added in `edit-transaction-dialog.test.tsx` cover: click delegates to `onDelete` + closes the modal; button hidden without a handler; button hidden in create mode. ## Demo https://github.com/user-attachments/assets/e13dad30-0779-46f2-8cc4-84ea70568741
This commit is contained in:
parent
2d7e905ddd
commit
89174af4e6
|
|
@ -1,4 +1,4 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EditTransactionDialog } from './edit-transaction-dialog';
|
||||
|
|
@ -342,4 +342,92 @@ describe('EditTransactionDialog', () => {
|
|||
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const manualTransaction = {
|
||||
id: 'tx-manual',
|
||||
user_id: 'user-1',
|
||||
account_id: 'account-1',
|
||||
category_id: null,
|
||||
description: 'Groceries',
|
||||
description_iv: null,
|
||||
transaction_date: '2026-05-27',
|
||||
amount: -1200,
|
||||
currency_code: 'EUR',
|
||||
notes: null,
|
||||
notes_iv: null,
|
||||
creditor_name: null,
|
||||
debtor_name: null,
|
||||
source: 'manually_created' as const,
|
||||
created_at: '2026-05-27T00:00:00Z',
|
||||
updated_at: '2026-05-27T00:00:00Z',
|
||||
decryptedDescription: 'Groceries',
|
||||
decryptedNotes: null,
|
||||
label_ids: [],
|
||||
};
|
||||
|
||||
it('closes the dialog and asks the parent to delete when Delete is clicked', () => {
|
||||
const onDelete = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
render(
|
||||
<EditTransactionDialog
|
||||
transaction={manualTransaction}
|
||||
categories={[]}
|
||||
accounts={[checkingAccount]}
|
||||
banks={[]}
|
||||
labels={[]}
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
onSuccess={vi.fn()}
|
||||
onDelete={onDelete}
|
||||
mode="edit"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(onDelete).toHaveBeenCalledWith(manualTransaction);
|
||||
});
|
||||
|
||||
it('hides the Delete button when no onDelete handler is provided', () => {
|
||||
render(
|
||||
<EditTransactionDialog
|
||||
transaction={manualTransaction}
|
||||
categories={[]}
|
||||
accounts={[checkingAccount]}
|
||||
banks={[]}
|
||||
labels={[]}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
mode="edit"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Delete' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the Delete button in create mode', () => {
|
||||
render(
|
||||
<EditTransactionDialog
|
||||
transaction={null}
|
||||
categories={[]}
|
||||
accounts={[checkingAccount]}
|
||||
banks={[]}
|
||||
labels={[]}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
mode="create"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Delete' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { formatDate } from '@/utils/date';
|
|||
import { __ } from '@/utils/i18n';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { getYear, parseISO } from 'date-fns';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
|
|
@ -61,6 +62,7 @@ interface EditTransactionDialogProps {
|
|||
source: 'edit_transaction_modal',
|
||||
) => void;
|
||||
onLabelCreated?: (label: Label) => void;
|
||||
onDelete?: (transaction: DecryptedTransaction) => void;
|
||||
mode: 'create' | 'edit';
|
||||
initialAccountId?: string | null;
|
||||
}
|
||||
|
|
@ -77,6 +79,7 @@ export function EditTransactionDialog({
|
|||
onSuccess,
|
||||
onCategorized,
|
||||
onLabelCreated,
|
||||
onDelete,
|
||||
mode,
|
||||
initialAccountId = null,
|
||||
}: EditTransactionDialogProps) {
|
||||
|
|
@ -867,6 +870,21 @@ export function EditTransactionDialog({
|
|||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{mode === 'edit' && onDelete && transaction && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
onDelete(transaction);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive sm:mr-auto dark:hover:bg-destructive/20"
|
||||
>
|
||||
<Trash2 />
|
||||
{__('Delete')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -1111,6 +1111,7 @@ export function TransactionList({
|
|||
onSuccess={updateTransaction}
|
||||
onCategorized={showAutomatizeToast}
|
||||
onLabelCreated={handleLabelCreated}
|
||||
onDelete={setDeleteTransaction}
|
||||
mode="edit"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -1542,6 +1542,7 @@ export default function Transactions({
|
|||
onSuccess={updateTransaction}
|
||||
onCategorized={showAutomatizeToast}
|
||||
onLabelCreated={handleLabelCreated}
|
||||
onDelete={setDeleteTransaction}
|
||||
mode="edit"
|
||||
/>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue