feat(transactions): reorder filters and switch accounts to a logo dropdown (#598)

## What

Updates the transaction filters popover:

- **Reordered** the filter sections to: **Date, Amount, Category,
Labels, Accounts, AI, Counterparties**.
- **Accounts** now use a searchable dropdown (the same pattern as
Categories/Labels) instead of toggle badges. Each option shows the
**bank logo** as its icon (with a `Building2` fallback for accounts
without a logo/bank).

## Why

The badge list didn't scale well with many accounts and was visually
inconsistent with the other multi-select filters. A dropdown keeps the
popover compact and adds search.

## Notes

- Fixed `handleAccountToggle` parameter type (`number` → `UUID`) to
match `accountIds`.
- Each account `CommandItem` uses a unique `value` (`name + id`) so
accounts that share a name aren't collapsed by cmdk's default filtering.
- Added Spanish translations for the new keys (`Select accounts...`,
`Search accounts...`).

## Tests

- New `transaction-filters.test.tsx` covering select/deselect of an
account and the empty state.
- Full front-end suite green (244 tests), ESLint/Prettier clean,
localization test passes.
This commit is contained in:
Víctor Falcón 2026-06-26 20:09:02 +02:00 committed by GitHub
parent 4038e60fbc
commit d7bc4e6707
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 247 additions and 52 deletions

View File

@ -1350,6 +1350,7 @@
"Savings rate": "Tasa de ahorro",
"Search": "Buscar",
"Search bank...": "Buscar banco...",
"Search accounts...": "Buscar cuentas...",
"Search banks...": "Buscar bancos...",
"Search categories...": "Buscar categorías...",
"Search description or notes...": "Buscar descripción o notas...",
@ -1384,6 +1385,7 @@
"Select an account": "Selecciona una cuenta",
"Select an icon": "Seleccionar un icono",
"Select at least a category or a label to track.": "Selecciona al menos una categoría o etiqueta para rastrear.",
"Select accounts...": "Seleccionar cuentas...",
"Select balance column": "Seleccionar columna de balance",
"Select balance column (optional)": "Seleccionar columna de balance (opcional)",
"Select bank...": "Seleccionar banco...",

View File

@ -0,0 +1,118 @@
import { type Account } from '@/types/account';
import { type TransactionFilters as FiltersType } from '@/types/transaction';
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TransactionFilters } from './transaction-filters';
const accounts: Account[] = [
{
id: 'acc-1',
name: 'Checking',
name_iv: null,
encrypted: false,
bank: {
id: 'bank-1',
user_id: null,
name: 'Acme Bank',
logo: 'https://example.com/acme.png',
},
type: 'checking',
currency_code: 'USD',
banking_connection_id: null,
external_account_id: null,
linked_at: null,
},
{
id: 'acc-2',
name: 'Savings',
name_iv: null,
encrypted: false,
bank: null,
type: 'savings',
currency_code: 'USD',
banking_connection_id: null,
external_account_id: null,
linked_at: null,
},
];
const emptyFilters: FiltersType = {
dateFrom: null,
dateTo: null,
amountMin: null,
amountMax: null,
categoryIds: [],
accountIds: [],
labelIds: [],
creditorName: '',
debtorName: '',
searchText: '',
aiCategorizedOnly: false,
};
function renderFilters(
filters: FiltersType,
accountList: Account[] = accounts,
) {
const onFiltersChange = vi.fn();
render(
<TransactionFilters
filters={filters}
onFiltersChange={onFiltersChange}
categories={[]}
labels={[]}
accounts={accountList}
/>,
);
return onFiltersChange;
}
describe('TransactionFilters accounts dropdown', () => {
beforeEach(() => {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
Element.prototype.scrollIntoView = vi.fn();
Element.prototype.hasPointerCapture = vi.fn(() => false);
Element.prototype.setPointerCapture = vi.fn();
Element.prototype.releasePointerCapture = vi.fn();
});
it('adds an account id when an account is selected', () => {
const onFiltersChange = renderFilters(emptyFilters);
fireEvent.click(screen.getByRole('button', { name: /Filters/ }));
fireEvent.click(screen.getByText('Select accounts...'));
fireEvent.click(screen.getByText('Checking'));
expect(onFiltersChange).toHaveBeenCalledWith(
expect.objectContaining({ accountIds: ['acc-1'] }),
);
});
it('removes an account id when a selected account is toggled off', () => {
const onFiltersChange = renderFilters({
...emptyFilters,
accountIds: ['acc-1'],
});
fireEvent.click(screen.getByRole('button', { name: /Filters/ }));
fireEvent.click(screen.getByText('1 selected'));
fireEvent.click(screen.getByText('Checking'));
expect(onFiltersChange).toHaveBeenCalledWith(
expect.objectContaining({ accountIds: [] }),
);
});
it('shows an empty state when there are no accounts', () => {
renderFilters(emptyFilters, []);
fireEvent.click(screen.getByRole('button', { name: /Filters/ }));
fireEvent.click(screen.getByText('Select accounts...'));
expect(screen.getByText('No accounts found.')).toBeInTheDocument();
});
});

