diff --git a/apps/frontend/src/app/expenses/page.tsx b/apps/frontend/src/app/expenses/page.tsx index 8d53714..c32cad4 100644 --- a/apps/frontend/src/app/expenses/page.tsx +++ b/apps/frontend/src/app/expenses/page.tsx @@ -1,5 +1,6 @@ 'use client'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, Suspense } from 'react'; +import { useQueryParams } from '@/hooks/useQueryParams'; import NavBar from '../components/Navbar'; import Header from '../components/Header'; import Pagination from '../components/Pagination'; @@ -48,18 +49,36 @@ export const EXPENSE_CATEGORIES = [ ]; export default function ExpensePage() { + return ( + + + + ); +} + +function ExpensePageContent() { // Data const [expenditures, setExpenditures] = useState([]); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // Search & Filters - const [query, setQuery] = useState(''); - const [selectedMonths, setSelectedMonths] = useState([]); - const [selectedTypes, setSelectedTypes] = useState([]); - const [selectedProjects, setSelectedProjects] = useState([]); - const [sortOption, setSortOption] = useState(''); + // Search & Filters (synced to URL query params) + const [filters, setFilter] = useQueryParams({ + q: '', + months: [] as string[], + types: [] as string[], + projects: [] as string[], + sort: '', + page: '', + }); + + const query = filters.q; + const selectedMonths = filters.months; + const selectedTypes = filters.types; + const selectedProjects = filters.projects; + const sortOption = filters.sort; + const currentPage = parseInt(filters.page, 10) || 1; // Dropdown visibility const [showMonthFilter, setShowMonthFilter] = useState(false); @@ -67,9 +86,6 @@ export default function ExpensePage() { const [showProjectFilter, setShowProjectFilter] = useState(false); const [showSortBy, setShowSortBy] = useState(false); - // Pagination - const [currentPage, setCurrentPage] = useState(1); - // Modal const [showNewExpense, setShowNewExpense] = useState(false); @@ -146,10 +162,6 @@ export default function ExpensePage() { currentPage * ROWS_PER_PAGE, ); - // Reset page when filters change - useEffect(() => { - setCurrentPage(1); - }, [query, selectedMonths, selectedTypes, selectedProjects, sortOption]); // Modal success handler async function handleExpenseAdded() { @@ -182,7 +194,7 @@ export default function ExpensePage() { placeholder="Search ..." variant="outline" value={query} - onChange={(e) => setQuery(e.target.value)} + onChange={(e) => setFilter({ q: e.target.value, page: '' })} /> @@ -210,7 +222,7 @@ export default function ExpensePage() { multiSelect={true} hideTrigger={true} value={selectedProjects} - onChange={(val) => setSelectedProjects(Array.isArray(val) ? val : [val])} + onChange={(val) => setFilter({ projects: Array.isArray(val) ? val : [val], page: '' })} /> )} @@ -240,7 +252,7 @@ export default function ExpensePage() { multiSelect={true} hideTrigger={true} value={selectedMonths} - onChange={(val) => setSelectedMonths(Array.isArray(val) ? val : [val])} + onChange={(val) => setFilter({ months: Array.isArray(val) ? val : [val], page: '' })} /> )} @@ -270,7 +282,7 @@ export default function ExpensePage() { multiSelect={true} hideTrigger={true} value={selectedTypes} - onChange={(val) => setSelectedTypes(Array.isArray(val) ? val : [val])} + onChange={(val) => setFilter({ types: Array.isArray(val) ? val : [val], page: '' })} /> )} @@ -300,7 +312,7 @@ export default function ExpensePage() { multiSelect={false} hideTrigger={true} value={sortOption} - onChange={(val) => setSortOption(val as string)} + onChange={(val) => setFilter({ sort: val as string, page: '' })} /> )} @@ -379,7 +391,7 @@ export default function ExpensePage() { setFilter({ page: String(p) })} /> )} diff --git a/apps/frontend/src/hooks/useQueryParams.ts b/apps/frontend/src/hooks/useQueryParams.ts new file mode 100644 index 0000000..ddf55ed --- /dev/null +++ b/apps/frontend/src/hooks/useQueryParams.ts @@ -0,0 +1,62 @@ +'use client'; +import { useCallback, useMemo } from 'react'; +import { useRouter, useSearchParams, usePathname } from 'next/navigation'; + +type ParamValue = string | string[]; + +/** + * Syncs a flat filter/state object to URL query params. + * + * - String values map to plain query params (e.g. `?q=foo`) + * - String[] values map to comma-separated params (e.g. `?months=Jan,Feb`) + * - Empty strings and empty arrays are omitted from the URL + * - Uses router.replace so filter changes don't add browser history entries + * + * Usage: + * const [params, setParams] = useQueryParams({ q: '', tags: [] as string[] }); + * setParams({ q: 'hello', tags: ['a', 'b'] }); + */ +export function useQueryParams>( + defaults: T, +): [T, (updates: Partial) => void] { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const values = useMemo(() => { + const result = { ...defaults } as T; + for (const key in defaults) { + const raw = searchParams.get(key); + if (raw === null) continue; + if (Array.isArray(defaults[key])) { + (result as Record)[key] = raw ? raw.split(',') : []; + } else { + (result as Record)[key] = raw; + } + } + return result; + // searchParams identity changes when URL changes, which is the correct dependency + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); + + const setParams = useCallback( + (updates: Partial) => { + const next = new URLSearchParams(searchParams.toString()); + for (const key in updates) { + const val = updates[key]; + if (val === undefined) continue; + if (Array.isArray(val)) { + if (val.length === 0) next.delete(key); + else next.set(key, val.join(',')); + } else { + if (!val) next.delete(key); + else next.set(key, val as string); + } + } + router.replace(`${pathname}?${next.toString()}`); + }, + [router, pathname, searchParams], + ); + + return [values, setParams]; +} diff --git a/apps/frontend/test/hooks/useQueryParams.test.ts b/apps/frontend/test/hooks/useQueryParams.test.ts new file mode 100644 index 0000000..b38623a --- /dev/null +++ b/apps/frontend/test/hooks/useQueryParams.test.ts @@ -0,0 +1,127 @@ +import { renderHook, act } from '@testing-library/react'; +import { useSearchParams, useRouter, usePathname } from 'next/navigation'; +import { useQueryParams } from '@/hooks/useQueryParams'; + +// next/navigation is globally mocked in jest.setup.ts +const mockReplace = jest.fn(); +const mockSearchParams = (init?: string) => + jest.mocked(useSearchParams).mockReturnValue(new URLSearchParams(init) as ReturnType); + +beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useRouter).mockReturnValue({ + replace: mockReplace, + push: jest.fn(), + prefetch: jest.fn(), + back: jest.fn(), + forward: jest.fn(), + refresh: jest.fn(), + }); + jest.mocked(usePathname).mockReturnValue('/expenses'); + mockSearchParams(); +}); + +const defaults = { + q: '', + months: [] as string[], + sort: '', + page: '', +}; + +describe('useQueryParams — initial values', () => { + test('returns defaults when URL has no params', () => { + const { result } = renderHook(() => useQueryParams(defaults)); + expect(result.current[0]).toEqual(defaults); + }); + + test('reads a string param from the URL', () => { + mockSearchParams('q=travel'); + const { result } = renderHook(() => useQueryParams(defaults)); + expect(result.current[0].q).toBe('travel'); + }); + + test('reads an array param from a comma-separated URL value', () => { + mockSearchParams('months=January%2CFebruary'); + const { result } = renderHook(() => useQueryParams(defaults)); + expect(result.current[0].months).toEqual(['January', 'February']); + }); + + test('returns empty array for an array param with empty string value', () => { + mockSearchParams('months='); + const { result } = renderHook(() => useQueryParams(defaults)); + expect(result.current[0].months).toEqual([]); + }); + + test('falls back to default for unrecognised keys', () => { + mockSearchParams('unknown=foo'); + const { result } = renderHook(() => useQueryParams(defaults)); + expect(result.current[0]).toEqual(defaults); + }); +}); + +describe('useQueryParams — setParams', () => { + test('calls router.replace with updated string param', () => { + const { result } = renderHook(() => useQueryParams(defaults)); + act(() => { result.current[1]({ q: 'food' }); }); + expect(mockReplace).toHaveBeenCalledWith('/expenses?q=food'); + }); + + test('calls router.replace with comma-separated array param', () => { + const { result } = renderHook(() => useQueryParams(defaults)); + act(() => { result.current[1]({ months: ['January', 'March'] }); }); + expect(mockReplace).toHaveBeenCalledWith('/expenses?months=January%2CMarch'); + }); + + test('removes param from URL when string is set to empty', () => { + mockSearchParams('q=food'); + const { result } = renderHook(() => useQueryParams(defaults)); + act(() => { result.current[1]({ q: '' }); }); + expect(mockReplace).toHaveBeenCalledWith('/expenses?'); + }); + + test('removes param from URL when array is set to empty', () => { + mockSearchParams('months=January'); + const { result } = renderHook(() => useQueryParams(defaults)); + act(() => { result.current[1]({ months: [] }); }); + expect(mockReplace).toHaveBeenCalledWith('/expenses?'); + }); + + test('preserves existing params when updating one key', () => { + mockSearchParams('q=food&sort=Amount'); + const { result } = renderHook(() => useQueryParams(defaults)); + act(() => { result.current[1]({ q: 'travel' }); }); + const url = mockReplace.mock.calls[0][0] as string; + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('q')).toBe('travel'); + expect(params.get('sort')).toBe('Amount'); + }); + + test('handles multiple updates in a single call', () => { + const { result } = renderHook(() => useQueryParams(defaults)); + act(() => { result.current[1]({ q: 'food', months: ['June'], page: '' }); }); + const url = mockReplace.mock.calls[0][0] as string; + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('q')).toBe('food'); + expect(params.get('months')).toBe('June'); + expect(params.has('page')).toBe(false); + }); + + test('page reset pattern: clears page when filter changes', () => { + mockSearchParams('q=old&page=3'); + const { result } = renderHook(() => useQueryParams(defaults)); + act(() => { result.current[1]({ q: 'new', page: '' }); }); + const url = mockReplace.mock.calls[0][0] as string; + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('q')).toBe('new'); + expect(params.has('page')).toBe(false); + }); + + test('uses router.replace not push (no history entry)', () => { + const mockPush = jest.fn(); + jest.mocked(useRouter).mockReturnValue({ replace: mockReplace, push: mockPush, prefetch: jest.fn(), back: jest.fn(), forward: jest.fn(), refresh: jest.fn() }); + const { result } = renderHook(() => useQueryParams(defaults)); + act(() => { result.current[1]({ q: 'test' }); }); + expect(mockReplace).toHaveBeenCalled(); + expect(mockPush).not.toHaveBeenCalled(); + }); +});