From 2f8da5957bce131b7e971c7be6d3efab8ef1baa5 Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Sun, 27 Apr 2025 16:55:53 +0530 Subject: [PATCH] feat: added custom single and multiselect components (#7497) * feat: added new Select component for multi and single select * feat: refactored code and added keyboard navigations in single select * feat: different state handling in single select * feat: updated the playground page * feat: multi-select updates * feat: fixed multiselect selection issues * feat: multiselect cleanup * feat: multiselect key navigation cleanup * feat: added tokenization in multiselect * feat: add on enter and handle duplicates * feat: design update to the components * feat: design update to the components * feat: design update to the components * feat: updated the playground page * feat: edited playground data * feat: edited styles * feat: code cleanup * feat: added shift + keys navigation and selection * feat: improved styles and added darkmode styles * feat: removed scroll bar hover style * feat: added scroll bar on hover * feat: added regex wrapper support * feat: fixed right arrow navigation across chips * feat: addressed all the single select feedbacks * feat: addressed all the single select feedbacks * feat: added only-all-toggle feat with ALL selection tag * feat: remove clear, update footer info content and style and misc fixes * feat: misc style fixes * feat: added quotes exception to the multiselect tagging * feat: removing demo page, and cleanup PR for reviews * feat: resolved comments and refactoring * feat: added test cases --- .../NewSelect/CustomMultiSelect.scss | 13 + .../NewSelect/CustomMultiSelect.tsx | 1765 +++++++++++++++++ .../src/components/NewSelect/CustomSelect.tsx | 606 ++++++ .../__test__/CustomMultiSelect.test.tsx | 263 +++ .../NewSelect/__test__/CustomSelect.test.tsx | 206 ++ frontend/src/components/NewSelect/index.ts | 8 + frontend/src/components/NewSelect/styles.scss | 838 ++++++++ frontend/src/components/NewSelect/types.ts | 60 + frontend/src/components/NewSelect/utils.ts | 135 ++ 9 files changed, 3894 insertions(+) create mode 100644 frontend/src/components/NewSelect/CustomMultiSelect.scss create mode 100644 frontend/src/components/NewSelect/CustomMultiSelect.tsx create mode 100644 frontend/src/components/NewSelect/CustomSelect.tsx create mode 100644 frontend/src/components/NewSelect/__test__/CustomMultiSelect.test.tsx create mode 100644 frontend/src/components/NewSelect/__test__/CustomSelect.test.tsx create mode 100644 frontend/src/components/NewSelect/index.ts create mode 100644 frontend/src/components/NewSelect/styles.scss create mode 100644 frontend/src/components/NewSelect/types.ts create mode 100644 frontend/src/components/NewSelect/utils.ts diff --git a/frontend/src/components/NewSelect/CustomMultiSelect.scss b/frontend/src/components/NewSelect/CustomMultiSelect.scss new file mode 100644 index 0000000000..e74414d5b9 --- /dev/null +++ b/frontend/src/components/NewSelect/CustomMultiSelect.scss @@ -0,0 +1,13 @@ +.custom-multiselect-dropdown { + .divider { + height: 1px; + background-color: #e8e8e8; + margin: 4px 0; + } + + .all-option { + font-weight: 500; + border-bottom: 1px solid #f0f0f0; + margin-bottom: 8px; + } +} diff --git a/frontend/src/components/NewSelect/CustomMultiSelect.tsx b/frontend/src/components/NewSelect/CustomMultiSelect.tsx new file mode 100644 index 0000000000..23333e0322 --- /dev/null +++ b/frontend/src/components/NewSelect/CustomMultiSelect.tsx @@ -0,0 +1,1765 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +/* eslint-disable react/jsx-props-no-spreading */ +/* eslint-disable no-nested-ternary */ +/* eslint-disable react/function-component-definition */ +import './styles.scss'; + +import { + DownOutlined, + LoadingOutlined, + ReloadOutlined, +} from '@ant-design/icons'; +import { Color } from '@signozhq/design-tokens'; +import { Button, Checkbox, Select, Typography } from 'antd'; +import cx from 'classnames'; +import { SOMETHING_WENT_WRONG } from 'constants/api'; +import { capitalize, isEmpty } from 'lodash-es'; +import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp } from 'lucide-react'; +import type { BaseSelectRef } from 'rc-select'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { popupContainer } from 'utils/selectPopupContainer'; + +import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types'; +import { + filterOptionsBySearch, + prioritizeOrAddOptionForMultiSelect, + SPACEKEY, +} from './utils'; + +enum ToggleTagValue { + Only = 'Only', + All = 'All', +} + +const ALL_SELECTED_VALUE = '__all__'; // Constant for the special value + +const CustomMultiSelect: React.FC = ({ + placeholder = 'Search...', + className, + loading = false, + onSearch, + options = [], + value = [], + onChange, + defaultActiveFirstOption = true, + dropdownMatchSelectWidth = true, + noDataMessage, + errorMessage, + onClear, + enableAllSelection = true, + getPopupContainer, + dropdownRender, + highlightSearch = true, + popupClassName, + placement = 'bottomLeft', + maxTagCount, + allowClear = false, + onRetry, + maxTagTextLength, + ...rest +}) => { + // ===== State & Refs ===== + const [isOpen, setIsOpen] = useState(false); + const [searchText, setSearchText] = useState(''); + const selectRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(-1); + const [activeChipIndex, setActiveChipIndex] = useState(-1); // For tracking active chip/tag + const [selectionStart, setSelectionStart] = useState(-1); + const [selectionEnd, setSelectionEnd] = useState(-1); + const [selectedChips, setSelectedChips] = useState([]); + const [isSelectionMode, setIsSelectionMode] = useState(false); + const dropdownRef = useRef(null); + const optionRefs = useRef>({}); + const [visibleOptions, setVisibleOptions] = useState([]); + const isClickInsideDropdownRef = useRef(false); + + // Convert single string value to array for consistency + const selectedValues = useMemo( + (): string[] => + Array.isArray(value) ? value : value ? [value as string] : [], + [value], + ); + + // Helper function to get all *available* values from options (excluding disabled) + const getAllAvailableValues = useCallback( + (optionsList: OptionData[]): string[] => { + const values: string[] = []; + + optionsList.forEach((option) => { + if ('options' in option && Array.isArray(option.options)) { + option.options?.forEach((subOption) => { + if (subOption.value) { + values.push(subOption.value); + } + }); + } else if (option.value) { + values.push(option.value); + } + }); + + return values; + }, + [], + ); + + const allAvailableValues = useMemo(() => { + const combinedOptions = prioritizeOrAddOptionForMultiSelect( + options, + selectedValues, + ); + return getAllAvailableValues(combinedOptions); + }, [options, selectedValues, getAllAvailableValues]); + + const isAllSelected = useMemo(() => { + if (!enableAllSelection || allAvailableValues.length === 0) { + return false; + } + // Check if every available value is included in the selected values + return allAvailableValues.every((val) => selectedValues.includes(val)); + }, [selectedValues, allAvailableValues, enableAllSelection]); + + // Value passed to the underlying Ant Select component + const displayValue = useMemo( + () => (isAllSelected ? [ALL_SELECTED_VALUE] : selectedValues), + [isAllSelected, selectedValues], + ); + + // ===== Internal onChange Handler ===== + const handleInternalChange = useCallback( + (newValue: string | string[]): void => { + // Ensure newValue is an array + const currentNewValue = Array.isArray(newValue) ? newValue : []; + + if (!onChange) return; + + // Case 1: Cleared (empty array or undefined) + if (!newValue || currentNewValue.length === 0) { + onChange([], []); + return; + } + + // 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 }, + ); + onChange(allAvailableValues as any, allActualOptions as any); + } else { + // Case 3: Regular values selected + // Check if the selection now constitutes "all selected" + const nowAllSelected = + enableAllSelection && + allAvailableValues.length > 0 && + allAvailableValues.every((val) => currentNewValue.includes(val)); + + if (nowAllSelected) { + const allActualOptions = allAvailableValues.map( + (v) => + options.flat().find((o) => o.value === v) || { label: v, value: v }, + ); + onChange(allAvailableValues as any, allActualOptions as any); + } else { + // Pass through the regular selection + // Map selected values back to OptionData format if possible + const correspondingOptions = currentNewValue.map( + (v) => + options.flat().find((o) => o.value === v) || { label: v, value: v }, + ); + onChange(currentNewValue as any, correspondingOptions as any); + } + } + }, + [onChange, allAvailableValues, options, enableAllSelection], + ); + + // ===== Existing Callbacks (potentially needing adjustment later) ===== + + const currentToggleTagValue = useCallback( + ({ option }: { option: string }): ToggleTagValue => { + if ( + Array.isArray(selectedValues) && + selectedValues?.includes(option.toString()) && + selectedValues.length === 1 + ) { + return ToggleTagValue.All; + } + return ToggleTagValue.Only; + }, + [selectedValues], + ); + + const ensureValidOption = useCallback( + (option: string): boolean => + !( + currentToggleTagValue({ option }) === ToggleTagValue.All && + !enableAllSelection + ), + [currentToggleTagValue, enableAllSelection], + ); + + /** + * Checks if a label exists in the provided options + */ + const isLabelPresent = useCallback( + (options: OptionData[], label: string): boolean => + options.some((option) => { + const lowerLabel = label.toLowerCase(); + + // Check in nested options if they exist + if ('options' in option && Array.isArray(option.options)) { + return ( + option.options?.some( + (subOption) => subOption.label.toLowerCase() === lowerLabel, + ) || false + ); + } + + // Check top-level option + return option.label.toLowerCase() === lowerLabel; + }), + [], + ); + + /** + * Separates section and non-section options + */ + const splitOptions = useCallback((options: OptionData[]): { + sectionOptions: OptionData[]; + nonSectionOptions: OptionData[]; + } => { + const sectionOptions: OptionData[] = []; + const nonSectionOptions: OptionData[] = []; + + options.forEach((option) => { + if ('options' in option && Array.isArray(option.options)) { + sectionOptions.push(option); + } else { + nonSectionOptions.push(option); + } + }); + + return { sectionOptions, nonSectionOptions }; + }, []); + + /** + * Apply search filtering to options + */ + const filteredOptions = useMemo( + (): OptionData[] => filterOptionsBySearch(options, searchText), + [options, searchText], + ); + + useEffect(() => { + if (!isEmpty(searchText)) { + setVisibleOptions([ + { + label: searchText, + value: searchText, + type: 'custom', + }, + ...filteredOptions, + ]); + } else { + setVisibleOptions( + selectedValues.length > 0 && isEmpty(searchText) + ? prioritizeOrAddOptionForMultiSelect(filteredOptions, selectedValues) + : filteredOptions, + ); + } + }, [filteredOptions, searchText, options, selectedValues]); + + // ===== Text Selection Utilities ===== + + /** + * Clears all chip selections + */ + const clearSelection = useCallback((): void => { + setSelectionStart(-1); + setSelectionEnd(-1); + setSelectedChips([]); + setIsSelectionMode(false); + }, []); + + /** + * Selects all chips + */ + const selectAllChips = useCallback((): void => { + if (selectedValues.length === 0) return; + + // When maxTagCount is set, only select visible chips + const visibleCount = + maxTagCount !== undefined && maxTagCount > 0 + ? Math.min(maxTagCount, selectedValues.length) + : selectedValues.length; + + const allIndices = Array.from({ length: visibleCount }, (_, i) => i); + + setSelectionStart(0); + setSelectionEnd(visibleCount - 1); + setSelectedChips(allIndices); + setIsSelectionMode(true); + }, [selectedValues, maxTagCount]); + + /** + * Gets indices between start and end (inclusive) + */ + const getIndicesBetween = useCallback( + (start: number, end: number): number[] => { + const indices: number[] = []; + const min = Math.min(start, end); + const max = Math.max(start, end); + + for (let i = min; i <= max; i++) { + indices.push(i); + } + + return indices; + }, + [], + ); + + /** + * Start selection from an index + */ + const startSelection = useCallback((index: number): void => { + setSelectionStart(index); + setSelectionEnd(index); + setSelectedChips([index]); + setIsSelectionMode(true); + setActiveChipIndex(index); + }, []); + + /** + * Extend selection to an index + */ + const extendSelection = useCallback( + (index: number): void => { + if (selectionStart === -1) { + startSelection(index); + return; + } + + setSelectionEnd(index); + const newSelectedChips = getIndicesBetween(selectionStart, index); + setSelectedChips(newSelectedChips); + setActiveChipIndex(index); + }, + [selectionStart, getIndicesBetween, startSelection], + ); + + /** + * Handle copy event + */ + const handleCopy = useCallback((): void => { + if (selectedChips.length === 0) return; + + const selectedTexts = selectedChips + .sort((a, b) => a - b) + .map((index) => selectedValues[index]); + + const textToCopy = selectedTexts.join(', '); + + navigator.clipboard.writeText(textToCopy).catch(console.error); + }, [selectedChips, selectedValues]); + + /** + * Handle cut event + */ + const handleCut = useCallback((): void => { + if (selectedChips.length === 0) return; + + // First copy the content + handleCopy(); + + // Then remove the selected chips + const newValues = selectedValues.filter( + (_, index) => !selectedChips.includes(index), + ); + + if (onChange) { + onChange( + newValues as any, + newValues.map((v) => ({ label: v, value: v })), + ); + } + + // Clear selection after cut + clearSelection(); + }, [selectedChips, selectedValues, handleCopy, clearSelection, onChange]); + + // ===== Event Handlers ===== + + /** + * Handles search input changes + */ + const handleSearch = useCallback( + (value: string): void => { + setActiveIndex(-1); + + // Check if we have an unbalanced quote that needs to be preserved + const hasOpenQuote = + (value.match(/"/g) || []).length % 2 !== 0 || + (value.match(/'/g) || []).length % 2 !== 0; + + // Only process by comma if we don't have open quotes + if (value.includes(',') && !hasOpenQuote) { + const values: string[] = []; + let currentValue = ''; + let inSingleQuotes = false; + let inDoubleQuotes = false; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + + // Handle quote characters + if (char === '"' && !inSingleQuotes) { + inDoubleQuotes = !inDoubleQuotes; + currentValue += char; + } else if (char === "'" && !inDoubleQuotes) { + inSingleQuotes = !inSingleQuotes; + currentValue += char; + } + // Handle commas outside of quotes + else if (char === ',' && !inSingleQuotes && !inDoubleQuotes) { + // Comma outside quotes - end of value + if (currentValue.trim()) { + // Process the value to remove surrounding quotes if present + let processedValue = currentValue.trim(); + if ( + (processedValue.startsWith('"') && processedValue.endsWith('"')) || + (processedValue.startsWith("'") && processedValue.endsWith("'")) + ) { + // Remove surrounding quotes + processedValue = processedValue.substring(1, processedValue.length - 1); + } + + if (!selectedValues.includes(processedValue)) { + values.push(processedValue); + } + } + currentValue = ''; + } + // All other characters + else { + currentValue += char; + } + } + + // Process the last value if there is one + if (currentValue.trim()) { + let processedValue = currentValue.trim(); + if ( + (processedValue.startsWith('"') && processedValue.endsWith('"')) || + (processedValue.startsWith("'") && processedValue.endsWith("'")) + ) { + // Remove surrounding quotes + processedValue = processedValue.substring(1, processedValue.length - 1); + } + + if (!selectedValues.includes(processedValue)) { + values.push(processedValue); + } + } + + if (values.length > 0) { + const newValues = [...selectedValues, ...values]; + if (onChange) { + onChange( + newValues as any, + newValues.map((v) => ({ label: v, value: v })), + ); + } + } + setSearchText(''); + return; + } + if (value.endsWith(',') && !hasOpenQuote) { + // Process a single value when comma is typed at the end (outside quotes) + const valueToProcess = value.slice(0, -1).trim(); + + if (valueToProcess) { + // Process the value to remove surrounding quotes if present + let processedValue = valueToProcess; + if ( + (processedValue.startsWith('"') && processedValue.endsWith('"')) || + (processedValue.startsWith("'") && processedValue.endsWith("'")) + ) { + // Remove surrounding quotes + processedValue = processedValue.substring(1, processedValue.length - 1); + } + + if (!selectedValues.includes(processedValue)) { + const newValues = [...selectedValues, processedValue]; + if (onChange) { + onChange( + newValues as any, + newValues.map((v) => ({ label: v, value: v })), + ); + } + } + } + + setSearchText(''); + return; + } + + // Normal single value handling + setSearchText(value.trim()); + if (!isOpen) { + setIsOpen(true); + } + if (onSearch) onSearch(value.trim()); + }, + [onSearch, isOpen, selectedValues, onChange], + ); + + // ===== UI & Rendering Functions ===== + + /** + * Highlights matched text in search results + */ + const highlightMatchedText = useCallback( + (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}`; + + return part.toLowerCase() === searchQuery.toLowerCase() ? ( + + {part} + + ) : ( + part + ); + })} + + ); + }, + [highlightSearch], + ); + + // Adjusted handleSelectAll for internal change handler + const handleSelectAll = useCallback((): void => { + if (!options) return; + + if (isAllSelected) { + // If all are selected, deselect all + handleInternalChange([]); + } else { + // Otherwise, select all + handleInternalChange([ALL_SELECTED_VALUE]); + } + }, [options, isAllSelected, handleInternalChange]); + + /** + * Renders an individual option + */ + const renderOptionItem = useCallback( + ( + option: OptionData, + isSelected: boolean, + index?: number, + ): React.ReactElement => { + const isActive = index === activeIndex; + const optionId = `option-${index}`; + + const handleItemSelection = (source?: string): void => { + // Special handling for ALL option is done by the caller + + if (!option.value) return; + + if (source === 'option') { + if ( + currentToggleTagValue({ option: option.value }) === ToggleTagValue.All + ) { + handleSelectAll(); + } else { + const newValues = [option.value]; + + if (onChange) { + onChange( + newValues, + newValues.map((v) => ({ label: v, value: v })), + ); + } + } + return; + } + + const newValues = selectedValues.includes(option.value) + ? selectedValues.filter((v) => v !== option.value) + : [...selectedValues, option.value]; + + if (onChange) { + onChange( + newValues, + newValues.map( + (v) => options.find((o) => o.value === v) ?? { label: v, value: v }, + ), + ); + } + }; + + return ( +
{ + if (index !== undefined) { + optionRefs.current[index] = el; + } + }} + className={cx('option-item', { + selected: isSelected, + active: isActive, + })} + onClick={(e): void => { + e.stopPropagation(); + e.preventDefault(); + handleItemSelection('option'); + setActiveChipIndex(-1); + setActiveIndex(-1); + }} + onKeyDown={(e): void => { + if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) { + e.stopPropagation(); + e.preventDefault(); + handleItemSelection(); + } + }} + onMouseEnter={(): void => { + setActiveIndex(index ?? -1); + setActiveChipIndex(-1); // Clear chip selection when hovering ALL option + }} + role="option" + aria-selected={isSelected} + aria-disabled={option.disabled} + tabIndex={isActive ? 0 : -1} + > + { + e.stopPropagation(); + e.preventDefault(); + handleItemSelection('checkbox'); + setActiveChipIndex(-1); + setActiveIndex(-1); + }} + > +
+ + {highlightMatchedText(String(option.label || ''), searchText)} + + {(option.type === 'custom' || option.type === 'regex') && ( +
{capitalize(option.type)}
+ )} + {option.value && ensureValidOption(option.value) && ( + + )} + +
+
+
+ ); + }, + [ + activeIndex, + highlightMatchedText, + searchText, + ensureValidOption, + currentToggleTagValue, + selectedValues, + onChange, + handleSelectAll, + options, + ], + ); + + /** + * Helper function to render option with index tracking + */ + const renderOptionWithIndex = useCallback( + (option: OptionData, isSelected: boolean, idx: number) => + renderOptionItem(option, isSelected, idx), + [renderOptionItem], + ); + + // Helper function to get visible chip indices + const getVisibleChipIndices = useCallback((): number[] => { + // If no values, return empty array + if (selectedValues.length === 0) return []; + + // If maxTagCount is set and greater than 0, only return the first maxTagCount indices + const visibleCount = + maxTagCount !== undefined && maxTagCount > 0 + ? Math.min(maxTagCount, selectedValues.length) + : selectedValues.length; + + return Array.from({ length: visibleCount }, (_, i) => i); + }, [selectedValues.length, maxTagCount]); + + // Get the last visible chip index + const getLastVisibleChipIndex = useCallback((): number => { + const visibleIndices = getVisibleChipIndices(); + return visibleIndices.length > 0 + ? visibleIndices[visibleIndices.length - 1] + : -1; + }, [getVisibleChipIndices]); + + // Enhanced keyboard navigation with support for maxTagCount + const handleKeyDown = useCallback( + (e: React.KeyboardEvent): void => { + // Get flattened list of all selectable options + const getFlatOptions = (): OptionData[] => { + if (!visibleOptions) return []; + + const flatList: OptionData[] = []; + const hasAll = enableAllSelection && !searchText; + + // Process options + const { sectionOptions, nonSectionOptions } = splitOptions(visibleOptions); + + // Add all options to flat list + if (hasAll) { + flatList.push({ + label: 'ALL', + value: '__all__', // Special value for the ALL option + type: 'defined', + }); + } + + // Add Regex to flat list + if (!isEmpty(searchText)) { + // Only add regex wrapper if it doesn't already look like a regex pattern + const isAlreadyRegex = + searchText.startsWith('.*') && searchText.endsWith('.*'); + + if (!isAlreadyRegex) { + flatList.push({ + label: `.*${searchText}.*`, + value: `.*${searchText}.*`, + type: 'regex', + }); + } + } + + flatList.push(...nonSectionOptions); + sectionOptions.forEach((section) => { + if (section.options) { + flatList.push(...section.options); + } + }); + + return flatList; + }; + + const flatOptions = getFlatOptions(); + + // Get the active input element to check cursor position + const activeElement = document.activeElement as HTMLInputElement; + const isInputActive = activeElement?.tagName === 'INPUT'; + const cursorAtStart = isInputActive && activeElement?.selectionStart === 0; + const hasInputText = isInputActive && !!activeElement?.value; + + // Get indices of visible chips + const visibleIndices = getVisibleChipIndices(); + const lastVisibleChipIndex = getLastVisibleChipIndex(); + + // Handle special keyboard combinations + const isCtrlOrCmd = e.ctrlKey || e.metaKey; + + // Handle Ctrl+A (select all) + if (isCtrlOrCmd && e.key === 'a') { + e.preventDefault(); + e.stopPropagation(); + + // If there are chips, select them all + if (selectedValues.length > 0) { + selectAllChips(); + return; + } + + // Otherwise let the default select all behavior happen + return; + } + + // Handle copy/cut operations + if (isCtrlOrCmd && selectedChips.length > 0) { + if (e.key === 'c') { + e.preventDefault(); + e.stopPropagation(); + handleCopy(); + return; + } + + if (e.key === 'x') { + e.preventDefault(); + e.stopPropagation(); + handleCut(); + return; + } + } + + // Handle deletion of selected chips + if ( + (e.key === 'Backspace' || e.key === 'Delete') && + selectedChips.length > 0 + ) { + e.preventDefault(); + e.stopPropagation(); + + // Remove all the selected chips + const newValues = selectedValues.filter( + (_, index) => !selectedChips.includes(index), + ); + + if (onChange) { + onChange( + newValues as any, + newValues.map((v) => ({ label: v, value: v })), + ); + } + + // Clear selection after deletion + clearSelection(); + return; + } + + // Handle selection with Shift + Arrow keys + if (e.shiftKey) { + // Only handle chip selection if we have chips and either a chip is active + // or we're at the start of an empty/unselected input + const canHandleChipSelection = + selectedValues.length > 0 && + (activeChipIndex >= 0 || (cursorAtStart && !hasInputText)); + + if (canHandleChipSelection) { + switch (e.key) { + case 'ArrowLeft': { + e.preventDefault(); + e.stopPropagation(); + + // Start selection if not in selection mode + if (!isSelectionMode) { + const start = + activeChipIndex >= 0 ? activeChipIndex : lastVisibleChipIndex; + // Start selection with current chip and immediate neighbor + // If we're starting from an active chip, select it and the one to its left + if (activeChipIndex >= 0 && activeChipIndex > 0) { + setSelectionStart(activeChipIndex); + setSelectionEnd(activeChipIndex - 1); + setSelectedChips( + getIndicesBetween(activeChipIndex, activeChipIndex - 1), + ); + setIsSelectionMode(true); + setActiveChipIndex(activeChipIndex - 1); + } else { + // Fall back to single selection for edge cases + startSelection(start); + } + } else { + // Extend selection to the left + const newEnd = Math.max(0, selectionEnd - 1); + extendSelection(newEnd); + } + return; + } + + case 'ArrowRight': { + e.preventDefault(); + e.stopPropagation(); + + // Start selection if not in selection mode + if (!isSelectionMode) { + const start = activeChipIndex >= 0 ? activeChipIndex : 0; + // Start selection with current chip and immediate neighbor + // If we're starting from an active chip, select it and the one to its right + if (activeChipIndex >= 0 && activeChipIndex < lastVisibleChipIndex) { + setSelectionStart(activeChipIndex); + setSelectionEnd(activeChipIndex + 1); + setSelectedChips( + getIndicesBetween(activeChipIndex, activeChipIndex + 1), + ); + setIsSelectionMode(true); + setActiveChipIndex(activeChipIndex + 1); + } else { + // Fall back to single selection for edge cases + startSelection(start); + } + } + // Extend selection to the right if not at last chip + else if (selectionEnd < lastVisibleChipIndex) { + const newEnd = selectionEnd + 1; + extendSelection(newEnd); + } else { + // Move focus to input when extending past last chip + clearSelection(); + setActiveChipIndex(-1); + if (selectRef.current) { + selectRef.current.focus(); + } + } + + return; + } + + case 'Home': { + e.preventDefault(); + e.stopPropagation(); + + // Start or extend selection to beginning + if (!isSelectionMode) { + startSelection(0); + } else { + extendSelection(0); + } + return; + } + + case 'End': { + e.preventDefault(); + e.stopPropagation(); + + // Start or extend selection to end + if (!isSelectionMode) { + startSelection(lastVisibleChipIndex); + } else { + extendSelection(lastVisibleChipIndex); + } + return; + } + + default: + break; + } + } + } + // If any key is pressed without shift/ctrl and we're in selection mode, clear selection + else if (isSelectionMode) { + // Don't clear selection on navigation keys + const isNavigationKey = [ + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'Home', + 'End', + ].includes(e.key); + if (!isNavigationKey) { + clearSelection(); + } + } + + // Handle up/down keys to open dropdown from input + if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !isOpen) { + e.stopPropagation(); + e.preventDefault(); + setIsOpen(true); + setActiveIndex(0); + setActiveChipIndex(-1); + clearSelection(); + return; + } + + // Handle chip navigation when active + if (activeChipIndex >= 0 && selectedValues.length > 0) { + // Prepare variables for potential use in multiple case blocks + let newValues: string[] = []; + + switch (e.key) { + case 'ArrowLeft': + e.stopPropagation(); + e.preventDefault(); + + // Only navigate within the visible chip range + if (activeChipIndex > 0 && visibleIndices.includes(activeChipIndex - 1)) { + // Move to previous visible chip + setActiveChipIndex(activeChipIndex - 1); + } else { + // Wrap around to last visible chip + setActiveChipIndex(lastVisibleChipIndex); + } + + clearSelection(); + break; + + case 'ArrowRight': + e.stopPropagation(); + e.preventDefault(); + + // Only navigate within the visible chip range + if (activeChipIndex < lastVisibleChipIndex) { + // Move to next visible chip + setActiveChipIndex(activeChipIndex + 1); + } else { + // Move from last chip to input + setActiveChipIndex(-1); + clearSelection(); + if (selectRef.current) { + selectRef.current.focus(); + } + } + + clearSelection(); + break; + case 'Backspace': + case 'Delete': + // Remove the active chip + e.stopPropagation(); + e.preventDefault(); + newValues = selectedValues.filter( + (_, index) => index !== activeChipIndex, + ); + if (onChange) { + onChange(newValues as any, newValues as any); + } + // If we deleted the last chip, move focus to previous + if (activeChipIndex >= newValues.length) { + setActiveChipIndex( + newValues.length > 0 + ? Math.min(activeChipIndex - 1, lastVisibleChipIndex) + : -1, + ); + } + clearSelection(); + break; + case 'Escape': + // Clear chip selection + setActiveChipIndex(-1); + clearSelection(); + break; + case 'ArrowDown': + case 'ArrowUp': + // Switch from chip to dropdown navigation + if (isOpen) { + setActiveChipIndex(-1); + setActiveIndex(0); + } else { + setIsOpen(true); + setActiveChipIndex(-1); + setActiveIndex(0); + } + clearSelection(); + break; + default: + // If user types a letter when chip is active, focus the input field + if (e.key.length === 1 && /[a-zA-Z0-9]/.test(e.key)) { + e.stopPropagation(); + e.preventDefault(); + setActiveChipIndex(-1); + clearSelection(); + // Try to focus on the input field + if (selectRef.current) { + // Focus select which will in turn focus the input + selectRef.current.focus(); + } + } + break; + } + return; // Early return when navigating chips + } + + // Handle dropdown navigation when open + if (isOpen) { + switch (e.key) { + case 'ArrowDown': + e.stopPropagation(); + e.preventDefault(); + setActiveIndex((prev) => (prev < flatOptions.length - 1 ? prev + 1 : 0)); + setActiveChipIndex(-1); + clearSelection(); + break; + + case 'ArrowUp': + e.stopPropagation(); + e.preventDefault(); + setActiveIndex((prev) => (prev > 0 ? prev - 1 : flatOptions.length - 1)); + setActiveChipIndex(-1); + clearSelection(); + break; + + case 'Tab': + // Tab navigation with Shift key support + if (e.shiftKey) { + e.stopPropagation(); + e.preventDefault(); + setActiveIndex((prev) => (prev > 0 ? prev - 1 : flatOptions.length - 1)); + } else { + e.stopPropagation(); + e.preventDefault(); + setActiveIndex((prev) => (prev < flatOptions.length - 1 ? prev + 1 : 0)); + } + setActiveChipIndex(-1); + clearSelection(); + break; + + case 'Enter': + e.stopPropagation(); + e.preventDefault(); + + // 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__') { + handleSelectAll(); + } else if (selectedOption.value && onChange) { + const newValues = selectedValues.includes(selectedOption.value) + ? selectedValues.filter((v) => v !== selectedOption.value) + : [...selectedValues, selectedOption.value]; + onChange(newValues as any, newValues as any); + } + } else if (searchText.trim()) { + const trimmedValue = searchText.trim(); + // Check if value already exists in selectedValues + if (!selectedValues.includes(trimmedValue)) { + const newValues = [...selectedValues, trimmedValue]; + if (onChange) { + onChange( + newValues as any, + newValues.map((v) => ({ label: v, value: v })), + ); + } + } + setSearchText(''); + } + + break; + + case 'Escape': + e.stopPropagation(); + e.preventDefault(); + setIsOpen(false); + setActiveIndex(-1); + break; + + case SPACEKEY: + if (activeIndex >= 0 && activeIndex < flatOptions.length) { + e.stopPropagation(); + e.preventDefault(); + const selectedOption = flatOptions[activeIndex]; + + // Check if it's the ALL option + if (selectedOption.value === '__all__') { + handleSelectAll(); + } else if (selectedOption.value && onChange) { + const newValues = selectedValues.includes(selectedOption.value) + ? selectedValues.filter((v) => v !== selectedOption.value) + : [...selectedValues, selectedOption.value]; + + onChange(newValues as any, newValues as any); + } + // Don't close dropdown, just update selection + } + break; + + case 'ArrowLeft': + // If at start of input, move to chips + if (cursorAtStart && visibleIndices.length > 0 && !hasInputText) { + e.stopPropagation(); + e.preventDefault(); + setActiveChipIndex(lastVisibleChipIndex); + setActiveIndex(-1); + } + break; + + case 'ArrowRight': + // Start chip navigation when right arrow is pressed and we have chips to navigate + if (visibleIndices.length > 0 && !hasInputText) { + e.stopPropagation(); + e.preventDefault(); + // Navigate to the first chip + setActiveChipIndex(0); + setActiveIndex(-1); + } + break; + + default: + break; + } + } else { + // Handle keyboard events when dropdown is closed + switch (e.key) { + case 'ArrowDown': + case 'ArrowUp': + // Open dropdown when Down is pressed while closed + e.stopPropagation(); + e.preventDefault(); + setIsOpen(true); + setActiveIndex(0); + setActiveChipIndex(-1); + break; + + case 'ArrowLeft': + // Start chip navigation if at start of input and no text or empty input + if ((cursorAtStart || !hasInputText) && visibleIndices.length > 0) { + e.stopPropagation(); + e.preventDefault(); + // Navigate to the last visible chip - this is what skips the "+N more" chip + setActiveChipIndex(lastVisibleChipIndex); + setActiveIndex(-1); + } + break; + + case 'ArrowRight': + // No special handling needed for right arrow when dropdown is closed + break; + + case 'Tab': + // When dropdown is closed and Tab is pressed, we should not capture it + // Let the browser handle the tab navigation + break; + + case 'Backspace': + // If at start of input and no text, select last chip + if (cursorAtStart && !hasInputText && visibleIndices.length > 0) { + e.stopPropagation(); + e.preventDefault(); + setActiveChipIndex(lastVisibleChipIndex); + setActiveIndex(-1); + } + break; + + case 'Escape': + // Clear focus when dropdown is closed + setActiveChipIndex(-1); + setActiveIndex(-1); + break; + + default: + break; + } + } + }, + [ + selectedChips, + isSelectionMode, + isOpen, + activeChipIndex, + selectedValues, + visibleOptions, + enableAllSelection, + searchText, + splitOptions, + selectAllChips, + handleCopy, + handleCut, + onChange, + clearSelection, + getIndicesBetween, + startSelection, + selectionEnd, + extendSelection, + activeIndex, + handleSelectAll, + getVisibleChipIndices, + getLastVisibleChipIndex, + ], + ); + + // Handle dropdown clicks + const handleDropdownClick = useCallback((e: React.MouseEvent): void => { + e.stopPropagation(); + }, []); + + // Add mousedown handler to the dropdown container + const handleDropdownMouseDown = useCallback((e: React.MouseEvent): void => { + e.preventDefault(); // Prevent focus change + isClickInsideDropdownRef.current = true; + }, []); + + // Handle blur with the flag + const handleBlur = useCallback((): void => { + if (isClickInsideDropdownRef.current) { + isClickInsideDropdownRef.current = false; + return; + } + // Handle actual blur + setIsOpen(false); + }, []); + + // Custom dropdown render with sections support + const customDropdownRender = useCallback((): React.ReactElement => { + // Process options based on current search + const processedOptions = + selectedValues.length > 0 && isEmpty(searchText) + ? prioritizeOrAddOptionForMultiSelect(filteredOptions, selectedValues) + : filteredOptions; + + const { sectionOptions, nonSectionOptions } = splitOptions(processedOptions); + + // Check if we need to add a custom option based on search text + const isSearchTextNotPresent = + !isEmpty(searchText) && !isLabelPresent(processedOptions, searchText); + + // We will add these options in this order, which will be reflected in the UI + const customOptions: OptionData[] = []; + + // add regex options first since they appear first in the UI + if (!isEmpty(searchText)) { + // Only add regex wrapper if it doesn't already look like a regex pattern + const isAlreadyRegex = + searchText.startsWith('.*') && searchText.endsWith('.*'); + + if (!isAlreadyRegex) { + customOptions.push({ + label: `.*${searchText}.*`, + value: `.*${searchText}.*`, + type: 'regex', + }); + } + } + + // add custom option next + if (isSearchTextNotPresent) { + customOptions.push({ + label: searchText, + value: searchText, + type: 'custom', + }); + } + + // Now add all custom options at the beginning + const enhancedNonSectionOptions = [...customOptions, ...nonSectionOptions]; + + const allOptionValues = getAllAvailableValues(processedOptions); + const allOptionsSelected = + allOptionValues.length > 0 && + allOptionValues.every((val) => selectedValues.includes(val)); + + // Determine if ALL option should be shown + const showAllOption = enableAllSelection && !searchText; + + // Initialize optionIndex based on whether the ALL option is shown + // If ALL option is shown, it gets index 0, and other options start at index 1 + let optionIndex = showAllOption ? 1 : 0; + + // Helper function to map options with index tracking + const mapOptions = (options: OptionData[]): React.ReactNode => + options.map((option) => { + const optionValue = option.value || ''; + const result = renderOptionWithIndex( + option, + selectedValues.includes(optionValue), + optionIndex, + ); + optionIndex += 1; + return result; + }); + + const customMenu = ( +
= 0 ? `option-${activeIndex}` : undefined + } + tabIndex={-1} + > + {/* ALL checkbox only when search is empty */} + {showAllOption && ( + <> +
{ + setActiveIndex(0); + setActiveChipIndex(-1); // Clear chip selection when hovering ALL option + }} + role="option" + aria-selected={allOptionsSelected} + tabIndex={0} + ref={(el): void => { + optionRefs.current[0] = el; + }} + onClick={(e): void => { + e.stopPropagation(); + e.preventDefault(); + handleSelectAll(); + }} + onKeyDown={(e): void => { + if ((e.key === 'Enter' || e.key === SPACEKEY) && activeIndex === 0) { + e.stopPropagation(); + e.preventDefault(); + handleSelectAll(); + } + }} + > + +
+
ALL
+
+
+
+
+ + )} + + {/* Non-section options when not searching */} + {enhancedNonSectionOptions.length > 0 && ( +
+ {mapOptions(enhancedNonSectionOptions)} +
+ )} + + {/* Section options when not searching */} + {sectionOptions.length > 0 && + sectionOptions.map((section) => + !isEmpty(section.options) ? ( +
+
+ {section.label} +
+
+ {section.options && mapOptions(section.options)} +
+
+ ) : null, + )} + + {/* Navigation help footer */} +
+ {!loading && !errorMessage && !noDataMessage && ( +
+ + + + + to navigate +
+ )} + {loading && ( +
+
+ +
+
We are updating the values...
+
+ )} + {errorMessage && !loading && ( +
+
+ {errorMessage || SOMETHING_WENT_WRONG} +
+
+ { + e.stopPropagation(); + if (onRetry) onRetry(); + }} + /> +
+
+ )} + + {noDataMessage && !loading && ( +
{noDataMessage}
+ )} +
+
+ ); + + return dropdownRender ? dropdownRender(customMenu) : customMenu; + }, [ + selectedValues, + searchText, + filteredOptions, + splitOptions, + isLabelPresent, + getAllAvailableValues, + enableAllSelection, + handleDropdownMouseDown, + handleDropdownClick, + handleKeyDown, + handleBlur, + activeIndex, + loading, + errorMessage, + noDataMessage, + dropdownRender, + renderOptionWithIndex, + handleSelectAll, + onRetry, + ]); + + // ===== Side Effects ===== + + // Clear search when dropdown closes + useEffect(() => { + if (!isOpen) { + setSearchText(''); + setActiveIndex(-1); + // Don't clear activeChipIndex when dropdown closes to maintain tag focus + } else { + // When opening dropdown, clear chip selection + setActiveChipIndex(-1); + } + }, [isOpen]); + + // Auto-scroll active option into view + useEffect(() => { + if (isOpen && activeIndex >= 0 && optionRefs.current[activeIndex]) { + optionRefs.current[activeIndex]?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + } + }, [isOpen, activeIndex]); + + // Add document level event listeners to handle clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent): void => { + // Find the select element by its class + const selectElement = document.querySelector('.custom-multiselect'); + + // If we're in selection mode and the click is outside the component, clear selection + if ( + isSelectionMode && + selectElement && + !selectElement.contains(e.target as Node) + ) { + clearSelection(); + } + }; + + const handleKeyDown = (e: KeyboardEvent): void => { + // Clear selection when Escape is pressed + if (e.key === 'Escape' && isSelectionMode) { + clearSelection(); + } + }; + + document.addEventListener('click', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + + return (): void => { + document.removeEventListener('click', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isSelectionMode, clearSelection]); + + // ===== Final Processing ===== + + // Custom Tag Render (needs significant updates) + const tagRender = useCallback( + (props: CustomTagProps): React.ReactElement => { + const { label, value, closable, onClose } = props; + + // If the display value is the special ALL value, render the ALL tag + if (value === ALL_SELECTED_VALUE && isAllSelected) { + const handleAllTagClose = ( + e: React.MouseEvent | React.KeyboardEvent, + ): void => { + e.stopPropagation(); + e.preventDefault(); + handleInternalChange([]); // Clear selection when ALL tag is closed + }; + + const handleAllTagKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Enter' || e.key === SPACEKEY) { + handleAllTagClose(e); + } + // Prevent Backspace/Delete propagation if needed, handle in main keydown handler + }; + + return ( +
+ ALL + {closable && ( + + × + + )} +
+ ); + } + + // If not isAllSelected, render individual tags using previous logic + // but base indices/visibility on the original `selectedValues` + if (!isAllSelected) { + const index = selectedValues.indexOf(value); + if (index === -1) return
; // Should not happen if value comes from displayValue + + const isActive = index === activeChipIndex; + const isSelected = selectedChips.includes(index); + + const isPlusNTag = + typeof value === 'string' && + value.startsWith('+') && + !selectedValues.includes(value); + + if (isPlusNTag) { + // Render the "+N more" tag as before + return ( +
+ {label} +
+ ); + } + + // Check visibility based on original selectedValues length and maxTagCount + const visibleCount = + maxTagCount !== undefined && maxTagCount > 0 + ? Math.min(maxTagCount, selectedValues.length) + : selectedValues.length; + const isVisible = index < visibleCount; + + if (!isVisible) { + return
; + } + + const handleTagKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Enter' || e.key === SPACEKEY) { + e.stopPropagation(); + e.preventDefault(); + onClose(); // Default close action removes the specific tag + } + }; + + return ( +
+ {label} + {closable && ( + + × + + )} +
+ ); + } + + // Fallback for safety, should not be reached + return
; + }, + [ + isAllSelected, + handleInternalChange, + activeChipIndex, + selectedChips, + selectedValues, + maxTagCount, + ], + ); + + // ===== Component Rendering ===== + return ( + } + dropdownRender={customDropdownRender} + menuItemSelectedIcon={null} + popupClassName={cx('custom-select-dropdown-container', popupClassName)} + listHeight={300} + placement={placement} + optionFilterProp="label" + notFoundContent={
{noDataMessage}
} + onKeyDown={handleKeyDown} + {...rest} + /> + ); +}; + +export default CustomSelect; diff --git a/frontend/src/components/NewSelect/__test__/CustomMultiSelect.test.tsx b/frontend/src/components/NewSelect/__test__/CustomMultiSelect.test.tsx new file mode 100644 index 0000000000..c4b35205cd --- /dev/null +++ b/frontend/src/components/NewSelect/__test__/CustomMultiSelect.test.tsx @@ -0,0 +1,263 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import CustomMultiSelect from '../CustomMultiSelect'; + +// Mock scrollIntoView which isn't available in JSDOM +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +// Mock options data +const mockOptions = [ + { label: 'Option 1', value: 'option1' }, + { label: 'Option 2', value: 'option2' }, + { label: 'Option 3', value: 'option3' }, +]; + +const mockGroupedOptions = [ + { + label: 'Group 1', + options: [ + { label: 'Group 1 - Option 1', value: 'g1-option1' }, + { label: 'Group 1 - Option 2', value: 'g1-option2' }, + ], + }, + { + label: 'Group 2', + options: [ + { label: 'Group 2 - Option 1', value: 'g2-option1' }, + { label: 'Group 2 - Option 2', value: 'g2-option2' }, + ], + }, +]; + +describe('CustomMultiSelect Component', () => { + it('renders with placeholder', () => { + const handleChange = jest.fn(); + render( + , + ); + + // Check placeholder exists + const placeholderElement = screen.getByText('Select multiple options'); + expect(placeholderElement).toBeInTheDocument(); + }); + + it('opens dropdown when clicked', async () => { + const handleChange = jest.fn(); + render(); + + // Click to open the dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Wait for dropdown to appear + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); // The ALL option + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + expect(screen.getByText('Option 3')).toBeInTheDocument(); + }); + }); + + it('selects multiple options', async () => { + const handleChange = jest.fn(); + + // Start with option1 already selected + render( + , + ); + + // Open dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Wait for dropdown to appear + await waitFor(() => { + expect(screen.getByText('Option 3')).toBeInTheDocument(); + }); + + // Click on Option 3 + const option3 = screen.getByText('Option 3'); + fireEvent.click(option3); + + // Verify onChange was called with the right values + expect(handleChange).toHaveBeenCalled(); + }); + + it('selects ALL options when ALL is clicked', async () => { + const handleChange = jest.fn(); + render( + , + ); + + // Open dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Wait for dropdown to appear + await waitFor(() => { + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); + + // Click on ALL option + const allOption = screen.getByText('ALL'); + fireEvent.click(allOption); + + // Verify onChange was called with all option values + expect(handleChange).toHaveBeenCalledWith( + ['option1', 'option2', 'option3'], + expect.arrayContaining([ + expect.objectContaining({ value: 'option1' }), + expect.objectContaining({ value: 'option2' }), + expect.objectContaining({ value: 'option3' }), + ]), + ); + }); + + it('displays selected options as tags', async () => { + render( + , + ); + + // Check that option values are shown as tags (not labels) + expect(screen.getByText('option1')).toBeInTheDocument(); + expect(screen.getByText('option2')).toBeInTheDocument(); + }); + + it('removes a tag when clicked', async () => { + const handleChange = jest.fn(); + render( + , + ); + + // Find close button on Option 1 tag and click it + const closeButtons = document.querySelectorAll( + '.ant-select-selection-item-remove', + ); + fireEvent.click(closeButtons[0]); + + // Verify onChange was called with remaining option + expect(handleChange).toHaveBeenCalledWith( + ['option2'], + expect.arrayContaining([expect.objectContaining({ value: 'option2' })]), + ); + }); + + it('filters options when searching', async () => { + render(); + + // Open dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Type into search box - get input directly + const inputElement = selectElement.querySelector('input'); + if (inputElement) { + fireEvent.change(inputElement, { target: { value: '2' } }); + } + + // Wait for the dropdown filtering to happen + await waitFor(() => { + // Check that the dropdown is present + const dropdownElement = document.querySelector( + '.custom-multiselect-dropdown', + ); + expect(dropdownElement).toBeInTheDocument(); + + // Verify Option 2 is visible in the dropdown + const options = document.querySelectorAll('.option-label-text'); + let foundOption2 = false; + + options.forEach((option) => { + const text = option.textContent || ''; + if (text.includes('Option 2')) foundOption2 = true; + }); + + expect(foundOption2).toBe(true); + }); + }); + + it('renders grouped options correctly', async () => { + render(); + + // Open dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Check group headers and options + await waitFor(() => { + expect(screen.getByText('Group 1')).toBeInTheDocument(); + expect(screen.getByText('Group 2')).toBeInTheDocument(); + expect(screen.getByText('Group 1 - Option 1')).toBeInTheDocument(); + expect(screen.getByText('Group 1 - Option 2')).toBeInTheDocument(); + expect(screen.getByText('Group 2 - Option 1')).toBeInTheDocument(); + expect(screen.getByText('Group 2 - Option 2')).toBeInTheDocument(); + }); + }); + + it('shows loading state', () => { + render(); + + // Open dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Check loading text is displayed + expect(screen.getByText('We are updating the values...')).toBeInTheDocument(); + }); + + it('shows error message', () => { + render( + , + ); + + // Open dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Check error message is displayed + expect(screen.getByText('Test error message')).toBeInTheDocument(); + }); + + it('shows no data message', () => { + render(); + + // Open dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Check no data message is displayed + expect(screen.getByText('No data available')).toBeInTheDocument(); + }); + + it('shows "ALL" tag when all options are selected', () => { + render( + , + ); + + // When all options are selected, component shows ALL tag instead + expect(screen.getByText('ALL')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/NewSelect/__test__/CustomSelect.test.tsx b/frontend/src/components/NewSelect/__test__/CustomSelect.test.tsx new file mode 100644 index 0000000000..26b9200698 --- /dev/null +++ b/frontend/src/components/NewSelect/__test__/CustomSelect.test.tsx @@ -0,0 +1,206 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import CustomSelect from '../CustomSelect'; + +// Mock scrollIntoView which isn't available in JSDOM +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +// Mock options data +const mockOptions = [ + { label: 'Option 1', value: 'option1' }, + { label: 'Option 2', value: 'option2' }, + { label: 'Option 3', value: 'option3' }, +]; + +const mockGroupedOptions = [ + { + label: 'Group 1', + options: [ + { label: 'Group 1 - Option 1', value: 'g1-option1' }, + { label: 'Group 1 - Option 2', value: 'g1-option2' }, + ], + }, + { + label: 'Group 2', + options: [ + { label: 'Group 2 - Option 1', value: 'g2-option1' }, + { label: 'Group 2 - Option 2', value: 'g2-option2' }, + ], + }, +]; + +describe('CustomSelect Component', () => { + it('renders with placeholder and options', () => { + const handleChange = jest.fn(); + render( + , + ); + + // Check placeholder exists in the DOM (not using getByPlaceholderText) + const placeholderElement = screen.getByText('Test placeholder'); + expect(placeholderElement).toBeInTheDocument(); + }); + + it('opens dropdown when clicked', async () => { + const handleChange = jest.fn(); + render(); + + // Click to open the dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Wait for dropdown to appear + await waitFor(() => { + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + expect(screen.getByText('Option 3')).toBeInTheDocument(); + }); + }); + + it('calls onChange when option is selected', async () => { + const handleChange = jest.fn(); + render(); + + // Open dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Click on an option + await waitFor(() => { + const option = screen.getByText('Option 2'); + fireEvent.click(option); + }); + + // Check onChange was called with correct value + expect(handleChange).toHaveBeenCalledWith('option2', expect.anything()); + }); + + it('filters options when searching', async () => { + render(); + + // Open dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Type into search box + fireEvent.change(selectElement, { target: { value: '2' } }); + + // Dropdown should only show Option 2 + await waitFor(() => { + // Check that the dropdown is present + const dropdownElement = document.querySelector('.custom-select-dropdown'); + expect(dropdownElement).toBeInTheDocument(); + + // Use a simple approach to verify filtering + const allOptionsInDropdown = document.querySelectorAll('.option-item'); + let foundOption2 = false; + + allOptionsInDropdown.forEach((option) => { + if (option.textContent?.includes('Option 2')) { + foundOption2 = true; + } + + // Should not show Options 1 or 3 + expect(option.textContent).not.toContain('Option 1'); + expect(option.textContent).not.toContain('Option 3'); + }); + + expect(foundOption2).toBe(true); + }); + }); + + it('renders grouped options correctly', async () => { + const handleChange = jest.fn(); + render(); + + // Open dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Check group headers and options + await waitFor(() => { + expect(screen.getByText('Group 1')).toBeInTheDocument(); + expect(screen.getByText('Group 2')).toBeInTheDocument(); + expect(screen.getByText('Group 1 - Option 1')).toBeInTheDocument(); + expect(screen.getByText('Group 1 - Option 2')).toBeInTheDocument(); + expect(screen.getByText('Group 2 - Option 1')).toBeInTheDocument(); + expect(screen.getByText('Group 2 - Option 2')).toBeInTheDocument(); + }); + }); + + it('shows loading state', () => { + render(); + + // Open dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Check loading text is displayed + expect(screen.getByText('We are updating the values...')).toBeInTheDocument(); + }); + + it('shows error message', () => { + render( + , + ); + + // Open dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Check error message is displayed + expect(screen.getByText('Test error message')).toBeInTheDocument(); + }); + + it('shows no data message', () => { + render(); + + // Open dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Check no data message is displayed + expect(screen.getByText('No data available')).toBeInTheDocument(); + }); + + it('supports keyboard navigation', async () => { + const handleChange = jest.fn(); + render(); + + // Open dropdown using keyboard + const selectElement = screen.getByRole('combobox'); + fireEvent.focus(selectElement); + + // Press down arrow to open dropdown + fireEvent.keyDown(selectElement, { key: 'ArrowDown' }); + + // Wait for dropdown to appear + await waitFor(() => { + expect(screen.getByText('Option 1')).toBeInTheDocument(); + }); + }); + + it('handles selection via keyboard', async () => { + const handleChange = jest.fn(); + render(); + + // Open dropdown + const selectElement = screen.getByRole('combobox'); + fireEvent.mouseDown(selectElement); + + // Wait for dropdown to appear then press Enter + await waitFor(() => { + expect(screen.getByText('Option 1')).toBeInTheDocument(); + + // Press Enter to select first option + fireEvent.keyDown(screen.getByText('Option 1'), { key: 'Enter' }); + }); + + // Check onChange was called + expect(handleChange).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/NewSelect/index.ts b/frontend/src/components/NewSelect/index.ts new file mode 100644 index 0000000000..a2b346040f --- /dev/null +++ b/frontend/src/components/NewSelect/index.ts @@ -0,0 +1,8 @@ +import type { CustomMultiSelectProps } from './CustomMultiSelect'; +import CustomMultiSelect from './CustomMultiSelect'; +import type { CustomSelectProps, OptionData } from './CustomSelect'; +import CustomSelect from './CustomSelect'; + +export { CustomMultiSelect, CustomSelect }; + +export type { CustomMultiSelectProps, CustomSelectProps, OptionData }; diff --git a/frontend/src/components/NewSelect/styles.scss b/frontend/src/components/NewSelect/styles.scss new file mode 100644 index 0000000000..7eb2d95414 --- /dev/null +++ b/frontend/src/components/NewSelect/styles.scss @@ -0,0 +1,838 @@ +// Main container styles + +// make const of #2c3044 +$custom-border-color: #2c3044; + +.custom-select { + width: 100%; + position: relative; + + &.ant-select-focused { + .ant-select-selector { + border-color: var(--bg-robin-500); + box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.2); + } + } + + .ant-select-selection-placeholder { + color: rgba(192, 193, 195, 0.45); + } + + // Base styles are for dark mode + .ant-select-selector { + background-color: var(--bg-ink-400); + border-color: var(--bg-slate-400); + } + + .ant-select-clear { + background-color: var(--bg-ink-400); + color: rgba(192, 193, 195, 0.7); + } +} + +// Keep chip styles ONLY in the multi-select +.custom-multiselect { + width: 100%; + position: relative; + + .ant-select-selector { + max-height: 200px; + overflow: auto; + scrollbar-width: thin; + background-color: var(--bg-ink-400); + border-color: var(--bg-slate-400); + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background-color: $custom-border-color; + border-radius: 3px; + } + + &::-webkit-scrollbar-track { + background-color: var(--bg-slate-400); + } + } + + &.ant-select-focused { + .ant-select-selector { + border-color: var(--bg-robin-500); + box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.2); + } + } + + .ant-select-selection-placeholder { + color: rgba(192, 193, 195, 0.45); + } + + // Customize tags in multiselect (dark mode by default) + .ant-select-selection-item { + background-color: var(--bg-slate-400); + border-radius: 4px; + border: 1px solid $custom-border-color; + margin-right: 4px; + transition: all 0.2s; + color: var(--bg-vanilla-400); + + // Style for active tag (keyboard navigation) + &-active { + border-color: var(--bg-robin-500) !important; + background-color: rgba(78, 116, 248, 0.15) !important; + outline: 2px solid rgba(78, 116, 248, 0.2); + } + + // Style for selected tags (via keyboard or mouse selection) + &-selected { + border-color: var(--bg-robin-500) !important; + background-color: rgba(78, 116, 248, 0.15) !important; + box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.2); + } + + .ant-select-selection-item-content { + color: var(--bg-vanilla-400); + } + + .ant-select-selection-item-remove { + color: rgba(192, 193, 195, 0.7); + &:hover { + color: rgba(192, 193, 195, 1); + } + } + } + + // Class applied when in selection mode + &.has-selection { + .ant-select-selection-item-selected { + cursor: move; // Indicate draggable + } + + // Change cursor for selection + .ant-select-selector { + cursor: text; + } + } +} + +// Dropdown styles +.custom-select-dropdown-container, +.custom-multiselect-dropdown-container { + z-index: 1050 !important; + padding: 0; + box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.5), 0 6px 16px 0 rgba(0, 0, 0, 0.4), + 0 9px 28px 8px rgba(0, 0, 0, 0.3); + background-color: var(--bg-ink-400); + border: 1px solid var(--bg-slate-400); + + .ant-select-item { + padding: 8px 12px; + color: var(--bg-vanilla-400); + + // Make keyboard navigation visible + &-option-active { + background-color: var(--bg-slate-400) !important; + } + + &-option-selected { + background-color: rgba(78, 116, 248, 0.15) !important; + } + } +} + +.custom-select-dropdown-container, +.custom-multiselect-dropdown-container { + width: 100%; + overflow-x: auto; + overflow-y: hidden; + resize: horizontal; + min-width: 300px !important; + + .empty-message { + padding: 12px; + text-align: center; + color: rgba(192, 193, 195, 0.45); + } +} + +// Custom dropdown styles for single select +.custom-select-dropdown { + padding: 8px 0 0 0; + max-height: 500px; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + border-radius: 4px; + border: 1px solid var(--bg-slate-400); + width: 100%; + background-color: var(--bg-ink-400); + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background-color: $custom-border-color; + border-radius: 3px; + } + + &::-webkit-scrollbar-track { + background-color: var(--bg-slate-400); + } + + .no-section-options { + margin-bottom: 8px; + } + + .select-group { + margin-bottom: 16px; + border-radius: 4px; + overflow: hidden; + + .group-label { + font-weight: 500; + padding: 4px 12px; + font-size: 13px; + color: var(--bg-vanilla-400); + background-color: var(--bg-slate-400); + border-bottom: 1px solid $custom-border-color; + border-top: 1px solid $custom-border-color; + position: relative; + z-index: 1; + margin-bottom: 4px; + } + } + + .option-item { + padding: 8px 12px; + cursor: pointer; + display: flex; + align-items: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--bg-vanilla-400); + + &:hover { + background-color: var(--bg-slate-400); + } + + &.selected { + background-color: rgba(78, 116, 248, 0.15); + font-weight: 500; + } + + &.active { + background-color: rgba(78, 116, 248, 0.15); + border-color: var(--bg-robin-500); + } + + .option-content { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + + .option-label-text { + margin-bottom: 0; + } + + .option-badge { + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; + background-color: $custom-border-color; + color: var(--bg-vanilla-400); + margin-left: 8px; + } + } + } + + .loading-container { + display: flex; + justify-content: center; + padding: 12px; + } +} + +.navigation-footer { + display: flex; + align-items: center; + padding: 8px 12px; + border-top: 1px solid var(--bg-slate-400); + position: sticky; + bottom: 0; + background-color: var(--bg-ink-400); + z-index: 1; + + .navigation-icons { + display: flex; + margin-right: 8px; + color: var(--bg-vanilla-400); + } + + .navigation-text { + color: var(--bg-vanilla-400); + font-size: 12px; + } + + .navigation-error { + .navigation-text, + .navigation-icons { + color: var(--bg-cherry-500) !important; + } + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 4px; + } + + .navigation-loading { + display: flex; + align-items: center; + gap: 8px; + + .navigation-text, + .navigation-icons { + color: var(--bg-robin-600) !important; + } + } + + .navigate { + display: flex; + align-items: center; + padding-right: 12px; + gap: 6px; + + .icons { + width: 14px; + height: 14px; + flex-shrink: 0; + border-radius: 2.286px; + border-top: 1.143px solid var(--bg-ink-200); + border-right: 1.143px solid var(--bg-ink-200); + border-bottom: 2.286px solid var(--bg-ink-200); + border-left: 1.143px solid var(--bg-ink-200); + background: var(--Ink-400, var(--bg-ink-400)); + } + } +} + +// Custom dropdown styles for multi-select +.custom-multiselect-dropdown { + padding: 8px 0 0 0; + max-height: 500px; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + border-radius: 4px; + border: 1px solid var(--bg-slate-400); + width: 100%; + background-color: var(--bg-ink-400); + + .select-all-option, + .custom-value-option { + padding: 8px 12px; + border-bottom: 1px solid $custom-border-color; + margin-bottom: 8px; + background-color: var(--bg-slate-400); + position: sticky; + top: 0; + z-index: 1; + } + + .selected-values-section { + padding: 0 0 8px 0; + border-bottom: 1px solid $custom-border-color; + margin-bottom: 8px; + + .selected-option { + padding: 4px 12px; + } + } + + .select-group { + margin-bottom: 12px; + overflow: hidden; + + .group-label { + font-weight: 500; + padding: 4px 12px; + font-size: 13px; + color: var(--bg-vanilla-400); + background-color: var(--bg-slate-400); + border-bottom: 1px solid $custom-border-color; + border-top: 1px solid $custom-border-color; + position: relative; + z-index: 1; + } + } + + .option-item { + padding: 8px 12px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--bg-vanilla-400); + + &.active { + background-color: rgba(78, 116, 248, 0.15); + border-color: var(--bg-robin-500); + } + + &:hover { + background-color: var(--bg-slate-400); + } + + &.selected { + background-color: rgba(78, 116, 248, 0.15); + font-weight: 500; + } + + &.all-option { + font-weight: 500; + border-bottom: 1px solid $custom-border-color; + margin-bottom: 8px; + } + + .option-checkbox { + width: 100%; + + > span:not(.ant-checkbox) { + width: 100%; + } + + .option-content { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + + .option-label-text { + margin-bottom: 0; + } + + .option-badge { + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; + background-color: $custom-border-color; + color: var(--bg-vanilla-400); + margin-left: 8px; + } + } + + .only-btn { + display: none; + } + .toggle-btn { + display: none; + } + + .only-btn:hover { + background-color: unset; + } + .toggle-btn:hover { + background-color: unset; + } + + .option-content:hover { + .only-btn { + display: flex; + align-items: center; + justify-content: center; + height: 21px; + } + .toggle-btn { + display: none; + } + + .option-badge { + display: none; + } + } + } + + .option-checkbox:hover { + .toggle-btn { + display: flex; + align-items: center; + justify-content: center; + height: 21px; + } + .option-badge { + display: none; + } + } + } + + .loading-container { + display: flex; + justify-content: center; + padding: 12px; + } + + .empty-message { + padding: 12px; + text-align: center; + color: rgba(192, 193, 195, 0.45); + } + + .status-message { + padding: 8px 12px; + text-align: center; + font-style: italic; + color: rgba(192, 193, 195, 0.65); + border-top: 1px dashed $custom-border-color; + } +} + +// Custom styles for highlight text +.highlight-text { + background-color: rgba(78, 116, 248, 0.2); + padding: 0 1px; + border-radius: 2px; + font-weight: 500; +} + +// Custom option styles for keyboard navigation +.custom-option { + &.focused, + &.ant-select-item-option-active { + background-color: var(--bg-slate-400) !important; + } +} + +// Improve the sticky headers appearance +.custom-select-dropdown-container { + .group-label, + .ant-select-item-group { + position: sticky; + top: 0; + z-index: 2; + background-color: var(--bg-slate-400); + border-bottom: 1px solid $custom-border-color; + padding: 4px 12px; + margin: 0; + width: 100%; // Ensure the header spans full width + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); // Add subtle shadow for separation + } + + // Ensure proper spacing between sections + .select-group { + margin-bottom: 8px; + position: relative; // Create a positioning context + } +} + +// Custom scrollbar styling (shared between components) +@mixin custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(192, 193, 195, 0.3) rgba(29, 33, 45, 0.6); + + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + &::-webkit-scrollbar-track { + background-color: rgba(29, 33, 45, 0.6); + border-radius: 10px; + } + + &::-webkit-scrollbar-thumb { + background-color: rgba(192, 193, 195, 0.3); + border-radius: 10px; + transition: background-color 0.2s ease; + + &:hover { + background-color: rgba(192, 193, 195, 0.5); + } + } +} + +// Subtle nested scrollbar styling +@mixin nested-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(192, 193, 195, 0.2) rgba(29, 33, 45, 0.6); + + &::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + &::-webkit-scrollbar-track { + background-color: rgba(29, 33, 45, 0.6); + border-radius: 10px; + } + + &::-webkit-scrollbar-thumb { + background-color: rgba(192, 193, 195, 0.2); + border-radius: 10px; + + &:hover { + background-color: rgba(192, 193, 195, 0.3); + } + } +} + +// Apply to main dropdown containers +.custom-select-dropdown, +.custom-multiselect-dropdown { + @include custom-scrollbar; + + // Main content area + .options-container { + @include custom-scrollbar; + padding-right: 2px; // Add slight padding to prevent content touching scrollbar + } + + // Non-sectioned options + .no-section-options { + @include nested-scrollbar; + margin-right: 2px; + padding-right: 2px; + } +} + +// Apply to dropdown container wrappers +.custom-select-dropdown-container, +.custom-multiselect-dropdown-container { + @include custom-scrollbar; + + // Add subtle shadow inside to indicate scrollable area + &.has-overflow { + box-shadow: inset 0 -10px 10px -10px rgba(0, 0, 0, 0.2); + } +} + +// Light Mode Overrides +.lightMode { + .custom-select { + .ant-select-selector { + background-color: var(--bg-vanilla-100); + border-color: #e9e9e9; + } + + .ant-select-selection-placeholder { + color: rgba(0, 0, 0, 0.45); + } + + .ant-select-clear { + background-color: var(--bg-vanilla-100); + color: rgba(0, 0, 0, 0.45); + } + + &.ant-select-focused { + .ant-select-selector { + border-color: #1890ff; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); + } + } + } + + .custom-multiselect { + .ant-select-selector { + background-color: var(--bg-vanilla-100); + border-color: #e9e9e9; + + &::-webkit-scrollbar-thumb { + background-color: #ccc; + } + + &::-webkit-scrollbar-track { + background-color: #f0f0f0; + } + } + + .ant-select-selection-placeholder { + color: rgba(0, 0, 0, 0.45); + } + + .ant-select-selection-item { + background-color: #f5f5f5; + border: 1px solid #e8e8e8; + color: rgba(0, 0, 0, 0.85); + + .ant-select-selection-item-content { + color: rgba(0, 0, 0, 0.85); + } + + .ant-select-selection-item-remove { + color: rgba(0, 0, 0, 0.45); + &:hover { + color: rgba(0, 0, 0, 0.85); + } + } + + &-active { + border-color: var(--bg-robin-500) !important; + background-color: var(--bg-vanilla-300) !important; + } + + &-selected { + border-color: #1890ff !important; + background-color: var(--bg-vanilla-300) !important; + } + } + } + + .custom-select-dropdown-container, + .custom-multiselect-dropdown-container { + background-color: var(--bg-vanilla-100); + border: 1px solid #f0f0f0; + box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12), + 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05); + + .empty-message { + color: rgba(0, 0, 0, 0.45); + } + + .ant-select-item { + color: rgba(0, 0, 0, 0.85); + + &-option-active { + background-color: #f5f5f5 !important; + } + + &-option-selected { + background-color: var(--bg-vanilla-300) !important; + } + } + } + + .custom-select-dropdown, + .custom-multiselect-dropdown { + border: 1px solid #f0f0f0; + background-color: var(--bg-vanilla-100); + + &::-webkit-scrollbar-thumb { + background-color: #ccc; + } + + &::-webkit-scrollbar-track { + background-color: #f0f0f0; + } + + .select-group { + .group-label { + color: rgba(0, 0, 0, 0.85); + background-color: #fafafa; + border-bottom: 1px solid #f0f0f0; + border-top: 1px solid #f0f0f0; + } + } + + .option-item { + color: rgba(0, 0, 0, 0.85); + + &:hover { + background-color: #f5f5f5; + } + + &.selected { + background-color: var(--bg-vanilla-300); + } + + &.active { + background-color: var(--bg-vanilla-300); + border-color: #91d5ff; + } + + .option-content { + .option-badge { + background-color: #f0f0f0; + color: #666; + } + } + } + } + + .navigation-footer { + border-top: 1px solid #f0f0f0; + background-color: var(--bg-vanilla-100); + + .navigation-icons { + color: rgba(0, 0, 0, 0.45); + } + + .navigation-text { + color: rgba(0, 0, 0, 0.45); + } + + .navigate { + .icons { + border-top: 1.143px solid var(--bg-ink-200); + border-right: 1.143px solid var(--bg-ink-200); + border-bottom: 2.286px solid var(--bg-ink-200); + border-left: 1.143px solid var(--bg-ink-200); + background: var(--bg-vanilla-300); + } + } + } + + .custom-multiselect-dropdown { + .select-all-option, + .custom-value-option { + border-bottom: 1px solid #f0f0f0; + background-color: #fafafa; + } + + .selected-values-section { + border-bottom: 1px solid #f0f0f0; + } + + .status-message { + color: rgba(0, 0, 0, 0.65); + border-top: 1px dashed #f0f0f0; + } + + .option-item { + &.all-option { + border-bottom: 1px solid #f0f0f0; + } + } + } + + .highlight-text { + background-color: rgba(24, 144, 255, 0.2); + } + + .custom-option { + &.focused, + &.ant-select-item-option-active { + background-color: #f5f5f5 !important; + } + } + + .custom-select-dropdown-container { + .group-label, + .ant-select-item-group { + background-color: #f5f0f0; + border-bottom: 1px solid #e8e8e8; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + } + } + + // Light mode scrollbar overrides + .custom-select-dropdown, + .custom-multiselect-dropdown, + .custom-select-dropdown-container, + .custom-multiselect-dropdown-container { + scrollbar-color: rgba(0, 0, 0, 0.2) rgba(0, 0, 0, 0.05); + + &::-webkit-scrollbar-track { + background-color: rgba(0, 0, 0, 0.05); + } + + &::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.2); + + &:hover { + background-color: rgba(0, 0, 0, 0.3); + } + } + } +} diff --git a/frontend/src/components/NewSelect/types.ts b/frontend/src/components/NewSelect/types.ts new file mode 100644 index 0000000000..49369c89af --- /dev/null +++ b/frontend/src/components/NewSelect/types.ts @@ -0,0 +1,60 @@ +import { SelectProps } from 'antd'; + +export interface OptionData { + label: string; + value?: string; + disabled?: boolean; + className?: string; + style?: React.CSSProperties; + options?: OptionData[]; + type?: 'defined' | 'custom' | 'regex'; +} + +export interface CustomSelectProps extends Omit { + placeholder?: string; + className?: string; + loading?: boolean; + onSearch?: (value: string) => void; + options?: OptionData[]; + defaultActiveFirstOption?: boolean; + noDataMessage?: string; + onClear?: () => void; + getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement; + dropdownRender?: (menu: React.ReactElement) => React.ReactElement; + highlightSearch?: boolean; + placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; + popupMatchSelectWidth?: boolean; + errorMessage?: string; + allowClear?: SelectProps['allowClear']; + onRetry?: () => void; +} + +export interface CustomTagProps { + label: React.ReactNode; + value: string; + closable: boolean; + onClose: () => void; +} + +export interface CustomMultiSelectProps + extends Omit, 'options'> { + placeholder?: string; + className?: string; + loading?: boolean; + onSearch?: (value: string) => void; + options?: OptionData[]; + defaultActiveFirstOption?: boolean; + dropdownMatchSelectWidth?: boolean | number; + noDataMessage?: string; + onClear?: () => void; + enableAllSelection?: boolean; + getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement; + dropdownRender?: (menu: React.ReactElement) => React.ReactElement; + highlightSearch?: boolean; + errorMessage?: string; + popupClassName?: string; + placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; + maxTagCount?: number; + allowClear?: SelectProps['allowClear']; + onRetry?: () => void; +} diff --git a/frontend/src/components/NewSelect/utils.ts b/frontend/src/components/NewSelect/utils.ts new file mode 100644 index 0000000000..30579cd53f --- /dev/null +++ b/frontend/src/components/NewSelect/utils.ts @@ -0,0 +1,135 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +import { OptionData } from './types'; + +export const SPACEKEY = ' '; + +export const prioritizeOrAddOptionForSingleSelect = ( + options: OptionData[], + value: string, + label?: string, +): OptionData[] => { + let foundOption: OptionData | null = null; + + // Separate the found option and the rest + const filteredOptions = options + .map((option) => { + if ('options' in option && Array.isArray(option.options)) { + // Filter out the value from nested options + const remainingSubOptions = option.options.filter( + (subOption) => subOption.value !== value, + ); + const extractedOption = option.options.find( + (subOption) => subOption.value === value, + ); + + if (extractedOption) foundOption = extractedOption; + + // Keep the group if it still has remaining options + return remainingSubOptions.length > 0 + ? { ...option, options: remainingSubOptions } + : null; + } + + // Check top-level options + if (option.value === value) { + foundOption = option; + return null; // Remove it from the list + } + + return option; + }) + .filter(Boolean) as OptionData[]; // Remove null values + + // If not found, create a new option + if (!foundOption) { + foundOption = { value, label: label ?? value }; + } + + // Add the found/new option at the top + return [foundOption, ...filteredOptions]; +}; + +export const prioritizeOrAddOptionForMultiSelect = ( + options: OptionData[], + values: string[], // Only supports multiple values (string[]) + labels?: Record, +): OptionData[] => { + const foundOptions: OptionData[] = []; + + // Separate the found options and the rest + const filteredOptions = options + .map((option) => { + if ('options' in option && Array.isArray(option.options)) { + // Filter out selected values from nested options + const remainingSubOptions = option.options.filter( + (subOption) => subOption.value && !values.includes(subOption.value), + ); + const extractedOptions = option.options.filter( + (subOption) => subOption.value && values.includes(subOption.value), + ); + + if (extractedOptions.length > 0) { + foundOptions.push(...extractedOptions); + } + + // Keep the group if it still has remaining options + return remainingSubOptions.length > 0 + ? { ...option, options: remainingSubOptions } + : null; + } + + // Check top-level options + if (option.value && values.includes(option.value)) { + foundOptions.push(option); + return null; // Remove it from the list + } + + return option; + }) + .filter(Boolean) as OptionData[]; // Remove null values + + // Find missing values that were not present in the original options and create new ones + const missingValues = values.filter( + (value) => !foundOptions.some((opt) => opt.value === value), + ); + + const newOptions = missingValues.map((value) => ({ + value, + label: labels?.[value] ?? value, // Use provided label or default to value + })); + + // Add found & new options to the top + return [...newOptions, ...foundOptions, ...filteredOptions]; +}; + +/** + * Filters options based on search text + */ +export const filterOptionsBySearch = ( + options: OptionData[], + searchText: string, +): OptionData[] => { + if (!searchText.trim()) return options; + + const lowerSearchText = searchText.toLowerCase(); + + return options + .map((option) => { + if ('options' in option && Array.isArray(option.options)) { + // Filter nested options + const filteredSubOptions = option.options.filter((subOption) => + subOption.label.toLowerCase().includes(lowerSearchText), + ); + + return filteredSubOptions.length > 0 + ? { ...option, options: filteredSubOptions } + : undefined; + } + + // Filter top-level options + return option.label.toLowerCase().includes(lowerSearchText) + ? option + : undefined; + }) + .filter(Boolean) as OptionData[]; +};