diff --git a/resources/js/components/shared/label-combobox.tsx b/resources/js/components/shared/label-combobox.tsx index 29b1e0fa..0bd53974 100644 --- a/resources/js/components/shared/label-combobox.tsx +++ b/resources/js/components/shared/label-combobox.tsx @@ -18,7 +18,7 @@ import { cn } from '@/lib/utils'; import { getLabelColorClasses, LABEL_COLORS, type Label } from '@/types/label'; import { __ } from '@/utils/i18n'; import { Check, ChevronsUpDown, Plus, Tag, X } from 'lucide-react'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; interface LabelComboboxProps { value?: string[] | null; @@ -46,9 +46,20 @@ export function LabelCombobox({ const [open, setOpen] = useState(false); const [inputValue, setInputValue] = useState(''); const [isCreating, setIsCreating] = useState(false); + const [createdLabels, setCreatedLabels] = useState([]); + + const mergedLabels = useMemo(() => { + const labelsById = new Map(); + + for (const label of [...(labels ?? []), ...createdLabels]) { + labelsById.set(label.id, label); + } + + return Array.from(labelsById.values()); + }, [labels, createdLabels]); const { value: safeValue, labels: safeLabels } = - normalizeLabelComboboxState(value, labels); + normalizeLabelComboboxState(value, mergedLabels); const selectedLabels = safeLabels.filter((l) => safeValue.includes(l.id)); @@ -112,6 +123,19 @@ export function LabelCombobox({ const newLabel = data.data || data; if (newLabel) { + setCreatedLabels((previousLabels) => { + const existingLabelIndex = previousLabels.findIndex( + (label) => label.id === newLabel.id, + ); + + if (existingLabelIndex === -1) { + return [...previousLabels, newLabel]; + } + + return previousLabels.map((label) => + label.id === newLabel.id ? newLabel : label, + ); + }); onValueChange([...safeValue, newLabel.id]); setInputValue(''); onLabelCreated?.(newLabel); @@ -142,6 +166,7 @@ export function LabelCombobox({ triggerClassName, )} disabled={disabled} + data-testid="label-combobox-trigger" onClick={(e) => e.stopPropagation()} > {selectedLabels.length > 0 ? ( @@ -225,6 +250,7 @@ export function LabelCombobox({ onSelect={handleCreate} disabled={isCreating} className="gap-2" + data-testid="label-create-option" > {isCreating @@ -250,6 +276,8 @@ export function LabelCombobox({ key={label.id} value={label.name} onSelect={() => handleSelect(label.id)} + data-testid="label-option" + data-label-name={label.name} >
void; onLabelsChange: (labelIds: string[]) => void; + onLabelCreated?: (label: Label) => void; onDelete: () => void; onReEvaluateRules: () => void; onSelectAll?: () => void; @@ -46,6 +47,7 @@ export function BulkActionsBar({ labels = [], onCategoryChange, onLabelsChange, + onLabelCreated, onDelete, onReEvaluateRules, onSelectAll, @@ -111,6 +113,7 @@ export function BulkActionsBar({ diff --git a/resources/js/components/transactions/bulk-label-select.tsx b/resources/js/components/transactions/bulk-label-select.tsx index da41acc5..d24352c3 100644 --- a/resources/js/components/transactions/bulk-label-select.tsx +++ b/resources/js/components/transactions/bulk-label-select.tsx @@ -6,12 +6,14 @@ import { useState } from 'react'; interface BulkLabelSelectProps { labels: Label[]; onLabelsChange: (labelIds: string[]) => void; + onLabelCreated?: (label: Label) => void; disabled?: boolean; } export function BulkLabelSelect({ labels, onLabelsChange, + onLabelCreated, disabled = false, }: BulkLabelSelectProps) { const [value, setValue] = useState([]); @@ -31,6 +33,7 @@ export function BulkLabelSelect({ triggerClassName="h-9 w-[180px] min-h-9" allowCreate={true} allowRemoveAll={true} + onLabelCreated={onLabelCreated} /> ); } diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index f7263bc5..adbf3752 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -57,6 +57,7 @@ interface EditTransactionDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onSuccess: (transaction: DecryptedTransaction) => void; + onLabelCreated?: (label: Label) => void; mode: 'create' | 'edit'; } @@ -70,6 +71,7 @@ export function EditTransactionDialog({ open, onOpenChange, onSuccess, + onLabelCreated, mode, }: EditTransactionDialogProps) { const locale = useLocale(); @@ -793,6 +795,7 @@ export function EditTransactionDialog({ disabled={isSubmitting} placeholder={__('Add labels...')} allowCreate={true} + onLabelCreated={onLabelCreated} />
diff --git a/resources/js/components/transactions/transaction-list.tsx b/resources/js/components/transactions/transaction-list.tsx index 422d3417..b913f76f 100644 --- a/resources/js/components/transactions/transaction-list.tsx +++ b/resources/js/components/transactions/transaction-list.tsx @@ -233,7 +233,7 @@ export function TransactionList({ categories, accounts, banks, - labels: initialLabels = [], + labels: initialLabels, automationRules = [], accountId, transactions: providedTransactions, @@ -246,7 +246,23 @@ export function TransactionList({ }: TransactionListProps) { const { isKeySet } = useEncryptionKey(); const locale = useLocale(); - const labels = initialLabels; + const [labels, setLabels] = useState(() => initialLabels ?? []); + + useEffect(() => { + setLabels(initialLabels ?? []); + }, [initialLabels]); + + const handleLabelCreated = useCallback((label: Label) => { + setLabels((previousLabels) => { + const nextLabels = previousLabels.filter( + (existingLabel) => existingLabel.id !== label.id, + ); + + return [...nextLabels, label].sort((a, b) => + a.name.localeCompare(b.name), + ); + }); + }, []); const [transactions, setTransactions] = useState( [], @@ -1312,6 +1328,7 @@ export function TransactionList({ open={!!editTransaction} onOpenChange={(open) => !open && setEditTransaction(null)} onSuccess={updateTransaction} + onLabelCreated={handleLabelCreated} mode="edit" /> @@ -1325,6 +1342,7 @@ export function TransactionList({ open={createDialogOpen} onOpenChange={setCreateDialogOpen} onSuccess={() => {}} + onLabelCreated={handleLabelCreated} mode="create" /> @@ -1379,6 +1397,7 @@ export function TransactionList({ labels={labels} onCategoryChange={handleBulkCategoryChange} onLabelsChange={handleBulkLabelsChange} + onLabelCreated={handleLabelCreated} onDelete={handleBulkDeleteClick} onReEvaluateRules={handleBulkReEvaluateRules} onClear={handleClearSelection} diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 4ba55a7f..4a0afb63 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -368,7 +368,23 @@ export default function Transactions({ automationRules, }: Props) { const locale = useLocale(); - const labels = initialLabels; + const [labels, setLabels] = useState(() => initialLabels); + + useEffect(() => { + setLabels(initialLabels); + }, [initialLabels]); + + const handleLabelCreated = useCallback((label: Label) => { + setLabels((previousLabels) => { + const nextLabels = previousLabels.filter( + (existingLabel) => existingLabel.id !== label.id, + ); + + return [...nextLabels, label].sort((a, b) => + a.name.localeCompare(b.name), + ); + }); + }, []); // Convert server transactions to DecryptedTransaction for column compatibility const [allTransactions, setAllTransactions] = useState< @@ -1143,6 +1159,7 @@ export default function Transactions({ open={!!editTransaction} onOpenChange={(open) => !open && setEditTransaction(null)} onSuccess={updateTransaction} + onLabelCreated={handleLabelCreated} mode="edit" /> @@ -1156,6 +1173,7 @@ export default function Transactions({ open={createDialogOpen} onOpenChange={setCreateDialogOpen} onSuccess={() => refreshTransactions()} + onLabelCreated={handleLabelCreated} mode="create" /> @@ -1258,6 +1276,7 @@ export default function Transactions({ labels={labels} onCategoryChange={handleBulkCategoryChange} onLabelsChange={handleBulkLabelsChange} + onLabelCreated={handleLabelCreated} onDelete={handleBulkDeleteClick} onReEvaluateRules={handleBulkReEvaluateRules} onSelectAll={handleSelectAll} diff --git a/tests/Browser/TransactionsTest.php b/tests/Browser/TransactionsTest.php index 32ea1a13..e8fe8c00 100644 --- a/tests/Browser/TransactionsTest.php +++ b/tests/Browser/TransactionsTest.php @@ -3,6 +3,7 @@ use App\Models\Account; use App\Models\Bank; use App\Models\Category; +use App\Models\Label; use App\Models\Transaction; use App\Models\User; @@ -38,6 +39,42 @@ it('can open add transaction dialog', function () { ->assertNoJavascriptErrors(); }); +it('shows newly created labels in the transaction label dropdown without refreshing', function () { + $user = User::factory()->onboarded()->create(); + $bank = Bank::factory()->create(['name' => 'Label Bank']); + Category::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Sports', + ]); + Account::factory()->create([ + 'user_id' => $user->id, + 'bank_id' => $bank->id, + 'name' => 'Label Account', + 'currency_code' => 'USD', + 'type' => 'checking', + ]); + + actingAs($user); + + $page = visit('/transactions'); + + $page->assertSee('Transactions') + ->click('Transaction') + ->waitForText('Create Transaction', 5) + ->click('[data-testid="label-combobox-trigger"]') + ->fill('input[placeholder="Search or create labels..."]', 'Padel') + ->waitForText('Create "Padel"', 5) + ->click('[data-testid="label-create-option"]') + ->waitForText('Padel', 5) + ->assertPresent('[data-testid="label-option"][data-label-name="Padel"]') + ->assertNoJavascriptErrors(); + + expect(Label::query() + ->where('user_id', $user->id) + ->where('name', 'Padel') + ->exists())->toBeTrue(); +}); + it('can create a transaction', function () { $user = User::factory()->onboarded()->create(); $bank = Bank::factory()->create(['name' => 'Test Bank']);