import { renderHook, act, waitFor } from '@testing-library/react' import { useTableURL, UseTableURLConfig } from './useTableURL' import { SortState, FilterState } from '../types' // Mock Next.js navigation hooks const mockReplace = jest.fn() const mockSearchParams = new URLSearchParams() const mockPathname = '/test-path' jest.mock('next/navigation', () => ({ useSearchParams: jest.fn(() => mockSearchParams), useRouter: jest.fn(() => ({ replace: mockReplace, })), usePathname: jest.fn(() => mockPathname), })) describe('useTableURL', () => { beforeEach(() => { jest.clearAllMocks() jest.useFakeTimers() // Reset search params mockSearchParams.forEach((_, key) => { mockSearchParams.delete(key) }) }) afterEach(() => { jest.runOnlyPendingTimers() jest.useRealTimers() }) describe('initialization', () => { it('should return empty state when persistToUrl is false', () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: false, }) ) const state = result.current.getURLState() expect(state).toEqual({ sortBy: null, sortDirection: 'asc', filters: {}, page: 1, search: '', }) }) it('should return empty state when URL has no params', () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, }) ) const state = result.current.getURLState() expect(state).toEqual({ sortBy: null, sortDirection: 'asc', filters: {}, page: 1, search: '', }) }) it('should parse existing URL params on mount', () => { mockSearchParams.set('test_sortBy', 'name') mockSearchParams.set('test_sortDir', 'desc') mockSearchParams.set('test_page', '3') mockSearchParams.set('test_search', 'acme') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, }) ) const state = result.current.getURLState() expect(state).toEqual({ sortBy: 'name', sortDirection: 'desc', filters: {}, page: 3, search: 'acme', }) }) it('should parse filter params from URL', () => { mockSearchParams.set('test_filter_status', 'active') mockSearchParams.set('test_filter_tags', JSON.stringify(['tag1', 'tag2'])) const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, }) ) const state = result.current.getURLState() expect(state.filters).toEqual({ status: 'active', tags: ['tag1', 'tag2'], }) }) it('should handle invalid page number gracefully', () => { mockSearchParams.set('test_page', 'invalid') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, }) ) const state = result.current.getURLState() expect(state.page).toBe(1) }) it('should handle negative page number', () => { mockSearchParams.set('test_page', '-5') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, }) ) const state = result.current.getURLState() expect(state.page).toBe(1) }) it('should namespace params by tableId', () => { mockSearchParams.set('other_sortBy', 'otherColumn') mockSearchParams.set('test_sortBy', 'testColumn') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, }) ) const state = result.current.getURLState() expect(state.sortBy).toBe('testColumn') }) }) describe('setSortToURL', () => { it('should update sort params in URL', async () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) act(() => { result.current.setSortToURL({ sortBy: 'name', sortDirection: 'asc', }) }) // Fast-forward debounce act(() => { jest.advanceTimersByTime(100) }) await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith( expect.stringContaining('test_sortBy=name'), { scroll: false } ) expect(mockReplace).toHaveBeenCalledWith( expect.stringContaining('test_sortDir=asc'), { scroll: false } ) }) }) it('should reset page to 1 when sorting changes', async () => { mockSearchParams.set('test_page', '5') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) act(() => { result.current.setSortToURL({ sortBy: 'name', sortDirection: 'asc', }) }) act(() => { jest.advanceTimersByTime(100) }) await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith( expect.stringContaining('test_page=1'), { scroll: false } ) }) }) it('should remove sort params when sortBy is null', async () => { mockSearchParams.set('test_sortBy', 'name') mockSearchParams.set('test_sortDir', 'asc') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) act(() => { result.current.setSortToURL({ sortBy: null, sortDirection: 'asc', }) }) act(() => { jest.advanceTimersByTime(100) }) await waitFor(() => { const lastCall = mockReplace.mock.calls[mockReplace.mock.calls.length - 1] const url = lastCall?.[0] || '' expect(url).not.toContain('test_sortBy') expect(url).not.toContain('test_sortDir') }) }) it('should not update URL when persistToUrl is false', () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: false, }) ) act(() => { result.current.setSortToURL({ sortBy: 'name', sortDirection: 'asc', }) }) act(() => { jest.advanceTimersByTime(1000) }) expect(mockReplace).not.toHaveBeenCalled() }) it('should debounce rapid sort changes', async () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 300, }) ) // Make multiple rapid changes act(() => { result.current.setSortToURL({ sortBy: 'name', sortDirection: 'asc' }) }) act(() => { jest.advanceTimersByTime(100) }) act(() => { result.current.setSortToURL({ sortBy: 'email', sortDirection: 'desc' }) }) act(() => { jest.advanceTimersByTime(100) }) act(() => { result.current.setSortToURL({ sortBy: 'date', sortDirection: 'asc' }) }) // Should not have called replace yet expect(mockReplace).not.toHaveBeenCalled() // Fast-forward past debounce act(() => { jest.advanceTimersByTime(300) }) // Should only update once with the final value await waitFor(() => { expect(mockReplace).toHaveBeenCalledTimes(1) expect(mockReplace).toHaveBeenCalledWith( expect.stringContaining('test_sortBy=date'), { scroll: false } ) }) }) }) describe('setFiltersToURL', () => { it('should update filter params in URL', async () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) act(() => { result.current.setFiltersToURL({ status: 'active', category: 'tech', }) }) act(() => { jest.advanceTimersByTime(100) }) await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith( expect.stringContaining('test_filter_status=active'), { scroll: false } ) expect(mockReplace).toHaveBeenCalledWith( expect.stringContaining('test_filter_category=tech'), { scroll: false } ) }) }) it('should encode complex filter values as JSON', async () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) act(() => { result.current.setFiltersToURL({ tags: ['tag1', 'tag2'], range: { min: 0, max: 100 }, }) }) act(() => { jest.advanceTimersByTime(100) }) await waitFor(() => { const lastCall = mockReplace.mock.calls[mockReplace.mock.calls.length - 1] const url = lastCall?.[0] || '' expect(url).toContain('test_filter_tags') expect(url).toContain('test_filter_range') }) }) it('should remove filter params when filter value is empty', async () => { mockSearchParams.set('test_filter_status', 'active') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) act(() => { result.current.setFiltersToURL({ status: '', }) }) act(() => { jest.advanceTimersByTime(100) }) await waitFor(() => { const lastCall = mockReplace.mock.calls[mockReplace.mock.calls.length - 1] const url = lastCall?.[0] || '' expect(url).not.toContain('test_filter_status') }) }) it('should reset page to 1 when filters change', async () => { mockSearchParams.set('test_page', '5') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) act(() => { result.current.setFiltersToURL({ status: 'active', }) }) act(() => { jest.advanceTimersByTime(100) }) await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith( expect.stringContaining('test_page=1'), { scroll: false } ) }) }) it('should clear old filters when setting new ones', async () => { mockSearchParams.set('test_filter_oldFilter', 'oldValue') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) act(() => { result.current.setFiltersToURL({ newFilter: 'newValue', }) }) act(() => { jest.advanceTimersByTime(100) }) await waitFor(() => { const lastCall = mockReplace.mock.calls[mockReplace.mock.calls.length - 1] const url = lastCall?.[0] || '' expect(url).not.toContain('oldFilter') expect(url).toContain('newFilter') }) }) }) describe('setPageToURL', () => { it('should update page param in URL', async () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) act(() => { result.current.setPageToURL(5) }) act(() => { jest.advanceTimersByTime(100) }) await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith( expect.stringContaining('test_page=5'), { scroll: false } ) }) }) it('should remove page param when page is 1', async () => { mockSearchParams.set('test_page', '5') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) act(() => { result.current.setPageToURL(1) }) act(() => { jest.advanceTimersByTime(100) }) await waitFor(() => { const lastCall = mockReplace.mock.calls[mockReplace.mock.calls.length - 1] const url = lastCall?.[0] || '' expect(url).not.toContain('test_page') }) }) }) describe('setSearchToURL', () => { it('should update search param in URL', async () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) act(() => { result.current.setSearchToURL('acme corp') }) act(() => { jest.advanceTimersByTime(100) }) await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith( expect.stringContaining('test_search=acme+corp'), { scroll: false } ) }) }) it('should remove search param when search is empty', async () => { mockSearchParams.set('test_search', 'acme') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) act(() => { result.current.setSearchToURL('') }) act(() => { jest.advanceTimersByTime(100) }) await waitFor(() => { const lastCall = mockReplace.mock.calls[mockReplace.mock.calls.length - 1] const url = lastCall?.[0] || '' expect(url).not.toContain('test_search') }) }) it('should reset page to 1 when search changes', async () => { mockSearchParams.set('test_page', '5') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) act(() => { result.current.setSearchToURL('acme') }) act(() => { jest.advanceTimersByTime(100) }) await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith( expect.stringContaining('test_page=1'), { scroll: false } ) }) }) it('should debounce rapid search changes', async () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 300, }) ) // Simulate typing act(() => { result.current.setSearchToURL('a') }) act(() => { jest.advanceTimersByTime(100) }) act(() => { result.current.setSearchToURL('ac') }) act(() => { jest.advanceTimersByTime(100) }) act(() => { result.current.setSearchToURL('acme') }) // Should not have called replace yet expect(mockReplace).not.toHaveBeenCalled() // Fast-forward past debounce act(() => { jest.advanceTimersByTime(300) }) // Should only update once with the final value await waitFor(() => { expect(mockReplace).toHaveBeenCalledTimes(1) expect(mockReplace).toHaveBeenCalledWith( expect.stringContaining('test_search=acme'), { scroll: false } ) }) }) }) describe('clearURLState', () => { it('should remove all table-related params from URL', async () => { mockSearchParams.set('test_sortBy', 'name') mockSearchParams.set('test_page', '5') mockSearchParams.set('test_search', 'acme') mockSearchParams.set('test_filter_status', 'active') mockSearchParams.set('other_param', 'keep-this') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, }) ) act(() => { result.current.clearURLState() }) expect(mockReplace).toHaveBeenCalledTimes(1) const url = mockReplace.mock.calls[0][0] expect(url).not.toContain('test_sortBy') expect(url).not.toContain('test_page') expect(url).not.toContain('test_search') expect(url).not.toContain('test_filter_status') expect(url).toContain('other_param=keep-this') }) it('should navigate to pathname without params when all removed', () => { mockSearchParams.set('test_sortBy', 'name') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, }) ) act(() => { result.current.clearURLState() }) expect(mockReplace).toHaveBeenCalledWith('/test-path', { scroll: false }) }) }) describe('hasURLState', () => { it('should return false when persistToUrl is false', () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: false, }) ) expect(result.current.hasURLState()).toBe(false) }) it('should return false when URL has no table params', () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, }) ) expect(result.current.hasURLState()).toBe(false) }) it('should return true when URL has table params', () => { mockSearchParams.set('test_sortBy', 'name') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, }) ) expect(result.current.hasURLState()).toBe(true) }) it('should not detect params from other tables', () => { mockSearchParams.set('other_sortBy', 'name') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, }) ) expect(result.current.hasURLState()).toBe(false) }) }) describe('edge cases', () => { it('should handle special characters in filter values', async () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) act(() => { result.current.setFiltersToURL({ name: 'Acme & Co.', }) }) act(() => { jest.advanceTimersByTime(100) }) await waitFor(() => { expect(mockReplace).toHaveBeenCalled() }) }) it('should handle URL encoding for search terms', async () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) act(() => { result.current.setSearchToURL('test@example.com') }) act(() => { jest.advanceTimersByTime(100) }) await waitFor(() => { const lastCall = mockReplace.mock.calls[mockReplace.mock.calls.length - 1] const url = lastCall?.[0] || '' expect(url).toContain('test_search') }) }) it('should not update URL when params have not changed', async () => { mockSearchParams.set('test_sortBy', 'name') mockSearchParams.set('test_sortDir', 'asc') const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 100, }) ) // Set the same values act(() => { result.current.setSortToURL({ sortBy: 'name', sortDirection: 'asc', }) }) act(() => { jest.advanceTimersByTime(100) }) // Should not call replace when params are identical // Note: This test may need adjustment based on implementation details await waitFor(() => { // The implementation may still call replace, but ideally shouldn't // This is testing the optimization logic }) }) it('should cleanup debounce timer on unmount', () => { const { result, unmount } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 300, }) ) act(() => { result.current.setSearchToURL('test') }) // Unmount before debounce completes unmount() // Fast-forward timers act(() => { jest.advanceTimersByTime(300) }) // Should not have called replace after unmount expect(mockReplace).not.toHaveBeenCalled() }) }) describe('custom debounce timing', () => { it('should respect custom debounceMs value', async () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, debounceMs: 500, }) ) act(() => { result.current.setSearchToURL('test') }) act(() => { jest.advanceTimersByTime(400) }) // Should not have updated yet expect(mockReplace).not.toHaveBeenCalled() act(() => { jest.advanceTimersByTime(100) }) // Now it should have updated await waitFor(() => { expect(mockReplace).toHaveBeenCalled() }) }) it('should use default debounce of 300ms', async () => { const { result } = renderHook(() => useTableURL({ tableId: 'test', persistToUrl: true, // debounceMs not specified, should default to 300 }) ) act(() => { result.current.setSearchToURL('test') }) act(() => { jest.advanceTimersByTime(299) }) expect(mockReplace).not.toHaveBeenCalled() act(() => { jest.advanceTimersByTime(1) }) await waitFor(() => { expect(mockReplace).toHaveBeenCalled() }) }) }) })