From d7bc4e6707a802044b5f931c54cefca23dcbeebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 26 Jun 2026 20:09:02 +0200 Subject: [PATCH] feat(transactions): reorder filters and switch accounts to a logo dropdown (#598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- lang/es.json | 2 + .../transactions/transaction-filters.test.tsx | 118 ++++++++++++ .../transactions/transaction-filters.tsx | 179 +++++++++++++----- 3 files changed, 247 insertions(+), 52 deletions(-) create mode 100644 resources/js/components/transactions/transaction-filters.test.tsx diff --git a/lang/es.json b/lang/es.json index b0112af9..2e54b37d 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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...", diff --git a/resources/js/components/transactions/transaction-filters.test.tsx b/resources/js/components/transactions/transaction-filters.test.tsx new file mode 100644 index 00000000..482f223a --- /dev/null +++ b/resources/js/components/transactions/transaction-filters.test.tsx @@ -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( + , + ); + 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(); + }); +}); diff --git a/resources/js/components/transactions/transaction-filters.tsx b/resources/js/components/transactions/transaction-filters.tsx index 236ae37f..4046693b 100644 --- a/resources/js/components/transactions/transaction-filters.tsx +++ b/resources/js/components/transactions/transaction-filters.tsx @@ -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({ -
- - {__('Counterparties')} - -
- - setCreditorName(e.target.value) - } - placeholder={__('Creditor name')} - /> - - setDebtorName(e.target.value) - } - placeholder={__('Debtor name')} - /> -
-
-
{__('Categories')}
@@ -658,37 +639,109 @@ export function TransactionFilters({ {!hideAccountFilter && (
{__('Accounts')} -
- {accounts.map((account) => { - const isSelected = - filters.accountIds.includes( - account.id, - ); - return ( - - handleAccountToggle( - account.id, - ) - } +
+ + + + + + + - - ); - })} + + + {__( + 'No accounts found.', + )} + + {accounts.map( + (account) => { + const isSelected = + filters.accountIds.includes( + account.id, + ); + return ( + + handleAccountToggle( + account.id, + ) + } + > + +
+ + +
+
+ ); + }, + )} +
+
+
+
)} @@ -718,6 +771,28 @@ export function TransactionFilters({
+ +
+ + {__('Counterparties')} + +
+ + setCreditorName(e.target.value) + } + placeholder={__('Creditor name')} + /> + + setDebtorName(e.target.value) + } + placeholder={__('Debtor name')} + /> +
+