From bda06bccb0d78279a8750d2d2ae1f8ce50450d99 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 30 Jul 2026 07:03:16 +0100 Subject: [PATCH] feat: implement search input debouncing on transfers list and fix test selectors --- README.md | 2 +- eslint.config.js | 1 + package.json | 2 +- src/pages/Transfers.jsx | 39 +++++++++----- src/services/wallet.js | 2 +- test/hooks/useDebouncedValue.test.js | 62 +++++++++++++++++++++++ test/integration/send-money-form.test.jsx | 5 +- test/services/wallet.test.js | 2 +- test/touch-targets.test.js | 2 - 9 files changed, 98 insertions(+), 19 deletions(-) create mode 100644 test/hooks/useDebouncedValue.test.js diff --git a/README.md b/README.md index 77aadfe..2e1d952 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ The app runs at http://localhost:5173 by default. - **Send Money** — enter a recipient and amount, pick currencies, and see a live FX quote with the RemitFlow fee broken out before sending. - **Transfers** — list of your transfers with status badges (pending, - completed, failed), search/status/date-range filters synced to the URL, + completed, failed), debounced search input, status and date-range filters synced to the URL, plus loading, error and empty states. - **Tabs** — reusable tabbed interface with swipe gesture support for mobile, accessible keyboard navigation, and controlled/uncontrolled modes. - **Print support** — a dedicated print stylesheet (`src/print.css`) diff --git a/eslint.config.js b/eslint.config.js index 0e9e535..e7ba7c3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -27,6 +27,7 @@ export default [ languageOptions: { globals: { ...globals.browser, + ...globals.node, ...globals.es2021, }, parserOptions: { diff --git a/package.json b/package.json index b0428a0..629f6a6 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "lint": "eslint .", "preview": "vite preview", "storybook": "storybook dev -p 6006", - "test": "vitest run test/integration test/unit test/lighthouse-config.test.js", + "test": "vitest run test/integration test/unit test/hooks test/lighthouse-config.test.js", "test:lighthouse": "vitest run test/lighthouse-config.test.js", "test:watch": "vitest" }, diff --git a/src/pages/Transfers.jsx b/src/pages/Transfers.jsx index 2967fe8..9e90190 100644 --- a/src/pages/Transfers.jsx +++ b/src/pages/Transfers.jsx @@ -11,6 +11,7 @@ import Pagination from '../components/Pagination.jsx'; import PullToRefresh from '../components/PullToRefresh.jsx'; import SelectionToolbar from '../components/SelectionToolbar.jsx'; import { useTransfers } from '../hooks/useTransfers.js'; +import { useDebouncedValue } from '../hooks/useDebouncedValue.js'; import { useApp } from '../context/AppContext.jsx'; import { DATE_RANGE_PRESETS, isWithinDateRange } from '../utils/dateRange.js'; import './Transfers.css'; @@ -37,6 +38,28 @@ export default function Transfers() { const status = searchParams.get('status') || ''; const range = searchParams.get('range') || ''; + const [searchInput, setSearchInput] = useState(search); + const debouncedSearch = useDebouncedValue(searchInput, 250); + + // Sync searchInput when URL search parameter changes externally + useEffect(() => { + setSearchInput(search); + }, [search]); + + // Sync debounced search value to URL query parameters + useEffect(() => { + setSearchParams((prev) => { + const current = prev.get('search') || ''; + if (current === debouncedSearch) return prev; + if (debouncedSearch) { + prev.set('search', debouncedSearch); + } else { + prev.delete('search'); + } + return prev; + }); + }, [debouncedSearch, setSearchParams]); + // Selection state const [selectedIds, setSelectedIds] = useState(() => new Set()); const [selectAllAcross, setSelectAllAcross] = useState(false); @@ -79,17 +102,9 @@ export default function Transfers() { : selectedIds.size; const hasMorePages = totalPages > 1; - const handleSearchChange = useCallback( - (e) => { - const value = e.target.value; - setSearchParams((prev) => { - if (value) prev.set('search', value); - else prev.delete('search'); - return prev; - }); - }, - [setSearchParams], - ); + const handleSearchChange = useCallback((e) => { + setSearchInput(e.target.value); + }, []); const handleStatusChange = useCallback( (e) => { @@ -244,7 +259,7 @@ export default function Transfers() { type="search" className="transfers-filters-search" placeholder="Search by recipient…" - value={search} + value={searchInput} onChange={handleSearchChange} aria-label="Search transfers by recipient" /> diff --git a/src/services/wallet.js b/src/services/wallet.js index 73e5929..64bcf2d 100644 --- a/src/services/wallet.js +++ b/src/services/wallet.js @@ -43,7 +43,7 @@ export function connectWallet() { * @param {object} payload - the transaction details to "sign" * @returns {Promise<{signature: string}>} */ -export function signTransaction(_payload) { +export function signTransaction() { return new Promise((resolve) => { setTimeout(() => { resolve({ diff --git a/test/hooks/useDebouncedValue.test.js b/test/hooks/useDebouncedValue.test.js new file mode 100644 index 0000000..005106e --- /dev/null +++ b/test/hooks/useDebouncedValue.test.js @@ -0,0 +1,62 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useDebouncedValue } from '../../src/hooks/useDebouncedValue.js'; + +describe('useDebouncedValue hook', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns initial value immediately', () => { + const { result } = renderHook(() => useDebouncedValue('initial', 300)); + expect(result.current).toBe('initial'); + }); + + it('updates debounced value after the specified delay', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebouncedValue(value, 300), + { initialProps: { value: 'initial' } }, + ); + + rerender({ value: 'updated' }); + expect(result.current).toBe('initial'); + + act(() => { + vi.advanceTimersByTime(299); + }); + expect(result.current).toBe('initial'); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(result.current).toBe('updated'); + }); + + it('resets timer when value changes rapidly before delay expires', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebouncedValue(value, 300), + { initialProps: { value: 'first' } }, + ); + + rerender({ value: 'second' }); + act(() => { + vi.advanceTimersByTime(200); + }); + expect(result.current).toBe('first'); + + rerender({ value: 'third' }); + act(() => { + vi.advanceTimersByTime(200); + }); + expect(result.current).toBe('first'); + + act(() => { + vi.advanceTimersByTime(100); + }); + expect(result.current).toBe('third'); + }); +}); diff --git a/test/integration/send-money-form.test.jsx b/test/integration/send-money-form.test.jsx index 08cdef6..6c946f6 100644 --- a/test/integration/send-money-form.test.jsx +++ b/test/integration/send-money-form.test.jsx @@ -13,7 +13,10 @@ import * as api from '../../src/services/api.js'; async function fillValidForm(user) { await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com'); await user.type(screen.getByLabelText(/amount/i), '15'); - await user.selectOptions(screen.getByLabelText(/to/i), 'NGN'); + await user.selectOptions( + screen.getByLabelText(/to/i, { selector: 'select' }), + 'NGN', + ); } function createdTransfer(payload) { diff --git a/test/services/wallet.test.js b/test/services/wallet.test.js index e2e801d..abd7339 100644 --- a/test/services/wallet.test.js +++ b/test/services/wallet.test.js @@ -46,7 +46,7 @@ describe('connectWallet', () => { try { await connectWallet(); - } catch (err) { + } catch { // Expected to throw } diff --git a/test/touch-targets.test.js b/test/touch-targets.test.js index 34a0936..9dc93a8 100644 --- a/test/touch-targets.test.js +++ b/test/touch-targets.test.js @@ -6,8 +6,6 @@ function readCSS(filename) { return fs.readFileSync(path.resolve(process.cwd(), 'src', filename), 'utf8'); } -const MIN_TARGET = '44px'; - describe('touch target minimum sizes (44px)', () => { describe('button elements', () => { const css = readCSS(path.join('components', 'Button.css'));