Fix label creation dropdown refresh (#363)

## Summary
- keep newly created labels available in the combobox without reload
- propagate created labels through transaction forms and bulk actions
- add browser coverage for creating a label from transaction label
dropdown

## Tests
- php artisan test --compact tests/Browser/TransactionsTest.php
--filter='newly created labels'
- vendor/bin/pint --dirty --format agent
- npm run build

Note: `npm run types` still fails on pre-existing TypeScript errors
outside this change.
This commit is contained in:
Víctor Falcón 2026-05-07 10:26:43 +01:00 committed by GitHub
parent caae0e8918
commit 164235f6d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 117 additions and 5 deletions

View File

@ -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<Label[]>([]);
const mergedLabels = useMemo(() => {
const labelsById = new Map<string, Label>();
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"
>
<Plus className="h-4 w-4" />
{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}
>
<div className="flex items-center gap-2">
<div

View File

@ -32,6 +32,7 @@ interface BulkActionsBarProps {
labels?: Label[];
onCategoryChange: (categoryId: number | null) => 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({
<BulkLabelSelect
labels={labels}
onLabelsChange={onLabelsChange}
onLabelCreated={onLabelCreated}
disabled={isUpdating}
/>

View File

@ -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<string[]>([]);
@ -31,6 +33,7 @@ export function BulkLabelSelect({
triggerClassName="h-9 w-[180px] min-h-9"
allowCreate={true}
allowRemoveAll={true}
onLabelCreated={onLabelCreated}
/>
);
}

View File

@ -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}
/>
</div>

View File

@ -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<Label[]>(() => 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<DecryptedTransaction[]>(
[],
@ -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}

View File

@ -368,7 +368,23 @@ export default function Transactions({
automationRules,
}: Props) {
const locale = useLocale();
const labels = initialLabels;
const [labels, setLabels] = useState<Label[]>(() => 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}

View File

@ -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']);