Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 32 additions & 20 deletions apps/frontend/src/app/expenses/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -48,28 +49,43 @@ export const EXPENSE_CATEGORIES = [
];

export default function ExpensePage() {
return (
<Suspense>
<ExpensePageContent />
</Suspense>
);
}

function ExpensePageContent() {
// Data
const [expenditures, setExpenditures] = useState<Expenditure[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

// Search & Filters
const [query, setQuery] = useState('');
const [selectedMonths, setSelectedMonths] = useState<string[]>([]);
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [sortOption, setSortOption] = useState<string>('');
// 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);
const [showTypeFilter, setShowTypeFilter] = useState(false);
const [showProjectFilter, setShowProjectFilter] = useState(false);
const [showSortBy, setShowSortBy] = useState(false);

// Pagination
const [currentPage, setCurrentPage] = useState(1);

// Modal
const [showNewExpense, setShowNewExpense] = useState(false);

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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: '' })}
/>
</HStack>
<HStack>
Expand Down Expand Up @@ -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: '' })}
/>
</div>
)}
Expand Down Expand Up @@ -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: '' })}
/>
</div>
)}
Expand Down Expand Up @@ -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: '' })}
/>
</div>
)}
Expand Down Expand Up @@ -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: '' })}
/>
</div>
)}
Expand Down Expand Up @@ -379,7 +391,7 @@ export default function ExpensePage() {
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
onPageChange={(p) => setFilter({ page: String(p) })}
/>
)}
</div>
Expand Down
62 changes: 62 additions & 0 deletions apps/frontend/src/hooks/useQueryParams.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Record<string, ParamValue>>(
defaults: T,
): [T, (updates: Partial<T>) => 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<string, ParamValue>)[key] = raw ? raw.split(',') : [];
} else {
(result as Record<string, ParamValue>)[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<T>) => {
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];
}
127 changes: 127 additions & 0 deletions apps/frontend/test/hooks/useQueryParams.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof useSearchParams>);

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();
});
});
Loading