diff --git a/src/app/store/__tests__/searchStore.test.ts b/src/app/store/__tests__/searchStore.test.ts new file mode 100644 index 00000000..41e961db --- /dev/null +++ b/src/app/store/__tests__/searchStore.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { useSearchStore } from '../searchStore'; + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + }; +})(); + +vi.stubGlobal('localStorage', localStorageMock); + +const pushStateMock = vi.fn(); +const replaceStateMock = vi.fn(); +const originalLocation = window.location; + +beforeEach(() => { + localStorageMock.clear(); + useSearchStore.setState({ + searchQuery: '', + cursor: undefined, + difficulty: [], + duration: [0, 20], + topics: [], + instructors: [], + sortBy: 'relevance', + price: [0, 1000], + searchHistory: [], + }); + + Object.defineProperty(window, 'location', { + value: { ...originalLocation, pathname: '/search', search: '' }, + writable: true, + }); + + pushStateMock.mockClear(); + replaceStateMock.mockClear(); + vi.stubGlobal('history', { + pushState: pushStateMock, + replaceState: replaceStateMock, + state: {}, + }); +}); + +afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + }); +}); + +describe('searchStore URL sync', () => { + it('calls pushState when searchQuery changes (significant change)', () => { + const store = useSearchStore.getState(); + store.setSearchQuery('react'); + + expect(pushStateMock).toHaveBeenCalledTimes(1); + expect(pushStateMock).toHaveBeenCalledWith( + expect.objectContaining({ searchState: expect.objectContaining({ searchQuery: 'react' }) }), + '', + expect.stringContaining('q=react'), + ); + expect(replaceStateMock).not.toHaveBeenCalled(); + }); + + it('calls pushState when difficulty filter changes (significant change)', () => { + const store = useSearchStore.getState(); + store.setDifficulty(['beginner']); + + expect(pushStateMock).toHaveBeenCalledTimes(1); + expect(pushStateMock).toHaveBeenCalledWith( + expect.anything(), + '', + expect.stringContaining('difficulty=beginner'), + ); + }); + + it('calls pushState when sortBy changes (significant change)', () => { + const store = useSearchStore.getState(); + store.setSortBy('rating'); + + expect(pushStateMock).toHaveBeenCalledTimes(1); + expect(pushStateMock).toHaveBeenCalledWith( + expect.anything(), + '', + expect.stringContaining('sort=rating'), + ); + }); + + it('calls replaceState when cursor changes (minor/transient change)', () => { + const store = useSearchStore.getState(); + store.setCursor('abc123'); + + expect(replaceStateMock).toHaveBeenCalledWith( + expect.objectContaining({ searchState: expect.objectContaining({ cursor: 'abc123' }) }), + '', + expect.stringContaining('cursor=abc123'), + ); + expect(pushStateMock).not.toHaveBeenCalled(); + }); + + it('clears cursor on a new search query and uses pushState', () => { + useSearchStore.setState({ cursor: 'old-cursor' }); + const store = useSearchStore.getState(); + store.setSearchQuery('react'); + + expect(pushStateMock).toHaveBeenCalledWith( + expect.objectContaining({ + searchState: expect.not.objectContaining({ cursor: 'old-cursor' }), + }), + '', + expect.not.stringContaining('cursor'), + ); + const updatedCursor = useSearchStore.getState().cursor; + expect(updatedCursor).toBeUndefined(); + }); + + it('includes all active filters in the URL on setSearchQuery', () => { + useSearchStore.setState({ + difficulty: ['intermediate'], + topics: ['design'], + sortBy: 'newest', + }); + + const store = useSearchStore.getState(); + store.setSearchQuery('ux'); + + const urlArg = pushStateMock.mock.calls[0][2]; + expect(urlArg).toContain('q=ux'); + expect(urlArg).toContain('difficulty=intermediate'); + expect(urlArg).toContain('topics=design'); + expect(urlArg).toContain('sort=newest'); + }); + + it('does not include default values in the URL', () => { + const store = useSearchStore.getState(); + store.setSearchQuery('test'); + + const urlArg = pushStateMock.mock.calls[0][2]; + expect(urlArg).toContain('q=test'); + expect(urlArg).not.toContain('sort='); + expect(urlArg).not.toContain('price='); + expect(urlArg).not.toContain('duration='); + }); +}); + +describe('searchStore pushState state payload', () => { + it('includes full state snapshot in pushState', () => { + useSearchStore.setState({ + difficulty: ['beginner'], + topics: ['react'], + sortBy: 'rating', + }); + const store = useSearchStore.getState(); + store.setSearchQuery('hooks'); + + const stateArg = pushStateMock.mock.calls[0][0]; + expect(stateArg.searchState).toMatchObject({ + searchQuery: 'hooks', + difficulty: ['beginner'], + topics: ['react'], + sortBy: 'rating', + }); + }); +}); + +describe('searchStore updateFromUrl', () => { + it('parses search query from URL params', () => { + const params = new URLSearchParams('q=react'); + useSearchStore.getState().updateFromUrl(params); + expect(useSearchStore.getState().searchQuery).toBe('react'); + }); + + it('parses difficulty from URL params', () => { + const params = new URLSearchParams('difficulty=beginner,intermediate'); + useSearchStore.getState().updateFromUrl(params); + expect(useSearchStore.getState().difficulty).toEqual(['beginner', 'intermediate']); + }); + + it('parses cursor from URL params', () => { + const params = new URLSearchParams('cursor=abc123'); + useSearchStore.getState().updateFromUrl(params); + expect(useSearchStore.getState().cursor).toBe('abc123'); + }); + + it('parses cursor as empty string when present but empty', () => { + const params = new URLSearchParams('cursor='); + useSearchStore.getState().updateFromUrl(params); + expect(useSearchStore.getState().cursor).toBe(''); + }); + + it('restores full filter state from URL params', () => { + const params = new URLSearchParams( + 'q=design&difficulty=advanced&topics=ui,ux&sort=rating&duration=5,15&price=10,500', + ); + useSearchStore.getState().updateFromUrl(params); + const state = useSearchStore.getState(); + expect(state.searchQuery).toBe('design'); + expect(state.difficulty).toEqual(['advanced']); + expect(state.topics).toEqual(['ui', 'ux']); + expect(state.sortBy).toBe('rating'); + expect(state.duration).toEqual([5, 15]); + expect(state.price).toEqual([10, 500]); + }); + + it('resets fields to defaults when params are absent', () => { + useSearchStore.setState({ + searchQuery: 'old', + difficulty: ['advanced'], + topics: ['design'], + sortBy: 'rating', + cursor: 'abc', + }); + + const params = new URLSearchParams(''); + useSearchStore.getState().updateFromUrl(params); + const state = useSearchStore.getState(); + expect(state.searchQuery).toBe(''); + expect(state.difficulty).toEqual([]); + expect(state.topics).toEqual([]); + expect(state.sortBy).toBe('relevance'); + expect(state.cursor).toBeUndefined(); + }); +}); diff --git a/src/app/store/searchStore.ts b/src/app/store/searchStore.ts index 7f1a71e7..5b111219 100644 --- a/src/app/store/searchStore.ts +++ b/src/app/store/searchStore.ts @@ -21,6 +21,10 @@ interface FilterState { } interface SearchStore extends FilterState { + searchQuery: string; + cursor: string | undefined; + setSearchQuery: (query: string) => void; + setCursor: (cursor: string | undefined) => void; setDifficulty: (difficulty: Difficulty[]) => void; setDuration: (duration: [number, number]) => void; setTopics: (topics: string[]) => void; @@ -28,14 +32,14 @@ interface SearchStore extends FilterState { setSortBy: (sortBy: SortOption) => void; setPrice: (price: [number, number]) => void; clearFilters: () => void; - syncWithUrl: () => void; + syncWithUrl: (options?: { push?: boolean }) => void; updateFromUrl: (params: URLSearchParams) => void; searchHistory: string[]; addToSearchHistory: (term: string) => void; clearSearchHistory: () => void; } -const initialState: FilterState = { +const initialFilterState: FilterState = { difficulty: [], duration: [0, 20], topics: [], @@ -47,34 +51,80 @@ const initialState: FilterState = { export const useSearchStore = create()( persist( (set, get) => ({ - ...initialState, + ...initialFilterState, + searchQuery: '', + cursor: undefined, searchHistory: [], - setDifficulty: (difficulty) => set({ difficulty }), - setDuration: (duration) => set({ duration }), - setTopics: (topics) => set({ topics }), - setInstructors: (instructors) => set({ instructors }), - setSortBy: (sortBy) => set({ sortBy }), - setPrice: (price) => set({ price }), - clearFilters: () => set(initialState), + + setSearchQuery: (query) => { + set({ searchQuery: query, cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + + setCursor: (cursor) => { + set({ cursor }); + get().syncWithUrl({ push: false }); + }, + + setDifficulty: (difficulty) => { + set({ difficulty, cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + + setDuration: (duration) => { + set({ duration, cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + + setTopics: (topics) => { + set({ topics, cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + + setInstructors: (instructors) => { + set({ instructors, cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + + setSortBy: (sortBy) => { + set({ sortBy, cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + + setPrice: (price) => { + set({ price, cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + + clearFilters: () => { + set({ ...initialFilterState, searchQuery: '', cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + addToSearchHistory: (term: string) => { if (!term.trim()) return; const current = get().searchHistory; - const updated = [term, ...current.filter((item) => item !== term)].slice(0, 10); // Keep last 10 searches + const updated = [term, ...current.filter((item) => item !== term)].slice(0, 10); set({ searchHistory: updated }); }, + clearSearchHistory: () => set({ searchHistory: [] }), - syncWithUrl: () => { + + syncWithUrl: (options = { push: true }) => { const params = new URLSearchParams(window.location.search); const state = get(); + if (state.searchQuery) { + params.set('q', state.searchQuery); + } if (state.difficulty.length) { params.set('difficulty', state.difficulty.join(',')); } if ( - state.duration[0] !== initialState.duration[0] || - state.duration[1] !== initialState.duration[1] + state.duration[0] !== initialFilterState.duration[0] || + state.duration[1] !== initialFilterState.duration[1] ) { params.set('duration', `${state.duration[0]},${state.duration[1]}`); } @@ -84,26 +134,39 @@ export const useSearchStore = create()( if (state.instructors.length) { params.set('instructors', state.instructors.join(',')); } - if (state.sortBy !== initialState.sortBy) { + if (state.sortBy !== initialFilterState.sortBy) { params.set('sort', state.sortBy); } - if (state.price[0] !== initialState.price[0] || state.price[1] !== initialState.price[1]) { + if ( + state.price[0] !== initialFilterState.price[0] || + state.price[1] !== initialFilterState.price[1] + ) { params.set('price', `${state.price[0]},${state.price[1]}`); } + if (state.cursor) { + params.set('cursor', state.cursor); + } - window.history.replaceState( - {}, - '', - `${window.location.pathname}${params.toString() ? `?${params.toString()}` : ''}`, - ); + const url = `${window.location.pathname}${params.toString() ? `?${params.toString()}` : ''}`; + + if (options.push) { + window.history.pushState({ searchState: { ...state } }, '', url); + } else { + window.history.replaceState({ searchState: { ...state } }, '', url); + } }, + updateFromUrl: (params) => { - const newState = { ...initialState }; + const newState: Record = {}; + + newState.searchQuery = params.get('q') ?? ''; const difficulty = params.get('difficulty'); if (difficulty) { const validated = difficulty.split(',').map(sanitizeString).filter(isValidDifficulty); if (validated.length) newState.difficulty = validated; + } else { + newState.difficulty = []; } const duration = params.get('duration'); @@ -111,24 +174,32 @@ export const useSearchStore = create()( const parts = duration.split(',').map(Number); const validated = validateNumericRange(parts[0], parts[1], 0, 100); if (validated) newState.duration = validated; + } else { + newState.duration = [0, 20]; } const topics = params.get('topics'); if (topics) { const validated = validateStringArray(topics.split(',')); if (validated.length) newState.topics = validated; + } else { + newState.topics = []; } const instructors = params.get('instructors'); if (instructors) { const validated = validateStringArray(instructors.split(',')); if (validated.length) newState.instructors = validated; + } else { + newState.instructors = []; } const sort = params.get('sort'); if (sort) { const sanitized = sanitizeString(sort); if (isValidSortOption(sanitized)) newState.sortBy = sanitized; + } else { + newState.sortBy = 'relevance'; } const price = params.get('price'); @@ -136,6 +207,15 @@ export const useSearchStore = create()( const parts = price.split(',').map(Number); const validated = validateNumericRange(parts[0], parts[1], 0, 10000); if (validated) newState.price = validated; + } else { + newState.price = [0, 1000]; + } + + const cursor = params.get('cursor'); + if (cursor !== null) { + newState.cursor = cursor; + } else { + newState.cursor = undefined; } set(newState); @@ -146,3 +226,10 @@ export const useSearchStore = create()( }, ), ); + +if (typeof window !== 'undefined') { + window.addEventListener('popstate', () => { + const params = new URLSearchParams(window.location.search); + useSearchStore.getState().updateFromUrl(params); + }); +}