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:
parent
4038e60fbc
commit
d7bc4e6707
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue