Skip to content
Merged
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
165 changes: 165 additions & 0 deletions src/__tests__/components/AjaxSelectClientSide.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};

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(
<TestWrapper>
<AjaxSelect {...defaultProps} />
</TestWrapper>
);

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(
<TestWrapper>
<AjaxSelect {...defaultProps} />
</TestWrapper>
);

expect(mockUseSearchOptions).toHaveBeenCalledWith(
'test-endpoint',
'', // Empty query
{},
{ limit: 100 } // Limit override
);
});
});
26 changes: 19 additions & 7 deletions src/components/AjaxSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface AjaxSelectProps {
extraParams?: Record<string, string | number>;
className?: string;
disabled?: boolean;
clientSideFiltering?: boolean;
}

export const AjaxSelect: React.FC<AjaxSelectProps> = ({
Expand All @@ -29,6 +30,7 @@ export const AjaxSelect: React.FC<AjaxSelectProps> = ({
extraParams = {},
className = '',
disabled = false,
clientSideFiltering = false,
}) => {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
Expand All @@ -54,8 +56,9 @@ export const AjaxSelect: React.FC<AjaxSelectProps> = ({
// 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
Expand All @@ -76,9 +79,18 @@ export const AjaxSelect: React.FC<AjaxSelectProps> = ({
}, [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
Expand All @@ -95,7 +107,7 @@ export const AjaxSelect: React.FC<AjaxSelectProps> = ({
? 'Searching...'
: availableOptions.length > 0
? `${availableOptions.length} result${availableOptions.length === 1 ? '' : 's'} available`
: query && debouncedQuery
: query && (clientSideFiltering || debouncedQuery)
? 'No results found'
: '';

Expand Down Expand Up @@ -309,7 +321,7 @@ export const AjaxSelect: React.FC<AjaxSelectProps> = ({
{option.name}
</button>
))
) : query && debouncedQuery ? (
) : query && (clientSideFiltering || debouncedQuery) ? (
<div className="px-3 py-2 text-gray-500 dark:text-gray-400 text-sm" role="status">
No results found for "{query}"
</div>
Expand Down
1 change: 1 addition & 0 deletions src/routes/event-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')}
</div>
Expand Down