diff --git a/src/__tests__/components/AjaxSelectClientSide.test.tsx b/src/__tests__/components/AjaxSelectClientSide.test.tsx new file mode 100644 index 0000000..78c50f1 --- /dev/null +++ b/src/__tests__/components/AjaxSelectClientSide.test.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import AjaxSelect from '@/components/AjaxSelect'; +import { useSearchOptions, useSelectedOptions } from '../../hooks/useSearchOptions'; + +// Mock the useSearchOptions hook +vi.mock('../../hooks/useSearchOptions'); + +const mockUseSearchOptions = vi.mocked(useSearchOptions); +const mockUseSelectedOptions = vi.mocked(useSelectedOptions); + +const mockOptions = [ + { id: 1, name: 'Concert' }, + { id: 2, name: 'Conference' }, + { id: 3, name: 'Party' }, + { id: 4, name: 'Festival' }, +]; + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return ( + + {children} + + ); +}; + +describe('AjaxSelect Client Side Filtering', () => { + const defaultProps = { + label: 'Test Select', + endpoint: 'test-endpoint', + value: '' as number | '', + onChange: vi.fn(), + placeholder: 'Type to search...', + clientSideFiltering: true, + }; + + beforeEach(() => { + vi.clearAllMocks(); + // Mock useSearchOptions to return ALL options regardless of query + mockUseSearchOptions.mockReturnValue({ + data: mockOptions, + isLoading: false, + error: null, + isError: false, + isSuccess: true, + status: 'success', + fetchStatus: 'idle', + isPending: false, + isLoadingError: false, + isRefetchError: false, + isRefetching: false, + isFetching: false, + isFetched: true, + isFetchedAfterMount: true, + isPlaceholderData: false, + isStale: false, + refetch: vi.fn(), + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isInitialLoading: false, + isPaused: false, + isEnabled: true, + promise: Promise.resolve(mockOptions), + } as const); + + // Mock useSelectedOptions to return empty by default + mockUseSelectedOptions.mockReturnValue({ + data: [], + isLoading: false, + error: null, + isError: false, + isSuccess: true, + status: 'success', + fetchStatus: 'idle', + isPending: false, + isLoadingError: false, + isRefetchError: false, + isRefetching: false, + isFetching: false, + isFetched: true, + isFetchedAfterMount: true, + isPlaceholderData: false, + isStale: false, + refetch: vi.fn(), + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isInitialLoading: false, + isPaused: false, + isEnabled: true, + promise: Promise.resolve([]), + } as const); + }); + + it('filters options client-side based on search query', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const input = screen.getByRole('combobox'); + await user.click(input); + + // Initially all options should be visible + expect(screen.getByText('Concert')).toBeInTheDocument(); + expect(screen.getByText('Conference')).toBeInTheDocument(); + expect(screen.getByText('Party')).toBeInTheDocument(); + expect(screen.getByText('Festival')).toBeInTheDocument(); + + // Type 'Con' - should show Concert and Conference + await user.type(input, 'Con'); + + await waitFor(() => { + expect(screen.getByText('Concert')).toBeInTheDocument(); + expect(screen.getByText('Conference')).toBeInTheDocument(); + expect(screen.queryByText('Party')).not.toBeInTheDocument(); + expect(screen.queryByText('Festival')).not.toBeInTheDocument(); + }); + + // Type 'f' - should show Conference and Festival (case insensitive) + await user.clear(input); + await user.type(input, 'f'); + + await waitFor(() => { + expect(screen.queryByText('Concert')).not.toBeInTheDocument(); + expect(screen.getByText('Conference')).toBeInTheDocument(); + expect(screen.queryByText('Party')).not.toBeInTheDocument(); + expect(screen.getByText('Festival')).toBeInTheDocument(); + }); + }); + + it('calls useSearchOptions with empty query when clientSideFiltering is true', () => { + render( + + + + ); + + expect(mockUseSearchOptions).toHaveBeenCalledWith( + 'test-endpoint', + '', // Empty query + {}, + { limit: 100 } // Limit override + ); + }); +}); diff --git a/src/components/AjaxSelect.tsx b/src/components/AjaxSelect.tsx index d8e0cbf..f027c61 100644 --- a/src/components/AjaxSelect.tsx +++ b/src/components/AjaxSelect.tsx @@ -17,6 +17,7 @@ interface AjaxSelectProps { extraParams?: Record; className?: string; disabled?: boolean; + clientSideFiltering?: boolean; } export const AjaxSelect: React.FC = ({ @@ -29,6 +30,7 @@ export const AjaxSelect: React.FC = ({ extraParams = {}, className = '', disabled = false, + clientSideFiltering = false, }) => { const [query, setQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); @@ -54,8 +56,9 @@ export const AjaxSelect: React.FC = ({ // Fetch search results based on user query const { data: searchResults = [], isLoading } = useSearchOptions( endpoint, - debouncedQuery, - extraParams + clientSideFiltering ? '' : debouncedQuery, + extraParams, + clientSideFiltering ? { limit: 100 } : {} ); // Merge and deduplicate options from both queries @@ -76,9 +79,18 @@ export const AjaxSelect: React.FC = ({ }, [selectedOptionsData, searchResults]); // Filter out already selected option for the dropdown - const availableOptions = value - ? allOptions.filter(option => option.id !== value) - : allOptions; + const availableOptions = useMemo(() => { + let options = value + ? allOptions.filter(option => option.id !== value) + : allOptions; + + if (clientSideFiltering && query) { + const lowerQuery = query.toLowerCase(); + options = options.filter(option => option.name.toLowerCase().includes(lowerQuery)); + } + + return options; + }, [allOptions, value, clientSideFiltering, query]); // Get selected option for display const selectedOption = value @@ -95,7 +107,7 @@ export const AjaxSelect: React.FC = ({ ? 'Searching...' : availableOptions.length > 0 ? `${availableOptions.length} result${availableOptions.length === 1 ? '' : 's'} available` - : query && debouncedQuery + : query && (clientSideFiltering || debouncedQuery) ? 'No results found' : ''; @@ -309,7 +321,7 @@ export const AjaxSelect: React.FC = ({ {option.name} )) - ) : query && debouncedQuery ? ( + ) : query && (clientSideFiltering || debouncedQuery) ? (
No results found for "{query}"
diff --git a/src/routes/event-edit.tsx b/src/routes/event-edit.tsx index 1451820..c09671a 100644 --- a/src/routes/event-edit.tsx +++ b/src/routes/event-edit.tsx @@ -256,6 +256,7 @@ const EventEdit: React.FC<{ eventSlug: string }> = ({ eventSlug }) => { value={formData.event_type_id} onChange={(val) => setFormData((p) => ({ ...p, event_type_id: val }))} placeholder="Type to search event types..." + clientSideFiltering={true} /> {renderError('event_type_id')}