View File

@ -5,6 +5,7 @@ import { ChevronsUpDown, Tag, X } from 'lucide-react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { AccountName } from '@/components/accounts/account-name';
import { BankLogo } from '@/components/bank-logo';
import { SavedFilters } from '@/components/transactions/saved-filters';
import { AiSparkleIcon } from '@/components/ui/ai-sparkle-icon';
import { Badge } from '@/components/ui/badge';
@ -35,6 +36,7 @@ import { type Account } from '@/types/account';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { getLabelColorClasses, type Label } from '@/types/label';
import { type TransactionFilters as FiltersType } from '@/types/transaction';
import { type UUID } from '@/types/uuid';
import { CategoryIcon } from '../shared/category-combobox';
interface TransactionFiltersProps {
@ -62,6 +64,7 @@ export function TransactionFilters({
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
const [categorySearch, setCategorySearch] = useState('');
const [labelDropdownOpen, setLabelDropdownOpen] = useState(false);
const [accountDropdownOpen, setAccountDropdownOpen] = useState(false);
const [searchText, setSearchText] = useState(filters.searchText);
const [creditorName, setCreditorName] = useState(filters.creditorName);
const [debtorName, setDebtorName] = useState(filters.debtorName);
@ -187,7 +190,7 @@ export function TransactionFilters({
});
}
function handleAccountToggle(accountId: number) {
function handleAccountToggle(accountId: UUID) {
const newAccountIds = filters.accountIds.includes(accountId)
? filters.accountIds.filter((id) => id !== accountId)
: [...filters.accountIds, accountId];
@ -367,28 +370,6 @@ export function TransactionFilters({
</div>
</div>
<div className="space-y-2">
<FormLabel>
{__('Counterparties')}
</FormLabel>
<div className="grid grid-cols-2 gap-2 pt-2">
<Input
value={creditorName}
onChange={(e) =>
setCreditorName(e.target.value)
}
placeholder={__('Creditor name')}
/>
<Input
value={debtorName}
onChange={(e) =>
setDebtorName(e.target.value)
}
placeholder={__('Debtor name')}
/>
</div>
</div>
<div className="space-y-2">
<FormLabel>{__('Categories')}</FormLabel>
<div className="pt-2">
@ -658,37 +639,109 @@ export function TransactionFilters({
{!hideAccountFilter && (
<div className="space-y-2">
<FormLabel>{__('Accounts')}</FormLabel>
<div className="flex flex-wrap gap-2 pt-2">
{accounts.map((account) => {
const isSelected =
filters.accountIds.includes(
account.id,
);
return (
<Badge
key={account.id}
variant={
isSelected
? 'default'
: 'outline'
}
className="cursor-pointer px-2 py-1"
onClick={() =>
handleAccountToggle(
account.id,
)
}
<div className="pt-2">
<Popover
open={accountDropdownOpen}
onOpenChange={
setAccountDropdownOpen
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
>
<AccountName
account={account}
length={{
min: 6,
max: 28,
}}
{filters.accountIds
.length > 0 ? (
<span className="truncate">
{
filters
.accountIds
.length
}{' '}
selected
</span>
) : (
<span className="text-muted-foreground">
{__(
'Select accounts...',
)}
</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0"
align="start"
>
<Command>
<CommandInput
placeholder={__(
'Search accounts...',
)}
/>
</Badge>
);
})}
<CommandList>
<CommandEmpty>
{__(
'No accounts found.',
)}
</CommandEmpty>
{accounts.map(
(account) => {
const isSelected =
filters.accountIds.includes(
account.id,
);
return (
<CommandItem
key={
account.id
}
value={`${account.name} ${account.id}`}
onSelect={() =>
handleAccountToggle(
account.id,
)
}
>
<Checkbox
checked={
isSelected
}
className="pointer-events-none mr-1"
/>
<div className="flex min-w-0 items-center gap-2">
<BankLogo
src={
account
.bank
?.logo
}
name={
account
.bank
?.name
}
fallback="icon"
className="h-5 w-5 shrink-0 rounded-full"
/>
<AccountName
account={
account
}
className="truncate"
/>
</div>
</CommandItem>
);
},
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
)}
@ -718,6 +771,28 @@ export function TransactionFilters({
</Badge>
</div>
</div>
<div className="space-y-2">
<FormLabel>
{__('Counterparties')}
</FormLabel>
<div className="grid grid-cols-2 gap-2 pt-2">
<Input
value={creditorName}
onChange={(e) =>
setCreditorName(e.target.value)
}
placeholder={__('Creditor name')}
/>
<Input
value={debtorName}
onChange={(e) =>
setDebtorName(e.target.value)
}
placeholder={__('Debtor name')}
/>
</div>
</div>
</div>
</PopoverContent>
</Popover>