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 type React from 'react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { EditTransactionDialog } from './edit-transaction-dialog';
|
import { EditTransactionDialog } from './edit-transaction-dialog';
|
||||||
|
|
@ -342,4 +342,92 @@ describe('EditTransactionDialog', () => {
|
||||||
|
|
||||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
|
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 { __ } from '@/utils/i18n';
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import { getYear, parseISO } from 'date-fns';
|
import { getYear, parseISO } from 'date-fns';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
|
@ -61,6 +62,7 @@ interface EditTransactionDialogProps {
|
||||||
source: 'edit_transaction_modal',
|
source: 'edit_transaction_modal',
|
||||||
) => void;
|
) => void;
|
||||||
onLabelCreated?: (label: Label) => void;
|
onLabelCreated?: (label: Label) => void;
|
||||||
|
onDelete?: (transaction: DecryptedTransaction) => void;
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
initialAccountId?: string | null;
|
initialAccountId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -77,6 +79,7 @@ export function EditTransactionDialog({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onCategorized,
|
onCategorized,
|
||||||
onLabelCreated,
|
onLabelCreated,
|
||||||
|
onDelete,
|
||||||
mode,
|
mode,
|
||||||
initialAccountId = null,
|
initialAccountId = null,
|
||||||
}: EditTransactionDialogProps) {
|
}: EditTransactionDialogProps) {
|
||||||
|
|
@ -867,6 +870,21 @@ export function EditTransactionDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -1111,6 +1111,7 @@ export function TransactionList({
|
||||||
onSuccess={updateTransaction}
|
onSuccess={updateTransaction}
|
||||||
onCategorized={showAutomatizeToast}
|
onCategorized={showAutomatizeToast}
|
||||||
onLabelCreated={handleLabelCreated}
|
onLabelCreated={handleLabelCreated}
|
||||||
|
onDelete={setDeleteTransaction}
|
||||||
mode="edit"
|
mode="edit"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1542,6 +1542,7 @@ export default function Transactions({
|
||||||
onSuccess={updateTransaction}
|
onSuccess={updateTransaction}
|
||||||
onCategorized={showAutomatizeToast}
|
onCategorized={showAutomatizeToast}
|
||||||
onLabelCreated={handleLabelCreated}
|
onLabelCreated={handleLabelCreated}
|
||||||
|
onDelete={setDeleteTransaction}
|
||||||
mode="edit"
|
mode="edit"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue