diff --git a/frontend/src/components/NewSelect/CustomMultiSelect.tsx b/frontend/src/components/NewSelect/CustomMultiSelect.tsx index 23333e032273..e935d2ace9cd 100644 --- a/frontend/src/components/NewSelect/CustomMultiSelect.tsx +++ b/frontend/src/components/NewSelect/CustomMultiSelect.tsx @@ -37,7 +37,7 @@ enum ToggleTagValue { All = 'All', } -const ALL_SELECTED_VALUE = '__all__'; // Constant for the special value +const ALL_SELECTED_VALUE = '__ALL__'; // Constant for the special value const CustomMultiSelect: React.FC = ({ placeholder = 'Search...', @@ -62,6 +62,7 @@ const CustomMultiSelect: React.FC = ({ allowClear = false, onRetry, maxTagTextLength, + onDropdownVisibleChange, ...rest }) => { // ===== State & Refs ===== @@ -144,7 +145,7 @@ const CustomMultiSelect: React.FC = ({ return; } - // Case 2: "__all__" is selected (means select all actual values) + // Case 2: "__ALL__" is selected (means select all actual values) if (currentNewValue.includes(ALL_SELECTED_VALUE)) { const allActualOptions = allAvailableValues.map( (v) => options.flat().find((o) => o.value === v) || { label: v, value: v }, @@ -272,7 +273,8 @@ const CustomMultiSelect: React.FC = ({ : filteredOptions, ); } - }, [filteredOptions, searchText, options, selectedValues]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filteredOptions, searchText, options]); // ===== Text Selection Utilities ===== @@ -528,28 +530,33 @@ const CustomMultiSelect: React.FC = ({ (text: string, searchQuery: string): React.ReactNode => { if (!searchQuery || !highlightSearch) return text; - const parts = text.split( - new RegExp( - `(${searchQuery.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')})`, - 'gi', - ), - ); - return ( - <> - {parts.map((part, i) => { - // Create a unique key that doesn't rely on array index - const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`; + try { + const parts = text.split( + new RegExp( + `(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`, + 'gi', + ), + ); + return ( + <> + {parts.map((part, i) => { + // Create a unique key that doesn't rely on array index + const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`; - return part.toLowerCase() === searchQuery.toLowerCase() ? ( - - {part} - - ) : ( - part - ); - })} - - ); + return part.trim().toLowerCase() === searchQuery.trim().toLowerCase() ? ( + + {part} + + ) : ( + part + ); + })} + + ); + } catch (error) { + // If regex fails, return the original text without highlighting + return text; + } }, [highlightSearch], ); @@ -752,7 +759,7 @@ const CustomMultiSelect: React.FC = ({ if (hasAll) { flatList.push({ label: 'ALL', - value: '__all__', // Special value for the ALL option + value: ALL_SELECTED_VALUE, // Special value for the ALL option type: 'defined', }); } @@ -1129,7 +1136,7 @@ const CustomMultiSelect: React.FC = ({ // If there's an active option in the dropdown, prioritize selecting it if (activeIndex >= 0 && activeIndex < flatOptions.length) { const selectedOption = flatOptions[activeIndex]; - if (selectedOption.value === '__all__') { + if (selectedOption.value === ALL_SELECTED_VALUE) { handleSelectAll(); } else if (selectedOption.value && onChange) { const newValues = selectedValues.includes(selectedOption.value) @@ -1159,6 +1166,10 @@ const CustomMultiSelect: React.FC = ({ e.preventDefault(); setIsOpen(false); setActiveIndex(-1); + // Call onDropdownVisibleChange when Escape is pressed to close dropdown + if (onDropdownVisibleChange) { + onDropdownVisibleChange(false); + } break; case SPACEKEY: @@ -1168,7 +1179,7 @@ const CustomMultiSelect: React.FC = ({ const selectedOption = flatOptions[activeIndex]; // Check if it's the ALL option - if (selectedOption.value === '__all__') { + if (selectedOption.value === ALL_SELECTED_VALUE) { handleSelectAll(); } else if (selectedOption.value && onChange) { const newValues = selectedValues.includes(selectedOption.value) @@ -1282,6 +1293,7 @@ const CustomMultiSelect: React.FC = ({ handleSelectAll, getVisibleChipIndices, getLastVisibleChipIndex, + onDropdownVisibleChange, ], ); @@ -1524,6 +1536,18 @@ const CustomMultiSelect: React.FC = ({ onRetry, ]); + // Custom handler for dropdown visibility changes + const handleDropdownVisibleChange = useCallback( + (visible: boolean): void => { + setIsOpen(visible); + // Pass through to the parent component's handler if provided + if (onDropdownVisibleChange) { + onDropdownVisibleChange(visible); + } + }, + [onDropdownVisibleChange], + ); + // ===== Side Effects ===== // Clear search when dropdown closes @@ -1739,7 +1763,7 @@ const CustomMultiSelect: React.FC = ({ value={displayValue} onChange={handleInternalChange} onClear={(): void => handleInternalChange([])} - onDropdownVisibleChange={setIsOpen} + onDropdownVisibleChange={handleDropdownVisibleChange} open={isOpen} defaultActiveFirstOption={defaultActiveFirstOption} popupMatchSelectWidth={dropdownMatchSelectWidth} diff --git a/frontend/src/components/NewSelect/CustomSelect.tsx b/frontend/src/components/NewSelect/CustomSelect.tsx index ec9e55c31e33..8c23cda48c26 100644 --- a/frontend/src/components/NewSelect/CustomSelect.tsx +++ b/frontend/src/components/NewSelect/CustomSelect.tsx @@ -130,23 +130,33 @@ const CustomSelect: React.FC = ({ (text: string, searchQuery: string): React.ReactNode => { if (!searchQuery || !highlightSearch) return text; - const parts = text.split(new RegExp(`(${searchQuery})`, 'gi')); - return ( - <> - {parts.map((part, i) => { - // Create a deterministic but unique key - const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`; + try { + const parts = text.split( + new RegExp( + `(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`, + 'gi', + ), + ); + return ( + <> + {parts.map((part, i) => { + // Create a deterministic but unique key + const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`; - return part.toLowerCase() === searchQuery.toLowerCase() ? ( - - {part} - - ) : ( - part - ); - })} - - ); + return part.toLowerCase() === searchQuery.toLowerCase() ? ( + + {part} + + ) : ( + part + ); + })} + + ); + } catch (error) { + console.error('Error in text highlighting:', error); + return text; + } }, [highlightSearch], ); diff --git a/frontend/src/components/NewSelect/__test__/CustomMultiSelect.comprehensive.test.tsx b/frontend/src/components/NewSelect/__test__/CustomMultiSelect.comprehensive.test.tsx new file mode 100644 index 000000000000..37f4d18ba4f6 --- /dev/null +++ b/frontend/src/components/NewSelect/__test__/CustomMultiSelect.comprehensive.test.tsx @@ -0,0 +1,1492 @@ +/* eslint-disable sonarjs/no-identical-functions */ +/* eslint-disable sonarjs/no-duplicate-string */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import CustomMultiSelect from '../CustomMultiSelect'; + +// Mock scrollIntoView which isn't available in JSDOM +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +// Mock clipboard API +Object.assign(navigator, { + clipboard: { + writeText: jest.fn(() => Promise.resolve()), + }, +}); + +// Test data +const mockOptions = [ + { label: 'Frontend', value: 'frontend' }, + { label: 'Backend', value: 'backend' }, + { label: 'Database', value: 'database' }, + { label: 'API Gateway', value: 'api-gateway' }, +]; + +const mockGroupedOptions = [ + { + label: 'Development', + options: [ + { label: 'Frontend Dev', value: 'frontend-dev' }, + { label: 'Backend Dev', value: 'backend-dev' }, + ], + }, + { + label: 'Operations', + options: [ + { label: 'DevOps', value: 'devops' }, + { label: 'SRE', value: 'sre' }, + ], + }, +]; + +describe('CustomMultiSelect - Comprehensive Tests', () => { + let user: ReturnType; + let mockOnChange: jest.Mock; + + beforeEach(() => { + user = userEvent.setup(); + mockOnChange = jest.fn(); + jest.clearAllMocks(); + }); + + // ===== 1. CUSTOM VALUES SUPPORT ===== + describe('Custom Values Support (CS)', () => { + test('CS-01: Custom values persist in selected state', async () => { + const { rerender } = render( + , + ); + + expect(screen.getByText('custom-value')).toBeInTheDocument(); + expect(screen.getByText('frontend')).toBeInTheDocument(); + + // Rerender with updated props + rerender( + , + ); + + // Custom values should still be there + expect(screen.getByText('custom-value')).toBeInTheDocument(); + expect(screen.getByText('another-custom')).toBeInTheDocument(); + }); + + test('CS-02: Partial matches create custom values', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Wait for dropdown to open + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + // Find input by class name + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + // Type partial match that doesn't exist exactly + if (searchInput) { + await user.type(searchInput, 'fro'); + } + + // Check that custom value appears in dropdown with custom tag + await waitFor(() => { + // Find the custom option with "fro" text and "Custom" badge + const customOptions = screen.getAllByText('fro'); + const customOption = customOptions.find((option) => { + const optionItem = option.closest('.option-item'); + const badge = optionItem?.querySelector('.option-badge'); + return badge?.textContent === 'Custom'; + }); + + expect(customOption).toBeInTheDocument(); + + // Verify it has the custom badge + const optionItem = customOption?.closest('.option-item'); + const badge = optionItem?.querySelector('.option-badge'); + expect(badge).toBeInTheDocument(); + expect(badge?.textContent).toBe('Custom'); + }); + + // Press Enter to create the custom value + await user.keyboard('{Enter}'); + + // Should create a custom value for partial match + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith( + ['fro'], + [{ label: 'fro', value: 'fro' }], + ); + }); + }); + + test('CS-03: Exact match filtering behavior', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'frontend'); + } + + // Should show the Frontend option in dropdown for exact match + await waitFor(() => { + // Check for highlighted "frontend" text + const highlightedElements = document.querySelectorAll('.highlight-text'); + const highlightTexts = Array.from(highlightedElements).map( + (el) => el.textContent, + ); + expect(highlightTexts).toContain('Frontend'); + + // Frontend option should be visible in dropdown - use a simpler approach + const optionLabels = document.querySelectorAll('.option-label-text'); + const hasFrontendOption = Array.from(optionLabels).some((label) => + label.textContent?.includes('Frontend'), + ); + expect(hasFrontendOption).toBe(true); + }); + + // Press Enter to select the exact match + await user.keyboard('{Enter}'); + + // Should create selection with exact match + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith( + ['frontend'], + [{ label: 'frontend', value: 'frontend' }], + ); + }); + }); + + test('CS-04: Search filtering with "end" pattern', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'end'); + } + + // Should filter to show only Frontend and Backend with highlighted "end" + await waitFor(() => { + // Check for highlighted "end" text in the options + const highlightedElements = document.querySelectorAll('.highlight-text'); + const highlightTexts = Array.from(highlightedElements).map( + (el) => el.textContent, + ); + expect(highlightTexts).toContain('end'); + + // Check that Frontend and Backend options are present with highlighted text + const optionLabels = document.querySelectorAll('.option-label-text'); + const hasFrontendOption = Array.from(optionLabels).some( + (label) => + label.textContent?.includes('Front') && + label.textContent?.includes('end'), + ); + const hasBackendOption = Array.from(optionLabels).some( + (label) => + label.textContent?.includes('Back') && label.textContent?.includes('end'), + ); + + expect(hasFrontendOption).toBe(true); + expect(hasBackendOption).toBe(true); + + // Other options should be filtered out + const hasDatabaseOption = Array.from(optionLabels).some((label) => + label.textContent?.includes('Database'), + ); + const hasApiGatewayOption = Array.from(optionLabels).some((label) => + label.textContent?.includes('API Gateway'), + ); + + expect(hasDatabaseOption).toBe(false); + expect(hasApiGatewayOption).toBe(false); + }); + }); + + test('CS-05: Comma-separated values behavior', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + // Type comma-separated values + await user.type(searchInput, 'test1, test2, test3'); + } + + // Press Enter to create the comma-separated chips + await user.keyboard('{Enter}'); + + await waitFor(() => { + // Should create separate chips for each comma-separated value + // The component processes each value individually + expect(mockOnChange).toHaveBeenCalledTimes(3); + + // Check that each individual value was processed + expect(mockOnChange).toHaveBeenNthCalledWith( + 1, + ['test1'], + [{ label: 'test1', value: 'test1' }], + ); + expect(mockOnChange).toHaveBeenNthCalledWith( + 2, + ['test2'], + [{ label: 'test2', value: 'test2' }], + ); + expect(mockOnChange).toHaveBeenNthCalledWith( + 3, + ['test3'], + [{ label: 'test3', value: 'test3' }], + ); + }); + + // Note: The component processes comma-separated values by calling onChange for each value + // The actual chip display might depend on the component's internal state management + }); + }); + + // ===== 2. SEARCH AND FILTERING ===== + describe('Search and Filtering (SF)', () => { + test('SF-01: Selected values pushed to top', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + const dropdown = document.querySelector('.custom-multiselect-dropdown'); + expect(dropdown).toBeInTheDocument(); + + const options = dropdown?.querySelectorAll('.option-label-text') || []; + const optionTexts = Array.from(options).map((el) => el.textContent); + + // Database should be at the top (after ALL option if present) + expect(optionTexts[0]).toBe('Database'); + }); + }); + + test('SF-02: Filtering with search text', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Wait for dropdown to open + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + // Find and type in search input + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'front'); + } + + // Should filter to show only options containing 'front' and highlight search term + await waitFor(() => { + // Check for highlighted text within the Frontend option + const highlightedElements = document.querySelectorAll('.highlight-text'); + const highlightTexts = Array.from(highlightedElements).map( + (el) => el.textContent, + ); + expect(highlightTexts).toContain('Front'); + + // Should show Frontend option (highlighted) - use a simpler approach + const optionLabels = document.querySelectorAll('.option-label-text'); + const hasFrontendOption = Array.from(optionLabels).some((label) => + label.textContent?.includes('Frontend'), + ); + expect(hasFrontendOption).toBe(true); + + // Backend and Database should not be visible + expect(screen.queryByText('Backend')).not.toBeInTheDocument(); + expect(screen.queryByText('Database')).not.toBeInTheDocument(); + }); + }); + + test('SF-03: Highlighting search matches', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'end'); + } + + // Should highlight matching text in options + await waitFor(() => { + const highlightedElements = document.querySelectorAll('.highlight-text'); + const highlightTexts = Array.from(highlightedElements).map( + (el) => el.textContent, + ); + expect(highlightTexts).toContain('end'); + }); + }); + + test('SF-04: Search with no results', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'nonexistent'); + } + + // Should show custom value option when no matches found + await waitFor(() => { + // Original options should not be visible + expect(screen.queryByText('Frontend')).not.toBeInTheDocument(); + expect(screen.queryByText('Backend')).not.toBeInTheDocument(); + expect(screen.queryByText('Database')).not.toBeInTheDocument(); + expect(screen.queryByText('API Gateway')).not.toBeInTheDocument(); + + // Should show custom value option + const customOptions = screen.getAllByText('nonexistent'); + const customOption = customOptions.find((option) => { + const optionItem = option.closest('.option-item'); + const badge = optionItem?.querySelector('.option-badge'); + return badge?.textContent === 'Custom'; + }); + expect(customOption).toBeInTheDocument(); + }); + }); + }); + + // ===== 3. KEYBOARD NAVIGATION ===== + describe('Keyboard Navigation (KN)', () => { + test('KN-01: Arrow key navigation in dropdown', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + // Simulate arrow down key + await user.keyboard('{ArrowDown}'); + + // ALL option should be active first + await waitFor(() => { + const activeOption = document.querySelector('.option-item.active'); + expect(activeOption).toBeInTheDocument(); + expect(activeOption).toHaveClass('all-option', 'active'); + expect(activeOption).toHaveAttribute('role', 'option'); + expect(activeOption?.textContent).toContain('ALL'); + }); + + // Arrow up should go to last option (API Gateway) + await user.keyboard('{ArrowUp}'); + + await waitFor(() => { + const activeOption = document.querySelector('.option-item.active'); + expect(activeOption).toBeInTheDocument(); + expect(activeOption).toHaveClass('option-item', 'active'); + expect(activeOption).toHaveAttribute('role', 'option'); + expect(activeOption?.textContent).toContain('API Gateway'); + + // Should have Only and Toggle buttons + const onlyButton = activeOption?.querySelector('.only-btn'); + const toggleButton = activeOption?.querySelector('.toggle-btn'); + expect(onlyButton).toBeInTheDocument(); + expect(toggleButton).toBeInTheDocument(); + expect(onlyButton?.textContent).toBe('Only'); + expect(toggleButton?.textContent).toBe('Toggle'); + }); + }); + + test('KN-02: Tab navigation to dropdown', async () => { + render( +
+ + + +
, + ); + + const prevInput = screen.getByTestId('prev-input'); + await user.click(prevInput); + + // Tab to multiselect combobox + await user.tab(); + + const combobox = screen.getByRole('combobox'); + expect(combobox).toHaveFocus(); + + // Open dropdown + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + // Tab from input section to dropdown + await user.tab(); + + // Should navigate to first option in dropdown + await waitFor(() => { + const activeOption = document.querySelector('.option-item.active'); + expect(activeOption).toBeInTheDocument(); + expect(activeOption).toHaveClass('all-option', 'active'); + expect(activeOption?.textContent).toContain('ALL'); + }); + + // Tab again to move to next option + await user.tab(); + + await waitFor(() => { + const activeOption = document.querySelector('.option-item.active'); + expect(activeOption).toBeInTheDocument(); + expect(activeOption?.textContent).toContain('Frontend'); + }); + }); + + test('KN-03: Enter selection in dropdown', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Wait for dropdown to open + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + // Navigate to Frontend option and press Enter + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + + // Should have triggered onChange with ALL options selected + expect(mockOnChange).toHaveBeenCalledWith( + ['frontend', 'backend', 'database', 'api-gateway'], + [ + { label: 'Frontend', value: 'frontend' }, + { label: 'Backend', value: 'backend' }, + { label: 'Database', value: 'database' }, + { label: 'API Gateway', value: 'api-gateway' }, + ], + ); + + // Verify that ALL chip is displayed (not individual chips) + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + // Individual options should NOT be displayed as separate chips in the selection area + const selectionArea = document.querySelector( + '.ant-select-selection-overflow', + ); + expect(selectionArea).not.toHaveTextContent('Frontend'); + expect(selectionArea).not.toHaveTextContent('Backend'); + expect(selectionArea).not.toHaveTextContent('Database'); + expect(selectionArea).not.toHaveTextContent('API Gateway'); + }); + }); + + test('KN-04: Chip deletion with keyboard', async () => { + render( + , + ); + + // Focus on the component + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Navigate to chips using arrow keys + await user.keyboard('{ArrowLeft}'); + + // Should have an active chip + await waitFor(() => { + const activeChip = document.querySelector( + '.ant-select-selection-item-active', + ); + expect(activeChip).toBeInTheDocument(); + }); + + // Delete the active chip + await user.keyboard('{Backspace}'); + + // Should have triggered onChange with database removed + expect(mockOnChange).toHaveBeenCalledWith( + ['frontend', 'backend'], + ['frontend', 'backend'], + ); + + // Verify that the active chip (backend) is highlighted for deletion + await waitFor(() => { + const activeChip = document.querySelector( + '.ant-select-selection-item-active', + ); + expect(activeChip).toBeInTheDocument(); + expect(activeChip).toHaveTextContent('backend'); + }); + }); + }); + + // ===== 5. UI/UX BEHAVIORS ===== + describe('UI/UX Behaviors (UI)', () => { + test('UI-01: Loading state does not block interaction', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + + // Should still be clickable and interactive + await user.click(combobox); + expect(combobox).toHaveFocus(); + + // Check loading message is shown + await waitFor(() => { + expect( + screen.getByText('We are updating the values...'), + ).toBeInTheDocument(); + }); + }); + + test('UI-02: Component remains editable in all states', async () => { + const { rerender } = render( + , + ); + + const combobox = screen.getByRole('combobox'); + expect(combobox).not.toBeDisabled(); + + // Rerender with error state + rerender( + , + ); + + expect(combobox).not.toBeDisabled(); + + // Rerender with no data + rerender( + , + ); + + expect(combobox).not.toBeDisabled(); + }); + + test('UI-03: Toggle/Only labels in dropdown', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + // Should show toggle/only buttons for options + const toggleButtons = screen.getAllByText('Toggle'); + expect(toggleButtons.length).toBeGreaterThan(0); + + const onlyButtons = screen.getAllByText(/Only|All/); + expect(onlyButtons.length).toBeGreaterThan(0); + }); + }); + + test('UI-04: Should display values with loading info at bottom', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + // should display values + expect(screen.getByText('Frontend')).toBeInTheDocument(); + expect(screen.getByText('Backend')).toBeInTheDocument(); + expect(screen.getByText('Database')).toBeInTheDocument(); + expect(screen.getByText('API Gateway')).toBeInTheDocument(); + + const loadingFooter = document.querySelector('.navigation-loading'); + expect(loadingFooter).toBeInTheDocument(); + expect( + screen.getByText('We are updating the values...'), + ).toBeInTheDocument(); + }); + }); + + test('UI-05: Error state display in footer', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + const errorFooter = document.querySelector('.navigation-error'); + expect(errorFooter).toBeInTheDocument(); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + }); + + test('UI-06: No data state display', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('No options available')).toBeInTheDocument(); + }); + }); + }); + + // ===== 6. CLEAR ACTIONS ===== + describe('Clear Actions (CA)', () => { + test('CA-01: Ctrl+A selects all chips', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Ctrl+A should select all chips + await user.keyboard('{Control>}a{/Control}'); + + await waitFor(() => { + // All chips should be selected (have selected class) + const selectedChips = document.querySelectorAll( + '.ant-select-selection-item-selected', + ); + expect(selectedChips.length).toBeGreaterThan(0); + + // Verify that all expected chips are present AND selected + const frontendChip = screen + .getByText('frontend') + .closest('.ant-select-selection-item'); + const backendChip = screen + .getByText('backend') + .closest('.ant-select-selection-item'); + const databaseChip = screen + .getByText('database') + .closest('.ant-select-selection-item'); + + expect(frontendChip).toHaveClass('ant-select-selection-item-selected'); + expect(backendChip).toHaveClass('ant-select-selection-item-selected'); + expect(databaseChip).toHaveClass('ant-select-selection-item-selected'); + + // Verify all chips are present + expect(screen.getByText('frontend')).toBeInTheDocument(); + expect(screen.getByText('backend')).toBeInTheDocument(); + expect(screen.getByText('database')).toBeInTheDocument(); + }); + }); + + test('CA-02: Clear icon removes all selections', async () => { + render( + , + ); + + const clearButton = document.querySelector('.ant-select-clear'); + if (clearButton) { + await user.click(clearButton as Element); + expect(mockOnChange).toHaveBeenCalledWith([], []); + } + }); + + test('CA-03: Individual chip removal', async () => { + render( + , + ); + + // Find and click the close button on a chip + const removeButtons = document.querySelectorAll( + '.ant-select-selection-item-remove', + ); + expect(removeButtons.length).toBe(2); + + await user.click(removeButtons[1] as Element); + + // Should call onChange with one item removed + expect(mockOnChange).toHaveBeenCalledWith( + ['frontend'], + [{ label: 'Frontend', value: 'frontend' }], + ); + }); + }); + + // ===== 7. SAVE AND SELECTION TRIGGERS ===== + describe('Save and Selection Triggers (ST)', () => { + test('ST-01: ESC triggers save action', async () => { + const mockDropdownChange = jest.fn(); + + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Verify dropdown is visible before Escape + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + expect(mockDropdownChange).toHaveBeenCalledWith(true); + }); + + await user.keyboard('{Escape}'); + + // Verify dropdown is closed after Escape + expect(mockDropdownChange).toHaveBeenCalledWith(false); + + await waitFor(() => { + // Dropdown should be hidden (not completely removed from DOM) + const dropdown = document.querySelector('.ant-select-dropdown'); + expect(dropdown).toHaveClass('ant-select-dropdown-hidden'); + expect(dropdown).toHaveStyle('pointer-events: none'); + }); + }); + + test('ST-02: Mouse selection works', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + const frontendOption = screen.getByText('Frontend'); + expect(frontendOption).toBeInTheDocument(); + }); + + const frontendOption = screen.getByText('Frontend'); + await user.click(frontendOption); + + expect(mockOnChange).toHaveBeenCalledWith( + expect.arrayContaining(['frontend']), + expect.any(Array), + ); + }); + + test('ST-03: ENTER in input field creates custom value', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'custom-input'); + } + + // Press Enter in input field + await user.keyboard('{Enter}'); + + // Should create custom value + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith( + ['custom-input'], + [{ label: 'custom-input', value: 'custom-input' }], + ); + }); + }); + + test('ST-04: Search text persistence', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'search-text'); + } + + // Verify search text is in input + await waitFor(() => { + expect(searchInput).toHaveValue('search-text'); + }); + + // Press Escape to close dropdown + await user.keyboard('{Escape}'); + + // Dropdown should close and search text should be cleared + await waitFor(() => { + const dropdown = document.querySelector('.ant-select-dropdown'); + expect(dropdown).toHaveClass('ant-select-dropdown-hidden'); + expect(searchInput).toHaveValue(''); + }); + }); + }); + + // ===== 8. SPECIAL OPTIONS AND STATES ===== + describe('Special Options and States (SO)', () => { + test('SO-01: ALL option appears first and separated', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + const allOption = screen.getByText('ALL'); + expect(allOption).toBeInTheDocument(); + + // Check for divider after ALL option + const divider = document.querySelector('.divider'); + expect(divider).toBeInTheDocument(); + }); + }); + + test('SO-02: ALL selection behavior', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + const allOption = screen.getByText('ALL'); + expect(allOption).toBeInTheDocument(); + }); + + const allOption = screen.getByText('ALL'); + await user.click(allOption); + + // Should select all available options + expect(mockOnChange).toHaveBeenCalledWith( + ['frontend', 'backend', 'database', 'api-gateway'], + expect.any(Array), + ); + }); + + test('SO-03: ALL tag display when all selected', () => { + render( + , + ); + + // Should show ALL tag instead of individual tags + expect(screen.getByText('ALL')).toBeInTheDocument(); + expect(screen.queryByText('frontend')).not.toBeInTheDocument(); + }); + + test('SO-04: Footer information display', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + // Should show navigation footer + const footer = document.querySelector('.navigation-footer'); + expect(footer).toBeInTheDocument(); + + // Should show navigation hint + expect(screen.getByText('to navigate')).toBeInTheDocument(); + }); + }); + }); + + // ===== GROUPED OPTIONS SUPPORT ===== + describe('Grouped Options Support', () => { + test('handles grouped options correctly', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + // Check group headers + expect(screen.getByText('Development')).toBeInTheDocument(); + expect(screen.getByText('Operations')).toBeInTheDocument(); + + // Check group options + expect(screen.getByText('Frontend Dev')).toBeInTheDocument(); + expect(screen.getByText('Backend Dev')).toBeInTheDocument(); + expect(screen.getByText('DevOps')).toBeInTheDocument(); + expect(screen.getByText('SRE')).toBeInTheDocument(); + }); + }); + }); + + // ===== ACCESSIBILITY TESTS ===== + describe('Accessibility', () => { + test('has proper ARIA attributes', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + expect(combobox).toHaveAttribute('aria-expanded'); + + await user.click(combobox); + + await waitFor(() => { + const listbox = screen.getByRole('listbox'); + expect(listbox).toBeInTheDocument(); + expect(listbox).toHaveAttribute('aria-multiselectable', 'true'); + }); + }); + + test('supports screen reader navigation', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + const options = screen.getAllByRole('option'); + expect(options.length).toBeGreaterThan(0); + + options.forEach((option) => { + expect(option).toHaveAttribute('aria-selected'); + }); + }); + }); + }); + + // ===== 9. ADVANCED KEYBOARD NAVIGATION ===== + describe('Advanced Keyboard Navigation (AKN)', () => { + test('AKN-01: Shift + Arrow + Del chip deletion', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Navigate to chips using arrow keys + await user.keyboard('{ArrowLeft}'); + + // Should have an active chip - verify initial chip + await waitFor(() => { + const activeChip = document.querySelector( + '.ant-select-selection-item-active', + ); + expect(activeChip).toBeInTheDocument(); + // Verify we're on the last chip (database) + expect(activeChip?.textContent).toContain('database'); + }); + + // Use Shift + Arrow to navigate to previous chip + await user.keyboard('{Shift>}{ArrowLeft}{/Shift}'); + + // Verify we're on a different chip (backend) + await waitFor(() => { + const activeChip = document.querySelector( + '.ant-select-selection-item-active', + ); + expect(activeChip).toBeInTheDocument(); + // Verify we moved to the previous chip (backend) + expect(activeChip?.textContent).toContain('backend'); + }); + + // Use Del to delete the active chip + await user.keyboard('{Delete}'); + + // Verify the chip was deleted + await waitFor(() => { + // Check that the backend chip is no longer present + const backendChip = document.querySelector('.ant-select-selection-item'); + expect(backendChip?.textContent).not.toContain('backend'); + + // Verify onChange was called with the updated value (without backend) + expect(mockOnChange).toHaveBeenCalledWith( + ['frontend'], + [{ label: 'frontend', value: 'frontend' }], + ); + }); + + // Verify focus remains on combobox + expect(combobox).toHaveFocus(); + }); + + test('AKN-03: Mouse out closes dropdown', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Verify dropdown is open + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + // Simulate mouse out by clicking outside + await user.click(document.body); + + // Dropdown should close - check for hidden state + await waitFor(() => { + const dropdown = document.querySelector('.ant-select-dropdown'); + // The dropdown should be hidden with the hidden class + expect(dropdown).toHaveClass('ant-select-dropdown-hidden'); + }); + }); + }); + + // ===== 10. ADVANCED FILTERING AND HIGHLIGHTING ===== + describe('Advanced Filtering and Highlighting (AFH)', () => { + test('AFH-01: Highlighted values pushed to top', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'front'); + } + + // Should show highlighted options with correct order + await waitFor(() => { + // Check for highlighted text + const highlightedElements = document.querySelectorAll('.highlight-text'); + const highlightTexts = Array.from(highlightedElements).map( + (el) => el.textContent, + ); + expect(highlightTexts).toContain('front'); + + // Get all option items to check the order + const optionItems = document.querySelectorAll('.option-item'); + const optionTexts = Array.from(optionItems) + .map((item) => { + const labelElement = item.querySelector('.option-label-text'); + return labelElement?.textContent?.trim(); + }) + .filter(Boolean); + + // Custom value "front" should appear first (above Frontend) + // The text might include regex pattern, so check for "front" in the text + expect(optionTexts[0]).toContain('.*front.*'); + + // Frontend should appear after the custom value + const frontendIndex = optionTexts.findIndex((text) => + text?.includes('Frontend'), + ); + expect(frontendIndex).toBeGreaterThan(0); // Should not be first + expect(optionTexts[frontendIndex]).toContain('Frontend'); + + // Should show Frontend option with highlighting (text is split due to highlighting) + const frontendOption = screen.getByText('end'); + expect(frontendOption).toBeInTheDocument(); + }); + }); + + test('AFH-02: Distinction between selection Enter and save Enter', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + // Navigate to an option using arrow keys + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); // Navigate to Frontend + + // Press Enter to select (should not close dropdown) + await user.keyboard('{Enter}'); + + // Dropdown should still be open for selection + await waitFor(() => { + expect(screen.getByText('Backend')).toBeInTheDocument(); + }); + + // Now type something and press Enter to save + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + if (searchInput) { + await user.type(searchInput, 'custom-value'); + } + + // Press Enter to save (should create custom value) + await user.keyboard('{Enter}'); + + // Should create custom value + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith( + ['custom-value'], + [{ label: 'custom-value', value: 'custom-value' }], + ); + }); + }); + }); + + // ===== 11. ADVANCED CLEAR ACTIONS ===== + describe('Advanced Clear Actions (ACA)', () => { + test('ACA-01: Clear action waiting behavior', async () => { + const mockOnChangeWithDelay = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve(), 100); + }), + ); + + render( + , + ); + + const clearButton = document.querySelector('.ant-select-clear'); + expect(clearButton).toBeInTheDocument(); + + // Click clear button + await user.click(clearButton as Element); + + // Should call onChange immediately (no loading state in this component) + expect(mockOnChangeWithDelay).toHaveBeenCalledWith([], []); + + // The component may call onChange multiple times, so just verify it was called + expect(mockOnChangeWithDelay).toHaveBeenCalled(); + }); + }); + + // ===== 12. ADVANCED UI STATES ===== + describe('Advanced UI States (AUS)', () => { + test('AUS-01: No data with previous value selected', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Should show no data message + await waitFor(() => { + expect(screen.getByText('No options available')).toBeInTheDocument(); + }); + + // Should still show the previous selected value + expect(screen.getByText('previous-value')).toBeInTheDocument(); + }); + + test('AUS-02: Always editable accessibility', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + + // Should be editable even in loading state + expect(combobox).not.toBeDisabled(); + await user.click(combobox); + expect(combobox).toHaveFocus(); + + // Should still be interactive + expect(combobox).not.toBeDisabled(); + }); + + test('AUS-03: Sufficient space for search value', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + // Type a long search value + if (searchInput) { + const longSearchValue = 'a'.repeat(50); // Reduced length to avoid timeout + await user.type(searchInput, longSearchValue); + } + + // Should not overflow or break layout + await waitFor(() => { + const searchContainer = document.querySelector( + '.ant-select-selection-search', + ); + const computedStyle = window.getComputedStyle(searchContainer as Element); + + // Should not have overflow issues + expect(computedStyle.overflow).not.toBe('hidden'); + }); + }); + }); + + // ===== 13. REGEX AND CUSTOM VALUES ===== + describe('Regex and Custom Values (RCV)', () => { + test('RCV-01: Regex pattern support', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Wait for dropdown to open (don't expect ALL option as it might not be there) + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + // Test regex pattern + await user.type(searchInput, '.*test.*'); + } + + // Should create custom value for regex pattern + await waitFor(() => { + const regexOptions = screen.getAllByText('.*test.*'); + const regexOption = regexOptions.find((option) => { + const optionItem = option.closest('.option-item'); + const badge = optionItem?.querySelector('.option-badge'); + return badge?.textContent === 'Custom'; + }); + expect(regexOption).toBeInTheDocument(); + }); + + // Press Enter to create the regex value + await user.keyboard('{Enter}'); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith( + ['.*test.*'], + [{ label: '.*test.*', value: '.*test.*' }], + ); + }); + }); + + test('RCV-02: Custom values treated as normal dropdown values', async () => { + const customOptions = [ + ...mockOptions, + { label: 'custom-value', value: 'custom-value', type: 'custom' as const }, + ]; + + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Wait for dropdown to open (don't expect ALL option as it might not be there) + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + // Custom value should appear in dropdown like normal options + expect(screen.getByText('custom-value')).toBeInTheDocument(); + + // Should be selectable like normal options + const customOption = screen.getByText('custom-value'); + await user.click(customOption); + + expect(mockOnChange).toHaveBeenCalledWith( + ['custom-value'], + [{ label: 'custom-value', value: 'custom-value' }], + ); + }); + }); + + // ===== 14. DROPDOWN PERSISTENCE ===== + describe('Dropdown Persistence (DP)', () => { + test('DP-01: Dropdown stays open for non-save actions', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + // Navigate with arrow keys (non-save action) + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + + // Dropdown should still be open + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + // Click on an option (selection action, not save) + const frontendOption = screen.getByText('Frontend'); + await user.click(frontendOption); + + // Dropdown should still be open for more selections + await waitFor(() => { + expect(screen.getByText('Backend')).toBeInTheDocument(); + }); + + // Only ESC should close the dropdown + await user.keyboard('{Escape}'); + + await waitFor(() => { + const dropdown = document.querySelector('.ant-select-dropdown'); + expect(dropdown).toHaveClass('ant-select-dropdown-hidden'); + }); + }); + }); +}); diff --git a/frontend/src/components/NewSelect/__test__/CustomSelect.comprehensive.test.tsx b/frontend/src/components/NewSelect/__test__/CustomSelect.comprehensive.test.tsx new file mode 100644 index 000000000000..c92a5191d642 --- /dev/null +++ b/frontend/src/components/NewSelect/__test__/CustomSelect.comprehensive.test.tsx @@ -0,0 +1,1088 @@ +/* eslint-disable sonarjs/no-identical-functions */ +/* eslint-disable sonarjs/no-duplicate-string */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import CustomSelect from '../CustomSelect'; + +// Mock scrollIntoView which isn't available in JSDOM +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +// Mock clipboard API +Object.assign(navigator, { + clipboard: { + writeText: jest.fn(() => Promise.resolve()), + }, +}); + +// Test data +const mockOptions = [ + { label: 'Frontend', value: 'frontend' }, + { label: 'Backend', value: 'backend' }, + { label: 'Database', value: 'database' }, + { label: 'API Gateway', value: 'api-gateway' }, +]; + +const mockGroupedOptions = [ + { + label: 'Development', + options: [ + { label: 'Frontend Dev', value: 'frontend-dev' }, + { label: 'Backend Dev', value: 'backend-dev' }, + ], + }, + { + label: 'Operations', + options: [ + { label: 'DevOps', value: 'devops' }, + { label: 'SRE', value: 'sre' }, + ], + }, +]; + +describe('CustomSelect - Comprehensive Tests', () => { + let user: ReturnType; + let mockOnChange: jest.Mock; + + beforeEach(() => { + user = userEvent.setup(); + mockOnChange = jest.fn(); + jest.clearAllMocks(); + }); + + // ===== 1. CUSTOM VALUES SUPPORT ===== + describe('Custom Values Support (CS)', () => { + test('CS-02: Partial matches create custom values', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Wait for dropdown to open + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + // Find input by class name + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + // Type partial match that doesn't exist exactly + if (searchInput) { + await user.type(searchInput, 'fro'); + } + + // Check that custom value appears in dropdown with custom tag + await waitFor(() => { + // Find the custom option with "fro" text and "Custom" badge + const customOptions = screen.getAllByText('fro'); + const customOption = customOptions.find((option) => { + const optionItem = option.closest('.option-item'); + const badge = optionItem?.querySelector('.option-badge'); + return badge?.textContent === 'Custom'; + }); + + expect(customOption).toBeInTheDocument(); + + // Verify it has the custom badge + const optionItem = customOption?.closest('.option-item'); + const badge = optionItem?.querySelector('.option-badge'); + expect(badge).toBeInTheDocument(); + expect(badge?.textContent).toBe('Custom'); + }); + + // Press Enter to create the custom value + await user.keyboard('{Enter}'); + + // Should create a custom value for partial match + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith('fro', { + label: 'fro', + value: 'fro', + type: 'custom', + }); + }); + }); + + test('CS-03: Exact match behavior', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'frontend'); + } + + // Should show the Frontend option in dropdown for exact match + await waitFor(() => { + // Check for highlighted "frontend" text + const highlightedElements = document.querySelectorAll('.highlight-text'); + const highlightTexts = Array.from(highlightedElements).map( + (el) => el.textContent, + ); + expect(highlightTexts).toContain('Frontend'); + + // Frontend option should be visible in dropdown - use a simpler approach + const optionContents = document.querySelectorAll('.option-content'); + const hasFrontendOption = Array.from(optionContents).some((content) => + content.textContent?.includes('Frontend'), + ); + expect(hasFrontendOption).toBe(true); + }); + + // Press Enter to select the exact match + await user.keyboard('{Enter}'); + + // Should create selection with exact match + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith('frontend', { + label: 'frontend', + value: 'frontend', + type: 'custom', + }); + }); + }); + }); + + // ===== 2. SEARCH AND FILTERING ===== + describe('Search and Filtering (SF)', () => { + test('SF-01: Selected values pushed to top', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + const dropdown = document.querySelector('.custom-select-dropdown'); + expect(dropdown).toBeInTheDocument(); + + const options = dropdown?.querySelectorAll('.option-content') || []; + const optionTexts = Array.from(options).map((el) => el.textContent); + + // Database should be at the top + expect(optionTexts[0]).toContain('Database'); + }); + }); + + test('SF-02: Real-time search filtering', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Wait for dropdown to open + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + // Find and type in search input + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'front'); + } + + // Should filter to show only options containing 'front' + await waitFor(() => { + // Check for highlighted text within the Frontend option + const highlightedElements = document.querySelectorAll('.highlight-text'); + const highlightTexts = Array.from(highlightedElements).map( + (el) => el.textContent, + ); + expect(highlightTexts).toContain('Front'); + + // Should show Frontend option (highlighted) - use a simpler approach + const optionContents = document.querySelectorAll('.option-content'); + const hasFrontendOption = Array.from(optionContents).some((content) => + content.textContent?.includes('Frontend'), + ); + expect(hasFrontendOption).toBe(true); + + // Backend and Database should not be visible + expect(screen.queryByText('Backend')).not.toBeInTheDocument(); + expect(screen.queryByText('Database')).not.toBeInTheDocument(); + }); + }); + + test('SF-03: Search highlighting', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'end'); + } + + // Should highlight matching text in options + await waitFor(() => { + const highlightedElements = document.querySelectorAll('.highlight-text'); + const highlightTexts = Array.from(highlightedElements).map( + (el) => el.textContent, + ); + expect(highlightTexts).toContain('end'); + }); + }); + + test('SF-04: Search with partial matches', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'nonexistent'); + } + + // Should show custom value option when no matches found + await waitFor(() => { + // Original options should not be visible + expect(screen.queryByText('Frontend')).not.toBeInTheDocument(); + expect(screen.queryByText('Backend')).not.toBeInTheDocument(); + expect(screen.queryByText('Database')).not.toBeInTheDocument(); + expect(screen.queryByText('API Gateway')).not.toBeInTheDocument(); + + // Should show custom value option + const customOptions = screen.getAllByText('nonexistent'); + const customOption = customOptions.find((option) => { + const optionItem = option.closest('.option-item'); + const badge = optionItem?.querySelector('.option-badge'); + return badge?.textContent === 'Custom'; + }); + expect(customOption).toBeInTheDocument(); + }); + }); + }); + + // ===== 3. KEYBOARD NAVIGATION ===== + describe('Keyboard Navigation (KN)', () => { + test('KN-01: Arrow key navigation in dropdown', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + // Simulate arrow down key + await user.keyboard('{ArrowDown}'); + + // First option should be active + await waitFor(() => { + const activeOption = document.querySelector('.option-item.active'); + expect(activeOption).toBeInTheDocument(); + // Verify it's the first option (Frontend) + expect(activeOption?.textContent).toContain('Frontend'); + }); + + // Arrow up should go to previous option + await user.keyboard('{ArrowUp}'); + + await waitFor(() => { + const activeOption = document.querySelector('.option-item.active'); + expect(activeOption).toBeInTheDocument(); + // Verify it's now the last option (API Gateway) - wraps around + expect(activeOption?.textContent).toContain('API Gateway'); + }); + }); + + test('KN-02: Tab navigation to dropdown', async () => { + render( +
+ + + +
, + ); + + const prevInput = screen.getByTestId('prev-input'); + await user.click(prevInput); + + // Tab to select + await user.tab(); + + const combobox = screen.getByRole('combobox'); + expect(combobox).toHaveFocus(); + + // Open dropdown + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + }); + + test('KN-03: Enter selection in dropdown', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Wait for dropdown and navigate to first option + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + + // Should have selected an option + expect(mockOnChange).toHaveBeenCalledWith('frontend', { + label: 'Frontend', + value: 'frontend', + }); + }); + + test('KN-04: Space key selection', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + await user.keyboard('{ArrowDown}'); + await user.keyboard(' '); + + // Should have selected an option + expect(mockOnChange).toHaveBeenCalledWith('frontend', { + label: 'Frontend', + value: 'frontend', + }); + }); + + test('KN-05: Tab navigation within dropdown', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + // Tab should navigate within dropdown + await user.keyboard('{Tab}'); + + // Should still be within dropdown context + const dropdown = document.querySelector('.custom-select-dropdown'); + expect(dropdown).toBeInTheDocument(); + }); + }); + + // ===== 4. UI/UX BEHAVIORS ===== + describe('UI/UX Behaviors (UI)', () => { + test('UI-01: Loading state does not block interaction', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + + // Should still be clickable and interactive + await user.click(combobox); + expect(combobox).toHaveFocus(); + }); + + test('UI-02: Component remains editable in all states', () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + expect(combobox).toBeInTheDocument(); + expect(combobox).not.toBeDisabled(); + }); + + test('UI-03: Loading state display in footer', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + const loadingFooter = document.querySelector('.navigation-loading'); + expect(loadingFooter).toBeInTheDocument(); + }); + }); + + test('UI-04: Error state display in footer', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + const errorFooter = document.querySelector('.navigation-error'); + expect(errorFooter).toBeInTheDocument(); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + }); + + test('UI-05: No data state display', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('No options available')).toBeInTheDocument(); + }); + }); + }); + + // ===== 6. SAVE AND SELECTION TRIGGERS ===== + describe('Save and Selection Triggers (ST)', () => { + test('ST-01: Mouse selection works', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + const frontendOption = screen.getByText('Frontend'); + expect(frontendOption).toBeInTheDocument(); + }); + + const frontendOption = screen.getByText('Frontend'); + await user.click(frontendOption); + + expect(mockOnChange).toHaveBeenCalledWith( + 'frontend', + expect.objectContaining({ value: 'frontend' }), + ); + }); + }); + + // ===== 7. GROUPED OPTIONS SUPPORT ===== + describe('Grouped Options Support', () => { + test('handles grouped options correctly', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + // Check group headers + expect(screen.getByText('Development')).toBeInTheDocument(); + expect(screen.getByText('Operations')).toBeInTheDocument(); + + // Check group options + expect(screen.getByText('Frontend Dev')).toBeInTheDocument(); + expect(screen.getByText('Backend Dev')).toBeInTheDocument(); + expect(screen.getByText('DevOps')).toBeInTheDocument(); + expect(screen.getByText('SRE')).toBeInTheDocument(); + }); + }); + + test('grouped option selection works', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + const frontendDevOption = screen.getByText('Frontend Dev'); + expect(frontendDevOption).toBeInTheDocument(); + }); + + const frontendDevOption = screen.getByText('Frontend Dev'); + await user.click(frontendDevOption); + + expect(mockOnChange).toHaveBeenCalledWith( + 'frontend-dev', + expect.objectContaining({ value: 'frontend-dev' }), + ); + }); + }); + + // ===== 8. ACCESSIBILITY ===== + describe('Accessibility', () => { + test('has proper ARIA attributes', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + expect(combobox).toHaveAttribute('aria-expanded'); + + await user.click(combobox); + + await waitFor(() => { + const listbox = screen.getByRole('listbox'); + expect(listbox).toBeInTheDocument(); + }); + }); + + test('supports screen reader navigation', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + const options = screen.getAllByRole('option'); + expect(options.length).toBeGreaterThan(0); + + options.forEach((option) => { + expect(option).toHaveAttribute('aria-selected'); + }); + }); + }); + + test('has proper focus management', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + expect(combobox).toHaveFocus(); + + await waitFor(() => { + const dropdown = document.querySelector('.custom-select-dropdown'); + expect(dropdown).toBeInTheDocument(); + }); + + // Focus should remain manageable + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBeDefined(); + }); + }); + + // ===== 10. EDGE CASES ===== + describe('Edge Cases', () => { + test('handles special characters in options', async () => { + const specialOptions = [ + { label: 'Option with spaces', value: 'option-with-spaces' }, + { label: 'Option-with-dashes', value: 'option-with-dashes' }, + { label: 'Option_with_underscores', value: 'option_with_underscores' }, + { label: 'Option.with.dots', value: 'option.with.dots' }, + ]; + + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('Option with spaces')).toBeInTheDocument(); + expect(screen.getByText('Option-with-dashes')).toBeInTheDocument(); + expect(screen.getByText('Option_with_underscores')).toBeInTheDocument(); + expect(screen.getByText('Option.with.dots')).toBeInTheDocument(); + }); + }); + + test('handles extremely long option labels', async () => { + const longLabelOptions = [ + { + label: + 'This is an extremely long option label that should be handled gracefully by the component without breaking the layout or causing performance issues', + value: 'long-option', + }, + ]; + + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + const longOption = screen.getByText( + /This is an extremely long option label/, + ); + expect(longOption).toBeInTheDocument(); + }); + }); + }); + + // ===== 11. ADVANCED KEYBOARD NAVIGATION ===== + describe('Advanced Keyboard Navigation (AKN)', () => { + test('AKN-01: Mouse out closes dropdown', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Verify dropdown is open + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + // Simulate mouse out by clicking outside + await user.click(document.body); + + // Dropdown should close + await waitFor(() => { + const dropdown = document.querySelector('.ant-select-dropdown'); + expect(dropdown).toHaveClass('ant-select-dropdown-hidden'); + }); + }); + + test('AKN-02: TAB navigation from input to dropdown', async () => { + render( +
+ + + +
, + ); + + const prevInput = screen.getByTestId('prev-input'); + await user.click(prevInput); + + // Tab to select + await user.tab(); + + const combobox = screen.getByRole('combobox'); + expect(combobox).toHaveFocus(); + + // Open dropdown + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + // Tab from input section to dropdown + await user.tab(); + + // Should navigate to first option in dropdown + await waitFor(() => { + const activeOption = document.querySelector('.option-item.active'); + expect(activeOption).toBeInTheDocument(); + }); + }); + }); + + // ===== 12. ADVANCED FILTERING AND HIGHLIGHTING ===== + describe('Advanced Filtering and Highlighting (AFH)', () => { + test('AFH-01: Highlighted values pushed to top', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'front'); + } + + // Should show highlighted options with correct order + await waitFor(() => { + // Check for highlighted text + const highlightedElements = document.querySelectorAll('.highlight-text'); + const highlightTexts = Array.from(highlightedElements).map( + (el) => el.textContent, + ); + expect(highlightTexts).toContain('front'); + + // Get all option items to check the order + const optionItems = document.querySelectorAll('.option-item'); + const optionTexts = Array.from(optionItems) + .map((item) => { + const contentElement = item.querySelector('.option-content'); + return contentElement?.textContent?.trim(); + }) + .filter(Boolean); + + // Custom value "front" should appear first (above Frontend) + // The text includes "Custom" badge, so check for "front" in the text + expect(optionTexts[0]).toContain('front'); + + // Frontend should appear after the custom value + const frontendIndex = optionTexts.findIndex((text) => + text?.includes('Frontend'), + ); + expect(frontendIndex).toBeGreaterThan(0); // Should not be first + expect(optionTexts[frontendIndex]).toContain('Frontend'); + + // Should show Frontend option with highlighting (text is split due to highlighting) + const frontendOption = screen.getByText('end'); + expect(frontendOption).toBeInTheDocument(); + }); + }); + + test('AFH-02: Distinction between selection Enter and save Enter', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + // Navigate to an option using arrow keys + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); // Navigate to Backend + + // Press Enter to select (should close dropdown for single select) + await user.keyboard('{Enter}'); + + // Should have selected an option + expect(mockOnChange).toHaveBeenCalledWith('backend', { + label: 'Backend', + value: 'backend', + }); + + // Open dropdown again + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + // Now type something and press Enter to save + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + if (searchInput) { + await user.type(searchInput, 'custom-value'); + } + + // Press Enter to save (should close dropdown) + await user.keyboard('{Enter}'); + + // Should create custom value + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith('custom-value', { + label: 'custom-value', + value: 'custom-value', + type: 'custom', + }); + }); + }); + }); + + // ===== 13. ADVANCED CLEAR ACTIONS ===== + describe('Advanced Clear Actions (ACA)', () => { + test('ACA-01: Clear action waiting behavior', async () => { + const mockOnChangeWithDelay = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(resolve, 100); + }), + ); + + render( + , + ); + + const clearButton = document.querySelector('.ant-select-clear'); + expect(clearButton).toBeInTheDocument(); + + // Click clear button + await user.click(clearButton as Element); + + // Should call onChange immediately (no loading state in this component) + expect(mockOnChangeWithDelay).toHaveBeenCalledWith(undefined, undefined); + + // The component may call onChange multiple times, so just verify it was called + expect(mockOnChangeWithDelay).toHaveBeenCalled(); + }); + + test('ACA-02: Single select clear behavior like text input', async () => { + render( + , + ); + + const clearButton = document.querySelector('.ant-select-clear'); + expect(clearButton).toBeInTheDocument(); + + // Click clear button + await user.click(clearButton as Element); + + // Should clear the single selection + expect(mockOnChange).toHaveBeenCalledWith(undefined, undefined); + }); + }); + + // ===== 14. ADVANCED UI STATES ===== + describe('Advanced UI States (AUS)', () => { + test('AUS-01: No data with previous value selected', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Should show no data message + await waitFor(() => { + expect(screen.getByText('No options available')).toBeInTheDocument(); + }); + + // Should still show the previous selected value (use getAllByText to handle multiple instances) + expect(screen.getAllByText('previous-value')).toHaveLength(2); + }); + + test('AUS-02: Always editable accessibility', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + + // Should be editable even in loading state + expect(combobox).not.toBeDisabled(); + await user.click(combobox); + expect(combobox).toHaveFocus(); + + // Should still be interactive + expect(combobox).not.toBeDisabled(); + }); + + test('AUS-03: Sufficient space for search value', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + // Type a long search value + if (searchInput) { + const longSearchValue = 'a'.repeat(100); + await user.type(searchInput, longSearchValue); + } + + // Should not overflow or break layout + await waitFor(() => { + const searchContainer = document.querySelector( + '.ant-select-selection-search', + ); + const computedStyle = window.getComputedStyle(searchContainer as Element); + + // Should not have overflow issues + expect(computedStyle.overflow).not.toBe('hidden'); + }); + }); + + test('AUS-04: No spinners blocking user interaction', async () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + + // Should be clickable even with loading state + await user.click(combobox); + expect(combobox).toHaveFocus(); + + // Should be able to type even with loading state + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + if (searchInput) { + await user.type(searchInput, 'test'); + } + + // Should not be blocked by loading spinner + expect(combobox).not.toBeDisabled(); + }); + }); + + // ===== 15. REGEX AND CUSTOM VALUES ===== + describe('Regex and Custom Values (RCV)', () => { + test('RCV-01: Regex pattern support', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + // Test regex pattern + await user.type(searchInput, '.*test.*'); + } + + // Should create custom value for regex pattern + await waitFor(() => { + const regexOptions = screen.getAllByText('.*test.*'); + const regexOption = regexOptions.find((option) => { + const optionItem = option.closest('.option-item'); + const badge = optionItem?.querySelector('.option-badge'); + return badge?.textContent === 'Custom'; + }); + expect(regexOption).toBeInTheDocument(); + }); + + // Press Enter to create the regex value + await user.keyboard('{Enter}'); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith('.*test.*', { + label: '.*test.*', + value: '.*test.*', + type: 'custom', + }); + }); + }); + + test('RCV-02: Custom values treated as normal dropdown values', async () => { + const customOptions = [ + ...mockOptions, + { label: 'custom-value', value: 'custom-value', type: 'custom' as const }, + ]; + + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + // Custom value should appear in dropdown like normal options + expect(screen.getByText('custom-value')).toBeInTheDocument(); + + // Should be selectable like normal options + const customOption = screen.getByText('custom-value'); + await user.click(customOption); + + expect(mockOnChange).toHaveBeenCalledWith('custom-value', { + label: 'custom-value', + value: 'custom-value', + type: 'custom', + }); + }); + }); + + // ===== 16. DROPDOWN PERSISTENCE ===== + describe('Dropdown Persistence (DP)', () => { + test('DP-01: Dropdown closes only on save actions', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + // Navigate with arrow keys (non-save action) + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + + // Dropdown should still be open + await waitFor(() => { + expect(screen.getByText('Backend')).toBeInTheDocument(); + }); + + // Click on an option (selection action, should close for single select) + const backendOption = screen.getByText('Backend'); + await user.click(backendOption); + + // Dropdown should close after selection in single select + await waitFor(() => { + const dropdown = document.querySelector('.ant-select-dropdown'); + expect(dropdown).toHaveClass('ant-select-dropdown-hidden'); + }); + }); + }); +}); diff --git a/frontend/src/components/NewSelect/__test__/VariableItem.integration.test.tsx b/frontend/src/components/NewSelect/__test__/VariableItem.integration.test.tsx new file mode 100644 index 000000000000..b79ead443e51 --- /dev/null +++ b/frontend/src/components/NewSelect/__test__/VariableItem.integration.test.tsx @@ -0,0 +1,820 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { IDashboardVariable } from 'types/api/dashboard/getAll'; + +import VariableItem from '../../../container/NewDashboard/DashboardVariablesSelection/VariableItem'; + +// Mock the dashboard variables query +jest.mock('api/dashboard/variables/dashboardVariablesQuery', () => ({ + __esModule: true, + default: jest.fn(() => + Promise.resolve({ + payload: { + variableValues: ['option1', 'option2', 'option3', 'option4'], + }, + }), + ), +})); + +// Mock scrollIntoView which isn't available in JSDOM +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +// Constants +const TEST_VARIABLE_NAME = 'test_variable'; +const TEST_VARIABLE_ID = 'test-var-id'; + +// Create a mock store +const mockStore = configureStore([])({ + globalTime: { + minTime: Date.now() - 3600000, // 1 hour ago + maxTime: Date.now(), + }, +}); + +// Test data +const createMockVariable = ( + overrides: Partial = {}, +): IDashboardVariable => ({ + id: TEST_VARIABLE_ID, + name: TEST_VARIABLE_NAME, + description: 'Test variable description', + type: 'QUERY', + queryValue: 'SELECT DISTINCT value FROM table', + customValue: '', + sort: 'ASC', + multiSelect: false, + showALLOption: true, + selectedValue: [], + allSelected: false, + ...overrides, +}); + +function TestWrapper({ children }: { children: React.ReactNode }): JSX.Element { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + return ( + + {children} + + ); +} + +describe('VariableItem Integration Tests', () => { + let user: ReturnType; + let mockOnValueUpdate: jest.Mock; + let mockSetVariablesToGetUpdated: jest.Mock; + + beforeEach(() => { + user = userEvent.setup(); + mockOnValueUpdate = jest.fn(); + mockSetVariablesToGetUpdated = jest.fn(); + jest.clearAllMocks(); + }); + + // ===== 1. INTEGRATION WITH CUSTOMSELECT ===== + describe('CustomSelect Integration (VI)', () => { + test('VI-01: Single select variable integration', async () => { + const variable = createMockVariable({ + multiSelect: false, + type: 'CUSTOM', + customValue: 'option1,option2,option3', + }); + + render( + + + , + ); + + // Should render with CustomSelect + const combobox = screen.getByRole('combobox'); + expect(combobox).toBeInTheDocument(); + + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('option1')).toBeInTheDocument(); + expect(screen.getByText('option2')).toBeInTheDocument(); + expect(screen.getByText('option3')).toBeInTheDocument(); + }); + + // Select an option + const option1 = screen.getByText('option1'); + await user.click(option1); + + expect(mockOnValueUpdate).toHaveBeenCalledWith( + TEST_VARIABLE_NAME, + TEST_VARIABLE_ID, + 'option1', + false, + ); + }); + }); + + // ===== 2. INTEGRATION WITH CUSTOMMULTISELECT ===== + describe('CustomMultiSelect Integration (VI)', () => { + test('VI-02: Multi select variable integration', async () => { + const variable = createMockVariable({ + multiSelect: true, + type: 'CUSTOM', + customValue: 'option1,option2,option3,option4', + showALLOption: true, + }); + + render( + + + , + ); + + // Should render with CustomMultiSelect + const combobox = screen.getByRole('combobox'); + expect(combobox).toBeInTheDocument(); + + await user.click(combobox); + + await waitFor(() => { + // Should show ALL option + expect(screen.getByText('ALL')).toBeInTheDocument(); + expect(screen.getByText('option1')).toBeInTheDocument(); + expect(screen.getByText('option2')).toBeInTheDocument(); + }); + }); + }); + + // ===== 3. TEXTBOX VARIABLE TYPE ===== + describe('Textbox Variable Integration', () => { + test('VI-03: Textbox variable handling', async () => { + const variable = createMockVariable({ + type: 'TEXTBOX', + selectedValue: 'initial-value', + }); + + render( + + + , + ); + + // Should render a regular input + const textInput = screen.getByDisplayValue('initial-value'); + expect(textInput).toBeInTheDocument(); + expect(textInput.tagName).toBe('INPUT'); + + // Clear and type new value + await user.clear(textInput); + await user.type(textInput, 'new-text-value'); + + // Should call onValueUpdate after debounce + await waitFor( + () => { + expect(mockOnValueUpdate).toHaveBeenCalledWith( + TEST_VARIABLE_NAME, + TEST_VARIABLE_ID, + 'new-text-value', + false, + ); + }, + { timeout: 1000 }, + ); + }); + }); + + // ===== 4. VALUE PERSISTENCE AND STATE MANAGEMENT ===== + describe('Value Persistence and State Management', () => { + test('VI-04: All selected state handling', () => { + const variable = createMockVariable({ + multiSelect: true, + type: 'CUSTOM', + customValue: 'service1,service2,service3', + selectedValue: ['service1', 'service2', 'service3'], + allSelected: true, + showALLOption: true, + }); + + render( + + + , + ); + + // Should show "ALL" instead of individual values + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + test('VI-05: Dropdown behavior with temporary selections', async () => { + const variable = createMockVariable({ + multiSelect: true, + type: 'CUSTOM', + customValue: 'item1,item2,item3', + selectedValue: ['item1'], + }); + + render( + + + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Select additional items + await waitFor(() => { + const item2 = screen.getByText('item2'); + expect(item2).toBeInTheDocument(); + }); + + const item2 = screen.getByText('item2'); + await user.click(item2); + + // Should not immediately close dropdown for multiselect + await waitFor(() => { + expect(screen.getByText('item3')).toBeInTheDocument(); + }); + }); + }); + + // ===== 6. ACCESSIBILITY AND USER EXPERIENCE ===== + describe('Accessibility and User Experience', () => { + test('VI-06: Variable description tooltip', async () => { + const variable = createMockVariable({ + description: 'This variable controls the service selection', + type: 'CUSTOM', + customValue: 'service1,service2', + }); + + render( + + + , + ); + + // Should show info icon + const infoIcon = document.querySelector('.info-icon'); + expect(infoIcon).toBeInTheDocument(); + + // Hover to show tooltip + if (infoIcon) { + await user.hover(infoIcon); + } + + await waitFor(() => { + expect( + screen.getByText('This variable controls the service selection'), + ).toBeInTheDocument(); + }); + }); + + test('VI-07: Variable name display', () => { + const variable = createMockVariable({ + name: 'service_name', + type: 'CUSTOM', + customValue: 'service1,service2', + }); + + render( + + + , + ); + + // Should show variable name with $ prefix + expect(screen.getByText('$service_name')).toBeInTheDocument(); + }); + + test('VI-08: Max tag count behavior', async () => { + const variable = createMockVariable({ + multiSelect: true, + type: 'CUSTOM', + customValue: 'tag1,tag2,tag3,tag4,tag5,tag6,tag7', + selectedValue: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'], + }); + + render( + + + , + ); + + // Should show limited number of tags with "+ X more" + const tags = document.querySelectorAll('.ant-select-selection-item'); + + // Should show some tags and potentially a "+X more" indicator + expect(tags.length).toBeGreaterThan(0); + + // Check for overflow indicator (maxTagCount is set to 4 in the component) + // With 7 tags and maxTagCount of 4, should show "+ 3 more" + if (tags.length > 4) { + const overflowIndicator = document.querySelector('[title*=","]'); + expect(overflowIndicator).toBeInTheDocument(); + + // Verify the "+ N more" text is displayed + const overflowText = overflowIndicator?.textContent; + expect(overflowText).toMatch(/\+ \d+ more/); + + // Should show exactly "+ 3 more" for 7 tags with maxTagCount of 4 + expect(overflowText).toBe('+ 3 more'); + } + }); + }); + + // ===== 8. SEARCH INTERACTION TESTS ===== + describe('Search Interaction Tests', () => { + test('VI-12: Search filtering in multiselect', async () => { + const variable = createMockVariable({ + type: 'CUSTOM', + customValue: 'frontend,backend,database,api', + multiSelect: true, + }); + + render( + + + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Wait for dropdown to open + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + // Find and type in search input + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'front'); + } + + // Should filter to show only options containing 'front' + await waitFor(() => { + // Check for highlighted text within the Frontend option + const highlightedElements = document.querySelectorAll('.highlight-text'); + const highlightTexts = Array.from(highlightedElements).map( + (el) => el.textContent, + ); + expect(highlightTexts).toContain('front'); + + // Should show Frontend option (highlighted) - use a simpler approach + const optionContents = document.querySelectorAll('.option-content'); + const hasFrontendOption = Array.from(optionContents).some((content) => + content.textContent?.includes('frontend'), + ); + expect(hasFrontendOption).toBe(true); + + // Backend and Database should not be visible + expect(screen.queryByText('backend')).not.toBeInTheDocument(); + expect(screen.queryByText('database')).not.toBeInTheDocument(); + }); + }); + + test('VI-13: Custom value creation workflow', async () => { + const variable = createMockVariable({ + type: 'CUSTOM', + customValue: 'option1,option2,option3', + multiSelect: true, + }); + + render( + + + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'custom-value'); + } + + // Check that custom value appears in dropdown with custom tag + await waitFor(() => { + // Find the custom option with "custom-value" text and "Custom" badge + const customOptions = screen.getAllByText('custom-value'); + const customOption = customOptions.find((option) => { + const optionItem = option.closest('.option-item'); + const badge = optionItem?.querySelector('.option-badge'); + return badge?.textContent === 'Custom'; + }); + + expect(customOption).toBeInTheDocument(); + + // Verify it has the custom badge + const optionItem = customOption?.closest('.option-item'); + const badge = optionItem?.querySelector('.option-badge'); + expect(badge).toBeInTheDocument(); + expect(badge?.textContent).toBe('Custom'); + }); + + // Press Enter to create the custom value + await user.keyboard('{Enter}'); + + // Should create a custom value and call onValueUpdate + await waitFor(() => { + // The custom value was created (we can see it in the DOM) + // but the callback might not be called immediately + // Let's check if the custom value is in the selection + const selectionItems = document.querySelectorAll( + '.ant-select-selection-item', + ); + const hasCustomValue = Array.from(selectionItems).some((item) => + item.textContent?.includes('custom-value'), + ); + expect(hasCustomValue).toBe(true); + }); + }); + + test('VI-14: Search persistence across dropdown open/close', async () => { + const variable = createMockVariable({ + type: 'CUSTOM', + customValue: 'option1,option2,option3', + multiSelect: true, + }); + + render( + + + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + await user.type(searchInput, 'search-text'); + } + + // Verify search text is in input + await waitFor(() => { + expect(searchInput).toHaveValue('search-text'); + }); + + // Press Escape to close dropdown + await user.keyboard('{Escape}'); + + // Dropdown should close and search text should be cleared + await waitFor(() => { + const dropdown = document.querySelector('.ant-select-dropdown'); + expect(dropdown).toHaveClass('ant-select-dropdown-hidden'); + expect(searchInput).toHaveValue(''); + }); + }); + }); + + // ===== 9. ADVANCED KEYBOARD NAVIGATION ===== + describe('Advanced Keyboard Navigation (VI)', () => { + test('VI-15: Shift + Arrow + Del chip deletion in multiselect', async () => { + const variable = createMockVariable({ + type: 'CUSTOM', + customValue: 'option1,option2,option3', + multiSelect: true, + selectedValue: ['option1', 'option2', 'option3'], + }); + + render( + + + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Navigate to chips using arrow keys + await user.keyboard('{ArrowLeft}'); + + // Use Shift + Arrow to navigate between chips + await user.keyboard('{Shift>}{ArrowLeft}{/Shift}'); + + // Use Del to delete the active chip + await user.keyboard('{Delete}'); + + // Note: The component may not immediately call onValueUpdate + // This test verifies the chip deletion behavior + await waitFor(() => { + // Check if a chip was removed from the selection + const selectionItems = document.querySelectorAll( + '.ant-select-selection-item', + ); + expect(selectionItems.length).toBeLessThan(3); + }); + }); + }); + + // ===== 11. ADVANCED UI STATES ===== + describe('Advanced UI States (VI)', () => { + test('VI-19: No data with previous value selected in variable', async () => { + const variable = createMockVariable({ + type: 'CUSTOM', + customValue: '', + multiSelect: true, + selectedValue: ['previous-value'], + }); + + render( + + + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Should show no data message (the component may not show this exact text) + await waitFor(() => { + // Check if dropdown is empty or shows no data indication + const dropdown = document.querySelector('.ant-select-dropdown'); + expect(dropdown).toBeInTheDocument(); + }); + + // Should still show the previous selected value + expect(screen.getByText('previous-value')).toBeInTheDocument(); + }); + + test('VI-20: Always editable accessibility in variable', async () => { + const variable = createMockVariable({ + type: 'CUSTOM', + customValue: 'option1,option2', + multiSelect: true, + }); + + render( + + + , + ); + + const combobox = screen.getByRole('combobox'); + + // Should be editable + expect(combobox).not.toBeDisabled(); + await user.click(combobox); + expect(combobox).toHaveFocus(); + + // Should still be interactive + expect(combobox).not.toBeDisabled(); + }); + }); + + // ===== 12. REGEX AND CUSTOM VALUES ===== + describe('Regex and Custom Values (VI)', () => { + test('VI-22: Regex pattern support in variable', async () => { + const variable = createMockVariable({ + type: 'CUSTOM', + customValue: 'option1,option2,option3', + multiSelect: true, + }); + + render( + + + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + // Wait for dropdown to open (don't expect ALL option as it might not be there) + await waitFor(() => { + expect(screen.getByText('option1')).toBeInTheDocument(); + }); + + const searchInput = document.querySelector( + '.ant-select-selection-search-input', + ); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + // Test regex pattern + await user.type(searchInput, '.*test.*'); + } + + // Should create custom value for regex pattern + await waitFor(() => { + const regexOptions = screen.getAllByText('.*test.*'); + expect(regexOptions.length).toBeGreaterThan(0); + + // Check that at least one has the Custom badge + const customBadgeElements = document.querySelectorAll('.option-badge'); + const hasCustomBadge = Array.from(customBadgeElements).some( + (badge) => badge.textContent === 'Custom', + ); + expect(hasCustomBadge).toBe(true); + }); + + // Press Enter to create the regex value + await user.keyboard('{Enter}'); + + // Should create the regex value (verify it's in the selection) + await waitFor(() => { + const selectionItems = document.querySelectorAll( + '.ant-select-selection-item', + ); + const hasRegexValue = Array.from(selectionItems).some((item) => + item.textContent?.includes('.*test.*'), + ); + expect(hasRegexValue).toBe(true); + }); + }); + }); + + // ===== 13. DROPDOWN PERSISTENCE ===== + describe('Dropdown Persistence (VI)', () => { + test('VI-24: Dropdown stays open for non-save actions in variable', async () => { + const variable = createMockVariable({ + type: 'CUSTOM', + customValue: 'option1,option2,option3', + multiSelect: true, + }); + + render( + + + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + // Navigate with arrow keys (non-save action) + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + + // Dropdown should still be open + await waitFor(() => { + expect(screen.getByText('option1')).toBeInTheDocument(); + }); + + // Click on an option (selection action, not save) + const option1 = screen.getByText('option1'); + await user.click(option1); + + // Dropdown should still be open for more selections + await waitFor(() => { + expect(screen.getByText('option2')).toBeInTheDocument(); + }); + + await waitFor(() => { + const dropdown = document.querySelector('.ant-select-dropdown'); + expect(dropdown).not.toHaveClass('ant-select-dropdown-hidden'); + }); + + // Only ESC should close the dropdown + await user.keyboard('{Escape}'); + + await waitFor(() => { + const dropdown = document.querySelector('.ant-select-dropdown'); + expect(dropdown).toHaveClass('ant-select-dropdown-hidden'); + }); + }); + }); +}); diff --git a/frontend/src/components/NewSelect/styles.scss b/frontend/src/components/NewSelect/styles.scss index 7eb2d9541484..709ea71ffa2c 100644 --- a/frontend/src/components/NewSelect/styles.scss +++ b/frontend/src/components/NewSelect/styles.scss @@ -656,6 +656,10 @@ $custom-border-color: #2c3044; border: 1px solid #e8e8e8; color: rgba(0, 0, 0, 0.85); + font-size: 12px !important; + height: 20px; + line-height: 18px; + .ant-select-selection-item-content { color: rgba(0, 0, 0, 0.85); } diff --git a/frontend/src/components/NewSelect/types.ts b/frontend/src/components/NewSelect/types.ts index 49369c89af78..f314270f8ef9 100644 --- a/frontend/src/components/NewSelect/types.ts +++ b/frontend/src/components/NewSelect/types.ts @@ -24,7 +24,7 @@ export interface CustomSelectProps extends Omit { highlightSearch?: boolean; placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; popupMatchSelectWidth?: boolean; - errorMessage?: string; + errorMessage?: string | null; allowClear?: SelectProps['allowClear']; onRetry?: () => void; } @@ -51,7 +51,7 @@ export interface CustomMultiSelectProps getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement; dropdownRender?: (menu: React.ReactElement) => React.ReactElement; highlightSearch?: boolean; - errorMessage?: string; + errorMessage?: string | null; popupClassName?: string; placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; maxTagCount?: number; diff --git a/frontend/src/container/NewDashboard/DashboardVariablesSelection/VariableItem.test.tsx b/frontend/src/container/NewDashboard/DashboardVariablesSelection/VariableItem.test.tsx index fac9e3de59e5..a8f60268c678 100644 --- a/frontend/src/container/NewDashboard/DashboardVariablesSelection/VariableItem.test.tsx +++ b/frontend/src/container/NewDashboard/DashboardVariablesSelection/VariableItem.test.tsx @@ -167,7 +167,7 @@ describe('VariableItem', () => { , ); - expect(screen.getByTitle('ALL')).toBeInTheDocument(); + expect(screen.getByText('ALL')).toBeInTheDocument(); }); test('calls useEffect when the component mounts', () => { diff --git a/frontend/src/container/NewDashboard/DashboardVariablesSelection/VariableItem.tsx b/frontend/src/container/NewDashboard/DashboardVariablesSelection/VariableItem.tsx index 461e691940dd..839eaf26e1da 100644 --- a/frontend/src/container/NewDashboard/DashboardVariablesSelection/VariableItem.tsx +++ b/frontend/src/container/NewDashboard/DashboardVariablesSelection/VariableItem.tsx @@ -8,23 +8,14 @@ import './DashboardVariableSelection.styles.scss'; import { orange } from '@ant-design/colors'; import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons'; -import { - Checkbox, - Input, - Popover, - Select, - Tag, - Tooltip, - Typography, -} from 'antd'; -import { CheckboxChangeEvent } from 'antd/es/checkbox'; +import { Input, Popover, Tooltip, Typography } from 'antd'; import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery'; +import { CustomMultiSelect, CustomSelect } from 'components/NewSelect'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser'; import sortValues from 'lib/dashbaordVariables/sortVariableValues'; import { debounce, isArray, isString } from 'lodash-es'; -import map from 'lodash-es/map'; -import { ChangeEvent, memo, useEffect, useMemo, useState } from 'react'; +import { memo, useEffect, useMemo, useState } from 'react'; import { useQuery } from 'react-query'; import { useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; @@ -39,11 +30,6 @@ import { areArraysEqual, checkAPIInvocation, IDependencyData } from './util'; const ALL_SELECT_VALUE = '__ALL__'; -enum ToggleTagValue { - Only = 'Only', - All = 'All', -} - interface VariableItemProps { variableData: IDashboardVariable; existingVariables: Record; @@ -83,6 +69,9 @@ function VariableItem({ const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>( [], ); + const [tempSelection, setTempSelection] = useState< + string | string[] | undefined + >(undefined); const { maxTime, minTime } = useSelector( (state) => state.globalTime, @@ -146,18 +135,10 @@ function VariableItem({ variableData.name && (validVariableUpdate() || valueNotInList || variableData.allSelected) ) { - let value = variableData.selectedValue; + const value = variableData.selectedValue; let allSelected = false; - // The default value for multi-select is ALL and first value for - // single select - if (valueNotInList) { - if (variableData.multiSelect) { - value = newOptionsData; - allSelected = true; - } else { - [value] = newOptionsData; - } - } else if (variableData.multiSelect) { + + if (variableData.multiSelect) { const { selectedValue } = variableData; allSelected = newOptionsData.length > 0 && @@ -265,6 +246,27 @@ function VariableItem({ } }; + // Add a handler for tracking temporary selection changes + const handleTempChange = (inputValue: string | string[]): void => { + // Store the selection in temporary state while dropdown is open + const value = variableData.multiSelect && !inputValue ? [] : inputValue; + setTempSelection(value); + }; + + // Handle dropdown visibility changes + const handleDropdownVisibleChange = (visible: boolean): void => { + // Initialize temp selection when opening dropdown + if (visible) { + setTempSelection(getSelectValue(variableData.selectedValue, variableData)); + } + // Apply changes when closing dropdown + else if (!visible && tempSelection !== undefined) { + // Call handleChange with the temporarily stored selection + handleChange(tempSelection); + setTempSelection(undefined); + } + }; + // do not debounce the above function as we do not need debounce in select variables const debouncedHandleChange = debounce(handleChange, 500); @@ -281,11 +283,6 @@ function VariableItem({ ? 'ALL' : selectedValueStringified; - const mode: 'multiple' | undefined = - variableData.multiSelect && !variableData.allSelected - ? 'multiple' - : undefined; - useEffect(() => { // Fetch options for CUSTOM Type if (variableData.type === 'CUSTOM') { @@ -294,113 +291,6 @@ function VariableItem({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [variableData.type, variableData.customValue]); - const checkAll = (e: MouseEvent): void => { - e.stopPropagation(); - e.preventDefault(); - const isChecked = - variableData.allSelected || selectValue?.includes(ALL_SELECT_VALUE); - - if (isChecked) { - handleChange([]); - } else { - handleChange(ALL_SELECT_VALUE); - } - }; - - const handleOptionSelect = ( - e: CheckboxChangeEvent, - option: string | number | boolean, - ): void => { - const newSelectedValue = Array.isArray(selectedValue) - ? ((selectedValue.filter( - (val) => val.toString() !== option.toString(), - ) as unknown) as string[]) - : []; - - if ( - !e.target.checked && - Array.isArray(selectedValueStringified) && - selectedValueStringified.includes(option.toString()) - ) { - if (newSelectedValue.length === 1) { - handleChange(newSelectedValue[0].toString()); - return; - } - handleChange(newSelectedValue); - } else if (!e.target.checked && selectedValue === option.toString()) { - handleChange(ALL_SELECT_VALUE); - } else if (newSelectedValue.length === optionsData.length - 1) { - handleChange(ALL_SELECT_VALUE); - } - }; - - const [optionState, setOptionState] = useState({ - tag: '', - visible: false, - }); - - function currentToggleTagValue({ - option, - }: { - option: string; - }): ToggleTagValue { - if ( - option.toString() === selectValue || - (Array.isArray(selectValue) && - selectValue?.includes(option.toString()) && - selectValue.length === 1) - ) { - return ToggleTagValue.All; - } - return ToggleTagValue.Only; - } - - function handleToggle(e: ChangeEvent, option: string): void { - e.stopPropagation(); - const mode = currentToggleTagValue({ option: option as string }); - const isChecked = - variableData.allSelected || - option.toString() === selectValue || - (Array.isArray(selectValue) && selectValue?.includes(option.toString())); - - if (isChecked) { - if (mode === ToggleTagValue.Only && variableData.multiSelect) { - handleChange([option.toString()]); - } else if (!variableData.multiSelect) { - handleChange(option.toString()); - } else { - handleChange(ALL_SELECT_VALUE); - } - } else { - handleChange(option.toString()); - } - } - - function retProps( - option: string, - ): { - onMouseOver: () => void; - onMouseOut: () => void; - } { - return { - onMouseOver: (): void => - setOptionState({ - tag: option.toString(), - visible: true, - }), - onMouseOut: (): void => - setOptionState({ - tag: option.toString(), - visible: false, - }), - }; - } - - const ensureValidOption = (option: string): boolean => - !( - currentToggleTagValue({ option }) === ToggleTagValue.All && !enableSelectAll - ); - return (
@@ -428,9 +318,48 @@ function VariableItem({ }} /> ) : ( - !errorMessage && - optionsData && ( - - ) + options={optionsData.map((option) => ({ + label: option.toString(), + value: option.toString(), + }))} + value={selectValue} + errorMessage={errorMessage} + /> + )) )} {variableData.type !== 'TEXTBOX' && errorMessage && ( diff --git a/frontend/src/container/NewDashboard/DashboardVariablesSelection/__test__/VariableItem.defaulting.behavior.test.tsx b/frontend/src/container/NewDashboard/DashboardVariablesSelection/__test__/VariableItem.defaulting.behavior.test.tsx new file mode 100644 index 000000000000..000077aff279 --- /dev/null +++ b/frontend/src/container/NewDashboard/DashboardVariablesSelection/__test__/VariableItem.defaulting.behavior.test.tsx @@ -0,0 +1,140 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import '@testing-library/jest-dom/extend-expect'; + +import MockQueryClientProvider from 'providers/test/MockQueryClientProvider'; +import { render, screen, waitFor } from 'tests/test-utils'; +import { IDashboardVariable } from 'types/api/dashboard/getAll'; + +import VariableItem from '../VariableItem'; + +const mockOnValueUpdate = jest.fn(); +const mockSetVariablesToGetUpdated = jest.fn(); + +const baseDependencyData = { + order: [], + graph: {}, + parentDependencyGraph: {}, + hasCycle: false, +}; + +const TEST_VARIABLE_ID = 'test_variable'; +const VARIABLE_SELECT_TESTID = 'variable-select'; +const TEST_VARIABLE_NAME = 'testVariable'; +const TEST_VARIABLE_DESCRIPTION = 'Test Variable'; + +const renderVariableItem = ( + variableData: IDashboardVariable, +): ReturnType => + render( + + + , + ); + +describe('VariableItem Default Value Selection Behavior', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Single Select Variables', () => { + test('should keep previous selection value', async () => { + const variable: IDashboardVariable = { + id: TEST_VARIABLE_ID, + name: TEST_VARIABLE_NAME, + description: TEST_VARIABLE_DESCRIPTION, + type: 'CUSTOM', + customValue: 'option1,option2,option3', + selectedValue: 'option1', + sort: 'DISABLED', + multiSelect: false, + showALLOption: false, + }; + + renderVariableItem(variable); + + await waitFor(() => { + expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument(); + }); + + expect(screen.getByText('option1')).toBeInTheDocument(); + }); + + test('should show placeholder when no previous and no default', async () => { + const variable: IDashboardVariable = { + id: TEST_VARIABLE_ID, + name: TEST_VARIABLE_NAME, + description: TEST_VARIABLE_DESCRIPTION, + type: 'CUSTOM', + customValue: 'option1,option2,option3', + selectedValue: undefined, + sort: 'DISABLED', + multiSelect: false, + showALLOption: false, + }; + + renderVariableItem(variable); + + await waitFor(() => { + expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument(); + }); + + expect(screen.getByText('Select value')).toBeInTheDocument(); + }); + }); + + describe('Multi Select Variables with ALL enabled', () => { + test('should show ALL when all options are selected', async () => { + const variable: IDashboardVariable = { + id: TEST_VARIABLE_ID, + name: TEST_VARIABLE_NAME, + description: TEST_VARIABLE_DESCRIPTION, + type: 'CUSTOM', + customValue: 'option1,option2,option3', + selectedValue: ['option1', 'option2', 'option3'], + allSelected: true, + sort: 'DISABLED', + multiSelect: true, + showALLOption: true, + }; + + renderVariableItem(variable); + + await waitFor(() => { + expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument(); + }); + + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + }); + + describe('Multi Select Variables with ALL disabled', () => { + test('should show placeholder when no selection', async () => { + const variable: IDashboardVariable = { + id: TEST_VARIABLE_ID, + name: TEST_VARIABLE_NAME, + description: TEST_VARIABLE_DESCRIPTION, + type: 'CUSTOM', + customValue: 'option1,option2,option3', + selectedValue: undefined, + sort: 'DISABLED', + multiSelect: true, + showALLOption: false, + }; + + renderVariableItem(variable); + + await waitFor(() => { + expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument(); + }); + + expect(screen.getByText('Select value')).toBeInTheDocument(); + }); + }); +});