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:
parent
caae0e8918
commit
164235f6d3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
Loading…
Reference in New Issue