diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md index 580561b86..1374f33ae 100644 --- a/packages/design-system/CHANGELOG.md +++ b/packages/design-system/CHANGELOG.md @@ -19,7 +19,6 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - **FormFeedback:** remove `span: 9` from styles. - **FormGroup:** controlId is now optional. - **FormLabel:** extend Props Interface to accept `htmlFor` prop. -- **SelectMultiple:** NEW multiple selector for occasions when you can choose more than one option. - **FormSelectMultiple:** The new multiple selector is added to the "FormGroup" components. - **DropdownButton:** extend Props Interface to accept `ariaLabel` prop. - **DropdownButtonItem:** extend Props Interface to accept `as` prop. @@ -31,14 +30,13 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - **FormInput:** remove withinMultipleFieldsGroup prop. - **FormLabel:** remove withinMultipleFieldsGroup prop extend interface to accept ColProps. - **FormSelect:** remove withinMultipleFieldsGroup prop. -- **FormSelectMultiple:** remove withinMultipleFieldsGroup prop. - **FormText:** remove withinMultipleFieldsGroup prop. - **FormTextArea:** remove withinMultipleFieldsGroup prop. - **FormInputGroup:** remove hasVisibleLabel prop. - **FormInputGroupText:** refactor type. -- **Select Multiple:** add is-invalid classname if isInvalid prop is true. - **Card:** NEW card element to show header and body. - **ProgressBar:** NEW progress bar element to show progress. +- **SelectAdvanced:** NEW ehanced select to search across options, and perform both single and multiple selections. - **NavbarDropdownItem:** Now accepts `as` prop and takes `as` Element props. - **FormInputGroup:** extend Props Interface to accept `hasValidation` prop to properly show rounded corners in an with validation - **Button:** extend Props Interface to accept `size` prop. diff --git a/packages/design-system/src/lib/components/form/form-group/FormGroup.tsx b/packages/design-system/src/lib/components/form/form-group/FormGroup.tsx index 5248609b0..a4261d913 100644 --- a/packages/design-system/src/lib/components/form/form-group/FormGroup.tsx +++ b/packages/design-system/src/lib/components/form/form-group/FormGroup.tsx @@ -9,7 +9,7 @@ import { Col, ColProps } from '../../grid/Col' import { Row } from '../../grid/Row' import { FormCheckbox } from './form-element/FormCheckbox' import { FormFeedback } from './form-element/FormFeedback' -import { FormSelectMultiple } from './form-element/FormSelectMultiple' +import { FormSelectAdvanced } from './form-element/FormSelectAdvanced' interface FormGroupProps extends ColProps { as?: typeof Col | typeof Row @@ -27,7 +27,7 @@ function FormGroup({ as = Row, controlId, children, ...props }: PropsWithChildre FormGroup.Label = FormLabel FormGroup.Input = FormInput FormGroup.Select = FormSelect -FormGroup.SelectMultiple = FormSelectMultiple +FormGroup.SelectAdvanced = FormSelectAdvanced FormGroup.TextArea = FormTextArea FormGroup.Text = FormText FormGroup.Checkbox = FormCheckbox diff --git a/packages/design-system/src/lib/components/form/form-group/form-element/FormSelectAdvanced.tsx b/packages/design-system/src/lib/components/form/form-group/form-element/FormSelectAdvanced.tsx new file mode 100644 index 000000000..7dd1068be --- /dev/null +++ b/packages/design-system/src/lib/components/form/form-group/form-element/FormSelectAdvanced.tsx @@ -0,0 +1,15 @@ +import { PropsWithChildren, forwardRef } from 'react' +import { SelectAdvanced, SelectAdvancedProps } from '../../../select-advanced/SelectAdvanced' + +export type FormSelectAdvancedProps = SelectAdvancedProps & { + inputButtonId: string + isInvalid?: boolean +} + +export const FormSelectAdvanced = forwardRef( + ({ ...props }: PropsWithChildren, ref) => { + return } {...props} /> + } +) + +FormSelectAdvanced.displayName = 'FormSelectAdvanced' diff --git a/packages/design-system/src/lib/components/form/form-group/form-element/FormSelectMultiple.tsx b/packages/design-system/src/lib/components/form/form-group/form-element/FormSelectMultiple.tsx deleted file mode 100644 index b8296f6b2..000000000 --- a/packages/design-system/src/lib/components/form/form-group/form-element/FormSelectMultiple.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { PropsWithChildren, forwardRef } from 'react' -import { SelectMultiple, SelectMultipleProps } from '../../../select-multiple/SelectMultiple' - -export interface FormSelectMultipleProps extends SelectMultipleProps { - inputButtonId: string - isInvalid?: boolean -} - -export const FormSelectMultiple = forwardRef( - ({ ...props }: PropsWithChildren, ref) => { - return } {...props} /> - } -) - -FormSelectMultiple.displayName = 'FormSelectMultiple' diff --git a/packages/design-system/src/lib/components/select-multiple/SelectMultiple.module.scss b/packages/design-system/src/lib/components/select-advanced/SelectAdvanced.module.scss similarity index 84% rename from packages/design-system/src/lib/components/select-multiple/SelectMultiple.module.scss rename to packages/design-system/src/lib/components/select-advanced/SelectAdvanced.module.scss index 75913ce33..957e076d4 100644 --- a/packages/design-system/src/lib/components/select-multiple/SelectMultiple.module.scss +++ b/packages/design-system/src/lib/components/select-advanced/SelectAdvanced.module.scss @@ -3,11 +3,11 @@ @import 'src/lib/assets/styles/design-tokens/typography.module'; :root { - --select-multiple-menu-max-height: 300px; + --select-advanced-menu-max-height: 300px; --toggle-padding: 6px 36px 6px 12px; } -.select-multiple-toggle { +.select-advanced-toggle { position: relative; display: grid; background-color: #fff; @@ -77,7 +77,7 @@ padding: var(--toggle-padding); pointer-events: none; - .selected-options-container { + .multiple-selected-options-container { display: inline-flex; flex: 1; flex-wrap: wrap; @@ -105,13 +105,17 @@ } } } + + .single-selected-option { + margin: 0; + } } } -.select-multiple-menu { +.select-advanced-menu { width: 100%; max-width: 100%; - max-height: var(--select-multiple-menu-max-height); + max-height: var(--select-advanced-menu-max-height); padding-top: 0; overflow-x: hidden; overflow-y: auto; @@ -149,11 +153,27 @@ } &__checkbox-input { - padding-block: 0.25rem; + display: flex; + align-items: center; + padding-left: 0; + + input[type='checkbox'] { + float: unset; + margin-top: 0; + margin-left: 0; + } label { width: 100%; + padding-left: 0.5rem; + padding-block: 0.25rem; } } } + + .option-item-not-multiple { + margin-bottom: 0.125rem; + cursor: pointer; + transition: background-color 0.1s ease-in-out; + } } diff --git a/packages/design-system/src/lib/components/select-advanced/SelectAdvanced.tsx b/packages/design-system/src/lib/components/select-advanced/SelectAdvanced.tsx new file mode 100644 index 000000000..641430b5d --- /dev/null +++ b/packages/design-system/src/lib/components/select-advanced/SelectAdvanced.tsx @@ -0,0 +1,223 @@ +import { useEffect, useMemo, useId, useReducer, forwardRef, ForwardedRef, useCallback } from 'react' +import { Dropdown as DropdownBS } from 'react-bootstrap' +import { + selectAdvancedReducer, + selectOption, + removeOption, + selectAllOptions, + deselectAllOptions, + searchOptions, + getSelectAdvancedInitialState, + updateOptions +} from './selectAdvancedReducer' +import { SelectAdvancedToggle } from './SelectAdvancedToggle' +import { SelectAdvancedMenu } from './SelectAdvancedMenu' +import { areArraysEqual, debounce } from './utils' +import { useIsFirstRender } from './useIsFirstRender' + +export const DEFAULT_LOCALES = { + select: 'Select...' +} + +export const SELECT_MENU_SEARCH_DEBOUNCE_TIME = 400 + +export type SelectAdvancedProps = + | { + isMultiple?: false + options: string[] + onChange?: (selected: string) => void + defaultValue?: string + isSearchable?: boolean + isDisabled?: boolean + isInvalid?: boolean + inputButtonId?: string + locales?: { + select?: string + } + } + | { + isMultiple: true + options: string[] + onChange?: (selected: string[]) => void + defaultValue?: string[] + isSearchable?: boolean + isDisabled?: boolean + isInvalid?: boolean + inputButtonId?: string + locales?: { + select?: string + } + } + +export const SelectAdvanced = forwardRef( + ( + { + options: propsOption, + onChange, + defaultValue, + isMultiple, + isSearchable = true, + isDisabled = false, + isInvalid = false, + inputButtonId, + locales + }: SelectAdvancedProps, + ref: ForwardedRef + ) => { + const dynamicInitialOptions = useMemo(() => { + return isMultiple ? propsOption : [locales?.select ?? DEFAULT_LOCALES.select, ...propsOption] + }, [isMultiple, propsOption, locales]) + + const [{ selected, filteredOptions, searchValue, options }, dispatch] = useReducer( + selectAdvancedReducer, + getSelectAdvancedInitialState( + Boolean(isMultiple), + dynamicInitialOptions, + locales?.select ?? DEFAULT_LOCALES.select, + defaultValue + ) + ) + + const isFirstRender = useIsFirstRender() + const menuId = useId() + + const callOnChage = useCallback( + (newSelected: string | string[]): void => { + if (!onChange) return + //@ts-expect-error - types differs + onChange(newSelected) + }, + [onChange] + ) + + useEffect(() => { + const optionsRemainTheSame = areArraysEqual(dynamicInitialOptions, options) + + // If the options remain the same, do nothing + if (optionsRemainTheSame) return + + const selectedOptionsThatAreNotInNewOptions = isMultiple + ? (selected as string[]).filter((option) => !dynamicInitialOptions.includes(option)) + : [] + + // If there are selected options that are not in the new options, remove them + if (isMultiple && selectedOptionsThatAreNotInNewOptions.length > 0) { + selectedOptionsThatAreNotInNewOptions.forEach((option) => dispatch(removeOption(option))) + + const newSelected = (selected as string[]).filter((option) => + dynamicInitialOptions.includes(option) + ) + + callOnChage(newSelected) + } + + // If the selected option is not in the new options replace it with the default empty value + if ( + !isMultiple && + selected !== '' && + !dynamicInitialOptions.some((option) => option === (selected as string)) + ) { + dispatch(selectOption('')) + callOnChage('') + } + dispatch(updateOptions(dynamicInitialOptions)) + }, [dynamicInitialOptions, options, selected, isFirstRender, dispatch, callOnChage, isMultiple]) + + const handleSearch = debounce((e: React.ChangeEvent): void => { + const { value } = e.target + dispatch(searchOptions(value)) + }, SELECT_MENU_SEARCH_DEBOUNCE_TIME) + + // ONLY FOR MULTIPLE SELECT 👇 + const handleCheck = (e: React.ChangeEvent): void => { + const { value, checked } = e.target + + if (checked) { + const newSelected = [...(selected as string[]), value] + callOnChage(newSelected) + + dispatch(selectOption(value)) + } else { + const newSelected = (selected as string[]).filter((option) => option !== value) + callOnChage(newSelected) + + dispatch(removeOption(value)) + } + } + + // ONLY FOR SINGLE SELECT 👇 + const handleClickOption = (option: string): void => { + if ((selected as string) === option) { + return + } + callOnChage(option) + + dispatch(selectOption(option)) + } + + // ONLY FOR MULTIPLE SELECT 👇 + const handleRemoveSelectedOption = (option: string): void => { + const newSelected = (selected as string[]).filter((selected) => selected !== option) + callOnChage(newSelected) + + dispatch(removeOption(option)) + } + + // ONLY FOR MULTIPLE SELECT 👇 + const handleToggleAllOptions = (e: React.ChangeEvent): void => { + if (e.target.checked) { + const newSelected = + filteredOptions.length > 0 + ? Array.from(new Set([...(selected as string[]), ...filteredOptions])) + : options + + callOnChage(newSelected) + + dispatch(selectAllOptions()) + } else { + const newSelected = + filteredOptions.length > 0 + ? (selected as string[]).filter((option) => !filteredOptions.includes(option)) + : [] + + callOnChage(newSelected) + + dispatch(deselectAllOptions()) + } + } + + return ( + + + + + ) + } +) + +SelectAdvanced.displayName = 'SelectAdvanced' diff --git a/packages/design-system/src/lib/components/select-advanced/SelectAdvancedMenu.tsx b/packages/design-system/src/lib/components/select-advanced/SelectAdvancedMenu.tsx new file mode 100644 index 000000000..574fa7dea --- /dev/null +++ b/packages/design-system/src/lib/components/select-advanced/SelectAdvancedMenu.tsx @@ -0,0 +1,140 @@ +import { useId } from 'react' +import { Dropdown as DropdownBS, Form as FormBS } from 'react-bootstrap' +import styles from './SelectAdvanced.module.scss' + +interface SelectAdvancedMenuProps { + isMultiple: boolean + options: string[] + selected: string | string[] + filteredOptions: string[] + searchValue: string + handleToggleAllOptions: (e: React.ChangeEvent) => void + handleSearch: (e: React.ChangeEvent) => void + handleCheck: (e: React.ChangeEvent) => void + handleClickOption: (option: string) => void + isSearchable: boolean + menuId: string + selectWord: string +} + +export const SelectAdvancedMenu = ({ + isMultiple, + options, + selected, + filteredOptions, + searchValue, + handleToggleAllOptions, + handleSearch, + handleCheck, + handleClickOption, + isSearchable, + menuId, + selectWord +}: SelectAdvancedMenuProps) => { + const searchInputControlID = useId() + const toggleAllControlID = useId() + const optionLabelId = useId() + + const menuOptions = filteredOptions.length > 0 ? filteredOptions : options + + const noOptionsFound = searchValue !== '' && filteredOptions.length === 0 + + const allOptionsShownAreSelected = !noOptionsFound + ? filteredOptions.length > 0 + ? filteredOptions.every((option) => selected.includes(option)) + : options.every((option) => selected.includes(option)) + : false + + return ( + [0, 0] + } + } + ] + }}> + {(isMultiple || isSearchable) && ( + + {isMultiple && ( + + )} + + {isSearchable && ( + + )} + {isMultiple && !isSearchable && ( +

+ {selected.length} selected +

+ )} +
+ )} + + {!noOptionsFound && + menuOptions.map((option) => { + if (!isMultiple) { + return ( + handleClickOption(option === selectWord ? '' : option)} + active={option !== selectWord ? selected === option : selected === ''} + key={option}> + {option} + + ) + } + + return ( + + + + ) + })} + + {noOptionsFound && ( + + No options found + + )} +
+ ) +} diff --git a/packages/design-system/src/lib/components/select-advanced/SelectAdvancedToggle.tsx b/packages/design-system/src/lib/components/select-advanced/SelectAdvancedToggle.tsx new file mode 100644 index 000000000..80876268d --- /dev/null +++ b/packages/design-system/src/lib/components/select-advanced/SelectAdvancedToggle.tsx @@ -0,0 +1,88 @@ +import { ForwardedRef, forwardRef } from 'react' +import { Dropdown as DropdownBS, Button as ButtonBS } from 'react-bootstrap' +import { X as CloseIcon } from 'react-bootstrap-icons' +import styles from './SelectAdvanced.module.scss' + +type SelectAdvancedToggleProps = { + isMultiple: boolean + selected: string | string[] + handleRemoveSelectedOption: (option: string) => void + isInvalid?: boolean + isDisabled?: boolean + inputButtonId?: string + menuId: string + selectWord: string +} + +export const SelectAdvancedToggle = forwardRef( + ( + { + isMultiple, + selected, + handleRemoveSelectedOption, + isInvalid, + isDisabled, + inputButtonId, + menuId, + selectWord + }: SelectAdvancedToggleProps, + ref: ForwardedRef + ) => { + return ( +
+ +
+ {selected.length > 0 ? ( +
+ {isMultiple ? ( + (selected as string[]).map((selectedValue) => ( +
e.stopPropagation()} + key={`selected-option-${selectedValue}`}> + {selectedValue} + handleRemoveSelectedOption(selectedValue)}> + + +
+ )) + ) : ( +

+ {selected} +

+ )} +
+ ) : ( + selectWord + )} +
+
+ ) + } +) + +SelectAdvancedToggle.displayName = 'SelectAdvancedToggle' diff --git a/packages/design-system/src/lib/components/select-advanced/selectAdvancedReducer.ts b/packages/design-system/src/lib/components/select-advanced/selectAdvancedReducer.ts new file mode 100644 index 000000000..6f95f7dc9 --- /dev/null +++ b/packages/design-system/src/lib/components/select-advanced/selectAdvancedReducer.ts @@ -0,0 +1,154 @@ +export const getSelectAdvancedInitialState = ( + isMultiple: boolean, + initialOptions: string[], + selectWord: string, + defaultValue?: string | string[] +): SelectAdvancedState => ({ + options: initialOptions, + selected: isMultiple + ? (defaultValue as string[] | undefined) ?? [] + : (defaultValue as string | undefined) ?? '', + filteredOptions: [], + searchValue: '', + isMultiple, + selectWord +}) + +export interface SelectAdvancedState { + isMultiple: boolean + selected: string | string[] + options: string[] + filteredOptions: string[] + searchValue: string + selectWord: string +} + +type SelectAdvancedActions = + | { + type: 'SELECT_OPTION' + payload: string + } + | { + type: 'REMOVE_OPTION' + payload: string + } + | { + type: 'SELECT_ALL_OPTIONS' + } + | { + type: 'DESELECT_ALL_OPTIONS' + } + | { + type: 'SEARCH' + payload: string + } + | { + type: 'UPDATE_OPTIONS' + payload: string[] + } + +export const selectAdvancedReducer = ( + state: SelectAdvancedState, + action: SelectAdvancedActions +) => { + switch (action.type) { + case 'SELECT_OPTION': + if (state.isMultiple) { + return { + ...state, + selected: [...state.selected, action.payload] + } + } else { + return { + ...state, + selected: action.payload + } + } + // ONLY FOR MULTIPLE SELECT 👇 + case 'REMOVE_OPTION': + return { + ...state, + selected: (state.selected as string[]).filter((option) => option !== action.payload) + } + + // ONLY FOR MULTIPLE SELECT 👇 + case 'SELECT_ALL_OPTIONS': + return { + ...state, + selected: + state.filteredOptions.length > 0 + ? Array.from(new Set([...(state.selected as string[]), ...state.filteredOptions])) + : state.options + } + // ONLY FOR MULTIPLE SELECT 👇 + case 'DESELECT_ALL_OPTIONS': + return { + ...state, + selected: + state.filteredOptions.length > 0 + ? (state.selected as string[]).filter( + (option) => !state.filteredOptions.includes(option) + ) + : [] + } + case 'SEARCH': + return { + ...state, + filteredOptions: filterOptions(state, action), + searchValue: action.payload + } + case 'UPDATE_OPTIONS': + return { + ...state, + options: action.payload + } + default: + return state + } +} + +const filterOptions = ( + state: SelectAdvancedState, + action: { + type: 'SEARCH' + payload: string + } +) => { + if (action.payload === '') return [] + + const optionsWithoutSelectWord = state.options.filter((option) => option !== state.selectWord) + + return optionsWithoutSelectWord.filter((option) => + option.toLowerCase().includes(action.payload.toLowerCase()) + ) +} + +export const selectOption = /* istanbul ignore next */ (option: string): SelectAdvancedActions => ({ + type: 'SELECT_OPTION', + payload: option +}) + +export const removeOption = /* istanbul ignore next */ (option: string): SelectAdvancedActions => ({ + type: 'REMOVE_OPTION', + payload: option +}) + +export const selectAllOptions = /* istanbul ignore next */ (): SelectAdvancedActions => ({ + type: 'SELECT_ALL_OPTIONS' +}) + +export const deselectAllOptions = /* istanbul ignore next */ (): SelectAdvancedActions => ({ + type: 'DESELECT_ALL_OPTIONS' +}) + +export const searchOptions = /* istanbul ignore next */ (value: string): SelectAdvancedActions => ({ + type: 'SEARCH', + payload: value +}) + +export const updateOptions = /* istanbul ignore next */ ( + options: string[] +): SelectAdvancedActions => ({ + type: 'UPDATE_OPTIONS', + payload: options +}) diff --git a/packages/design-system/src/lib/components/select-multiple/useIsFirstRender.ts b/packages/design-system/src/lib/components/select-advanced/useIsFirstRender.ts similarity index 100% rename from packages/design-system/src/lib/components/select-multiple/useIsFirstRender.ts rename to packages/design-system/src/lib/components/select-advanced/useIsFirstRender.ts diff --git a/packages/design-system/src/lib/components/select-multiple/utils.ts b/packages/design-system/src/lib/components/select-advanced/utils.ts similarity index 51% rename from packages/design-system/src/lib/components/select-multiple/utils.ts rename to packages/design-system/src/lib/components/select-advanced/utils.ts index 0c0ad99cb..709120843 100644 --- a/packages/design-system/src/lib/components/select-multiple/utils.ts +++ b/packages/design-system/src/lib/components/select-advanced/utils.ts @@ -11,3 +11,24 @@ export function debounce unknown>( timeoutId = setTimeout(() => fn(...args), delay) } } + +export function areArraysEqual(arr1: string[], arr2: string[]): boolean { + if (arr1.length === 0 && arr2.length === 0) { + return true + } + + if (arr1.length !== arr2.length) { + return false + } + + const sortedArr1 = arr1.slice().sort() + const sortedArr2 = arr2.slice().sort() + + for (let i = 0; i < sortedArr1.length; i++) { + if (sortedArr1[i] !== sortedArr2[i]) { + return false + } + } + + return true +} diff --git a/packages/design-system/src/lib/components/select-multiple/SelectMultiple.tsx b/packages/design-system/src/lib/components/select-multiple/SelectMultiple.tsx deleted file mode 100644 index bf1c021ae..000000000 --- a/packages/design-system/src/lib/components/select-multiple/SelectMultiple.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { ForwardedRef, forwardRef, useEffect, useId, useReducer } from 'react' -import { Dropdown as DropdownBS } from 'react-bootstrap' -import { - selectMultipleInitialState, - selectMultipleReducer, - selectOption, - removeOption, - selectAllOptions, - deselectAllOptions, - searchOptions -} from './selectMultipleReducer' -import { SelectMultipleToggle } from './SelectMultipleToggle' -import { SelectMultipleMenu } from './SelectMultipleMenu' -import { debounce } from './utils' -import { useIsFirstRender } from './useIsFirstRender' - -export const SELECT_MENU_SEARCH_DEBOUNCE_TIME = 400 - -export interface SelectMultipleProps { - options: string[] - onChange?: (selectedOptions: string[]) => void - defaultValue?: string[] - isSearchable?: boolean - isDisabled?: boolean - isInvalid?: boolean - inputButtonId?: string -} - -export const SelectMultiple = forwardRef( - ( - { - options, - onChange, - defaultValue, - isSearchable = true, - isDisabled = false, - isInvalid = false, - inputButtonId - }: SelectMultipleProps, - ref: ForwardedRef - ) => { - const [{ selectedOptions, filteredOptions, searchValue }, dispatch] = useReducer( - selectMultipleReducer, - { - ...selectMultipleInitialState, - options: options, - selectedOptions: defaultValue || [] - } - ) - const isFirstRender = useIsFirstRender() - const menuId = useId() - - useEffect(() => { - if (!isFirstRender && onChange) { - onChange(selectedOptions) - } - }, [selectedOptions]) - - const handleSearch = debounce((e: React.ChangeEvent): void => { - const { value } = e.target - dispatch(searchOptions(value)) - }, SELECT_MENU_SEARCH_DEBOUNCE_TIME) - - const handleCheck = (e: React.ChangeEvent): void => { - const { value, checked } = e.target - - if (checked) { - dispatch(selectOption(value)) - } else { - dispatch(removeOption(value)) - } - } - - const handleRemoveSelectedOption = (option: string): void => dispatch(removeOption(option)) - - const handleToggleAllOptions = (e: React.ChangeEvent): void => { - if (e.target.checked) { - dispatch(selectAllOptions()) - } else { - dispatch(deselectAllOptions()) - } - } - - return ( - - - - - ) - } -) - -SelectMultiple.displayName = 'SelectMultiple' diff --git a/packages/design-system/src/lib/components/select-multiple/SelectMultipleMenu.tsx b/packages/design-system/src/lib/components/select-multiple/SelectMultipleMenu.tsx deleted file mode 100644 index 30912e7bd..000000000 --- a/packages/design-system/src/lib/components/select-multiple/SelectMultipleMenu.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useId } from 'react' -import { Dropdown as DropdownBS, Form as FormBS } from 'react-bootstrap' -import styles from './SelectMultiple.module.scss' - -interface SelectMultipleMenuProps { - options: string[] - selectedOptions: string[] - filteredOptions: string[] - searchValue: string - handleToggleAllOptions: (e: React.ChangeEvent) => void - handleSearch: (e: React.ChangeEvent) => void - handleCheck: (e: React.ChangeEvent) => void - isSearchable: boolean - menuId: string -} - -export const SelectMultipleMenu = ({ - options, - selectedOptions, - filteredOptions, - searchValue, - handleToggleAllOptions, - handleSearch, - handleCheck, - isSearchable, - menuId -}: SelectMultipleMenuProps) => { - const searchInputControlID = useId() - const toggleAllControlID = useId() - - const menuOptions = filteredOptions.length > 0 ? filteredOptions : options - - const noOptionsFound = searchValue !== '' && filteredOptions.length === 0 - - const allOptionsShownAreSelected = !noOptionsFound - ? filteredOptions.length > 0 - ? filteredOptions.every((option) => selectedOptions.includes(option)) - : options.every((option) => selectedOptions.includes(option)) - : false - - return ( - [0, 0] - } - } - ] - }}> - - - {isSearchable ? ( - - ) : ( -

{selectedOptions.length} selected

- )} -
- - {!noOptionsFound && - menuOptions.map((option) => ( - - - - ))} - - {noOptionsFound && ( - - No options found - - )} -
- ) -} diff --git a/packages/design-system/src/lib/components/select-multiple/SelectMultipleToggle.tsx b/packages/design-system/src/lib/components/select-multiple/SelectMultipleToggle.tsx deleted file mode 100644 index 6ad9e0f4b..000000000 --- a/packages/design-system/src/lib/components/select-multiple/SelectMultipleToggle.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { ForwardedRef, forwardRef } from 'react' -import { Dropdown as DropdownBS, Button as ButtonBS } from 'react-bootstrap' -import { X as CloseIcon } from 'react-bootstrap-icons' -import styles from './SelectMultiple.module.scss' - -interface SelectMultipleToggleProps { - selectedOptions: string[] - handleRemoveSelectedOption: (option: string) => void - isInvalid?: boolean - isDisabled?: boolean - inputButtonId?: string - menuId: string -} - -export const SelectMultipleToggle = forwardRef( - ( - { - selectedOptions, - handleRemoveSelectedOption, - isInvalid, - isDisabled, - inputButtonId, - menuId - }: SelectMultipleToggleProps, - ref: ForwardedRef - ) => { - return ( -
- -
- {selectedOptions.length > 0 ? ( -
- {selectedOptions.map((selectedOption) => ( -
e.stopPropagation()} - key={`selected-option-${selectedOption}`}> - {selectedOption} - handleRemoveSelectedOption(selectedOption)}> - - -
- ))} -
- ) : ( - 'Select...' - )} -
-
- ) - } -) - -SelectMultipleToggle.displayName = 'SelectMultipleToggle' diff --git a/packages/design-system/src/lib/components/select-multiple/selectMultipleReducer.ts b/packages/design-system/src/lib/components/select-multiple/selectMultipleReducer.ts deleted file mode 100644 index 107e4c6fb..000000000 --- a/packages/design-system/src/lib/components/select-multiple/selectMultipleReducer.ts +++ /dev/null @@ -1,104 +0,0 @@ -export const selectMultipleInitialState: SelectMultipleState = { - options: [], - selectedOptions: [], - filteredOptions: [], - searchValue: '' -} - -interface SelectMultipleState { - options: string[] - selectedOptions: string[] - filteredOptions: string[] - searchValue: string -} - -type SelectMultipleActions = - | { - type: 'SELECT_OPTION' - payload: string - } - | { - type: 'REMOVE_OPTION' - payload: string - } - | { - type: 'SELECT_ALL_OPTIONS' - } - | { - type: 'DESELECT_ALL_OPTIONS' - } - | { - type: 'SEARCH' - payload: string - } - -export const selectMultipleReducer = ( - state: SelectMultipleState, - action: SelectMultipleActions -) => { - switch (action.type) { - case 'SELECT_OPTION': - return { - ...state, - selectedOptions: [...state.selectedOptions, action.payload] - } - case 'REMOVE_OPTION': - return { - ...state, - selectedOptions: state.selectedOptions.filter((option) => option !== action.payload) - } - case 'SELECT_ALL_OPTIONS': - return { - ...state, - selectedOptions: - state.filteredOptions.length > 0 - ? Array.from(new Set([...state.selectedOptions, ...state.filteredOptions])) - : state.options - } - case 'DESELECT_ALL_OPTIONS': - return { - ...state, - selectedOptions: - state.filteredOptions.length > 0 - ? state.selectedOptions.filter((option) => !state.filteredOptions.includes(option)) - : [] - } - - case 'SEARCH': - return { - ...state, - filteredOptions: - action.payload !== '' - ? state.options.filter((option) => - option.toLowerCase().includes(action.payload.toLowerCase()) - ) - : [], - searchValue: action.payload - } - default: - return state - } -} - -export const selectOption = /* istanbul ignore next */ (option: string): SelectMultipleActions => ({ - type: 'SELECT_OPTION', - payload: option -}) - -export const removeOption = /* istanbul ignore next */ (option: string): SelectMultipleActions => ({ - type: 'REMOVE_OPTION', - payload: option -}) - -export const selectAllOptions = /* istanbul ignore next */ (): SelectMultipleActions => ({ - type: 'SELECT_ALL_OPTIONS' -}) - -export const deselectAllOptions = /* istanbul ignore next */ (): SelectMultipleActions => ({ - type: 'DESELECT_ALL_OPTIONS' -}) - -export const searchOptions = /* istanbul ignore next */ (value: string): SelectMultipleActions => ({ - type: 'SEARCH', - payload: value -}) diff --git a/packages/design-system/src/lib/index.ts b/packages/design-system/src/lib/index.ts index 28240a8af..6c08768a9 100644 --- a/packages/design-system/src/lib/index.ts +++ b/packages/design-system/src/lib/index.ts @@ -24,7 +24,7 @@ export { IconName } from './components/icon/IconName' export { Tooltip } from './components/tooltip/Tooltip' export { Pagination } from './components/pagination/Pagination' export { RequiredInputSymbol } from './components/form/required-input-symbol/RequiredInputSymbol' -export { SelectMultiple } from './components/select-multiple/SelectMultiple' +export { SelectAdvanced } from './components/select-advanced/SelectAdvanced' export { Card } from './components/card/Card' export { ProgressBar } from './components/progress-bar/ProgressBar' export { Stack } from './components/stack/Stack' diff --git a/packages/design-system/src/lib/stories/form/Form.stories.tsx b/packages/design-system/src/lib/stories/form/Form.stories.tsx index 813f23581..d4ff0b9ac 100644 --- a/packages/design-system/src/lib/stories/form/Form.stories.tsx +++ b/packages/design-system/src/lib/stories/form/Form.stories.tsx @@ -212,17 +212,36 @@ export const Select: Story = { ) } -export const SelectMultiple: Story = { +export const SelectAdvanced: Story = { render: () => (
- + Hobbies - + + +
+ ) +} + +export const SelectAdvancedMultiple: Story = { + render: () => ( +
+ + + Hobbies + + + diff --git a/packages/design-system/src/lib/stories/select-advanced/SelectAdvanced.stories.tsx b/packages/design-system/src/lib/stories/select-advanced/SelectAdvanced.stories.tsx new file mode 100644 index 000000000..e71f30593 --- /dev/null +++ b/packages/design-system/src/lib/stories/select-advanced/SelectAdvanced.stories.tsx @@ -0,0 +1,94 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { SelectAdvanced } from '../../components/select-advanced/SelectAdvanced' + +import { CanvasFixedHeight } from '../CanvasFixedHeight' + +/** + * ## Description + * The select advanced component is an element that allows users to select one or multiple options from a list of items. + * They can also search for items in the list, select all items and clear the selection (last two on multiple selection mode). + */ +const meta: Meta = { + title: 'Select Advanced', + component: SelectAdvanced, + tags: ['autodocs'] +} + +export default meta +type Story = StoryObj + +const exampleOptions = ['Option 1', 'Option 2', 'Option 3', 'Option 4'] + +export const Single: Story = { + render: () => ( + + + + ) +} +export const Multiple: Story = { + render: () => ( + + + + ) +} + +export const SingleWithDefaultValue: Story = { + render: () => ( + + + + ) +} +export const MultipleWithDefaultValues: Story = { + render: () => ( + + + + ) +} + +export const SingleNotSearchable: Story = { + render: () => ( + + + + ) +} + +export const MultipleNotSearchable: Story = { + render: () => ( + + + + ) +} + +export const Invalid: Story = { + render: () => ( + + + + ) +} + +export const Disabled: Story = { + render: () => ( + + + + ) +} + +export const WithDifferentSelectWord: Story = { + render: () => ( + + + + ) +} diff --git a/packages/design-system/src/lib/stories/select-multiple/SelectMultiple.stories.tsx b/packages/design-system/src/lib/stories/select-multiple/SelectMultiple.stories.tsx deleted file mode 100644 index 94d5c3120..000000000 --- a/packages/design-system/src/lib/stories/select-multiple/SelectMultiple.stories.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' -import { SelectMultiple } from '../../components/select-multiple/SelectMultiple' - -/** - * ## Description - * The select multiple component is a user interface element that allows users to select multiple options from a list of items. - * They can also search for items in the list, select all items, and clear the selection. - */ -const meta: Meta = { - title: 'Select Multiple', - component: SelectMultiple, - tags: ['autodocs'] -} - -export default meta -type Story = StoryObj - -const exampleOptions = [ - 'Agricultural Sciences', - 'Arts and Humanities', - 'Astronomy and Astrophysics', - 'Business and Management', - 'Chemistry', - 'Computer and Information Science', - 'Earth and Environmental Sciences', - 'Engineering', - 'Law', - 'Mathematical Sciences', - 'Medicine, Health and Life Sciences', - 'Physics', - 'Social Sciences', - 'Other' -] - -export const Default: Story = { - render: () => -} -export const WithDefaultValues: Story = { - render: () => ( - - ) -} - -export const NotSearchable: Story = { - render: () => -} - -export const Invalid: Story = { - render: () => -} - -export const Disabled: Story = { - render: () => -} diff --git a/packages/design-system/tests/component/form/form-group/FormGroupSelectMultiple.spec.tsx b/packages/design-system/tests/component/form/form-group/FormGroupSelectAdvanced.spec.tsx similarity index 71% rename from packages/design-system/tests/component/form/form-group/FormGroupSelectMultiple.spec.tsx rename to packages/design-system/tests/component/form/form-group/FormGroupSelectAdvanced.spec.tsx index e54c5d6a0..8710ccfb9 100644 --- a/packages/design-system/tests/component/form/form-group/FormGroupSelectMultiple.spec.tsx +++ b/packages/design-system/tests/component/form/form-group/FormGroupSelectAdvanced.spec.tsx @@ -1,11 +1,11 @@ import { FormGroup } from '../../../../src/lib/components/form/form-group/FormGroup' -describe('FormSelectMultiple', () => { +describe('FormSelectAdvanced', () => { it('renders without error', () => { cy.mount( - - Hobbies - + Hobbies + @@ -17,9 +17,9 @@ describe('FormSelectMultiple', () => { it('should focus on the input button when the label is clicked', () => { cy.mount( - - Hobbies - + Hobbies + diff --git a/packages/design-system/tests/component/select-advanced/SelectAdvanced.spec.tsx b/packages/design-system/tests/component/select-advanced/SelectAdvanced.spec.tsx new file mode 100644 index 000000000..45623e59d --- /dev/null +++ b/packages/design-system/tests/component/select-advanced/SelectAdvanced.spec.tsx @@ -0,0 +1,1179 @@ +import { useState } from 'react' +import { + SelectAdvanced, + SELECT_MENU_SEARCH_DEBOUNCE_TIME +} from '../../../src/lib/components/select-advanced/SelectAdvanced' + +function toggleOptionsMenu() { + cy.findByLabelText('Toggle options menu').click() +} + +describe('SelectAdvanced', () => { + describe('should render correctly', () => { + it('on single selection', () => { + cy.mount( + + ) + + cy.findByText('Select...').should('exist') + }) + it('on multiple selection', () => { + cy.mount( + + ) + cy.findByText('Select...').should('exist') + }) + }) + + describe('should render correct options', () => { + it('on single selection', () => { + cy.mount( + + ) + + toggleOptionsMenu() + + cy.findByText('Reading').should('exist') + cy.findByText('Reading').should('exist') + cy.findByText('Swimming').should('exist') + cy.findByText('Running').should('exist') + cy.findByText('Cycling').should('exist') + cy.findByText('Cooking').should('exist') + cy.findByText('Gardening').should('exist') + + // 6 Options + 1 Select... option + cy.findAllByRole('option').should('have.length', 7) + }) + it('on multiple selection', () => { + cy.mount( + + ) + toggleOptionsMenu() + + cy.findByText('Reading').should('exist') + cy.findByText('Swimming').should('exist') + cy.findByText('Running').should('exist') + cy.findByText('Cycling').should('exist') + cy.findByText('Cooking').should('exist') + cy.findByText('Gardening').should('exist') + + cy.findAllByRole('option').should('have.length', 6) + }) + }) + + describe('should render with default values', () => { + it('on single selection', () => { + cy.mount( + + ) + + cy.findByText('Running').should('exist') + }) + it('on multiple selection', () => { + cy.mount( + + ) + cy.findByText('Reading').should('exist') + cy.findByText('Running').should('exist') + }) + }) + + describe('should call onChange when an option is selected', () => { + it('on single selection', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByText('Reading').click() + + cy.get('@onChange').should('have.been.calledOnce') + cy.get('@onChange').should('have.been.calledWith', 'Reading') + }) + + it('on multiple selection', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + toggleOptionsMenu() + cy.findByLabelText('Reading').click() + + cy.get('@onChange').should('have.been.calledOnce') + cy.get('@onChange').should('have.been.calledWith', ['Reading']) + }) + }) + + describe('should call onChange when an option is deselected', () => { + // Only on multiple selection as in single selection mode we can't deselect an option just change it to another + it('on multiple selection', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + toggleOptionsMenu() + cy.findByLabelText('Reading').click() + cy.get('@onChange').should('have.been.calledWith', ['Reading']) + + cy.findByLabelText('Reading').click() + cy.get('@onChange').should('have.been.calledWith', []) + + cy.get('@onChange').should('have.been.calledTwice') + }) + it('on multiple selection with default value', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + toggleOptionsMenu() + cy.findByLabelText('Reading').click() + + cy.get('@onChange').should('have.been.calledOnce') + cy.get('@onChange').should('have.been.calledWith', ['Running']) + }) + }) + + describe('should not call onChange when passing defaultValues and rendering for first time', () => { + it('on single selection', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + cy.get('@onChange').should('not.have.been.called') + }) + it('on multiple selection', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + cy.get('@onChange').should('not.have.been.called') + }) + }) + + describe('should call onChange correct times after multiple types of selections', () => { + it('on single selection', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + toggleOptionsMenu() + + // 6 different individual selections + cy.findByText('Reading').click() + + toggleOptionsMenu() + cy.findByText('Swimming').click() + + toggleOptionsMenu() + cy.findByText('Running').click() + + toggleOptionsMenu() + cy.findByText('Cycling').click() + + toggleOptionsMenu() + cy.findByText('Reading').click() + + toggleOptionsMenu() + cy.findByText('Swimming').click() + + cy.get('@onChange').should('have.callCount', 6) + cy.get('@onChange').should('have.been.calledWith', 'Reading') + cy.get('@onChange').should('have.been.calledWith', 'Swimming') + cy.get('@onChange').should('have.been.calledWith', 'Running') + cy.get('@onChange').should('have.been.calledWith', 'Cycling') + }) + + it('on multiple selection', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + toggleOptionsMenu() + + // 4 individual selections + cy.findByLabelText('Reading').click() + cy.findByLabelText('Swimming').click() + cy.findByLabelText('Running').click() + cy.findByLabelText('Cycling').click() + + // Select all options + cy.findByLabelText('Toggle all options').click() + + // Now deselect 1 option individually + cy.findByLabelText('Cooking').click() + + cy.get('@onChange').should('have.callCount', 6) + cy.get('@onChange').should('have.been.calledWith', ['Reading']) + cy.get('@onChange').should('have.been.calledWith', ['Reading', 'Swimming']) + cy.get('@onChange').should('have.been.calledWith', ['Reading', 'Swimming', 'Running']) + cy.get('@onChange').should('have.been.calledWith', [ + 'Reading', + 'Swimming', + 'Running', + 'Cycling' + ]) + // When selecting all + cy.get('@onChange').should('have.been.calledWith', [ + 'Reading', + 'Swimming', + 'Running', + 'Cycling', + 'Cooking', + 'Gardening' + ]) + // When deselecting Cooking + cy.get('@onChange').should('have.been.calledWith', [ + 'Reading', + 'Swimming', + 'Running', + 'Cycling', + 'Gardening' + ]) + }) + }) + + describe('should select an option and be shown as selected both in the menu as well as in the selected options', () => { + it('on single selection', () => { + cy.mount( + + ) + toggleOptionsMenu() + cy.findByText('Reading').click() + cy.findAllByText('Reading').spread((_selectedItem, selectedListOption) => { + const element = cy.get(selectedListOption) + element.should('have.class', 'active') + }) + + cy.findByTestId('toggle-inner-content') + .should('exist') + .within(() => { + cy.findByText('Reading').should('exist') + }) + }) + it('on multiple selection', () => { + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByLabelText('Reading').click() + cy.findByLabelText('Reading').should('be.checked') + + cy.findByTestId('toggle-inner-content') + .should('exist') + .within(() => { + cy.findByText('Reading').should('exist') + }) + }) + }) + + it('should change the selected option when selecting another option in single selection mode', () => { + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByText('Reading').click() + cy.findAllByText('Reading') + .should('have.length', 2) + .spread((_selectedItem, selectedListOption) => { + const element = cy.get(selectedListOption) + element.should('have.class', 'active') + element.should('have.attr', 'aria-selected', 'true') + }) + + toggleOptionsMenu() + + cy.findByText('Swimming').click() + + cy.findAllByText('Swimming') + .should('have.length', 2) + .spread((_selectedItem, selectedListOption) => { + const element = cy.get(selectedListOption) + element.should('have.class', 'active') + element.should('have.attr', 'aria-selected', 'true') + }) + cy.findByText('Reading') + .should('not.have.class', 'active') + .should('have.attr', 'aria-selected', 'false') + }) + + it('should not change the selected option when clicking on the selected option in single selection mode', () => { + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByText('Reading').click() + + toggleOptionsMenu() + + cy.findAllByText('Reading') + .should('have.length', 2) + .spread((_selectedItem, selectedListOption) => { + const element = cy.get(selectedListOption) + element.should('have.class', 'active') + }) + + cy.findAllByText('Reading').spread((_selectedItem, selectedListOption) => { + const element = cy.get(selectedListOption) + element.click() + }) + + toggleOptionsMenu() + + cy.findAllByText('Reading') + .should('have.length', 2) + .spread((_selectedItem, selectedListOption) => { + const element = cy.get(selectedListOption) + element.should('have.class', 'active') + }) + }) + + it('should remove a selected option by clicking on an X icon of an item in the selected options in multiple selection mode', () => { + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByLabelText('Reading').click() + + cy.findByTestId('toggle-inner-content').within(() => { + cy.findByLabelText('Remove Reading option').click() + cy.findByText('Reading').should('not.exist') + }) + }) + + it('selects all options on multiple selection mode', () => { + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByLabelText('Toggle all options').click() + + cy.findByLabelText('Reading').should('be.checked') + cy.findByLabelText('Swimming').should('be.checked') + cy.findByLabelText('Running').should('be.checked') + cy.findByLabelText('Cycling').should('be.checked') + cy.findByLabelText('Cooking').should('be.checked') + cy.findByLabelText('Gardening').should('be.checked') + + cy.findByTestId('toggle-inner-content') + .should('exist') + .within(() => { + cy.findByText('Reading').should('exist') + cy.findByText('Swimming').should('exist') + cy.findByText('Running').should('exist') + cy.findByText('Cycling').should('exist') + cy.findByText('Cooking').should('exist') + cy.findByText('Gardening').should('exist') + }) + cy.findByText('Select...').should('not.exist') + }) + + it('deselects all options on multiple selection mode', () => { + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByLabelText('Toggle all options').click() + cy.findByLabelText('Toggle all options').click() + + cy.findByLabelText('Reading').should('not.be.checked') + cy.findByLabelText('Swimming').should('not.be.checked') + cy.findByLabelText('Running').should('not.be.checked') + cy.findByLabelText('Cycling').should('not.be.checked') + cy.findByLabelText('Cooking').should('not.be.checked') + cy.findByLabelText('Gardening').should('not.be.checked') + + cy.findByTestId('toggle-inner-content') + .should('exist') + .within(() => { + cy.findByText('Reading').should('not.exist') + cy.findByText('Swimming').should('not.exist') + cy.findByText('Running').should('not.exist') + cy.findByText('Cycling').should('not.exist') + cy.findByText('Cooking').should('not.exist') + cy.findByText('Gardening').should('not.exist') + }) + cy.findByText('Select...').should('exist') + }) + + it('should select all filtered options on multiple selection mode', () => { + cy.mount( + + ) + cy.clock() + + toggleOptionsMenu() + cy.findByPlaceholderText('Search...').type('Read') + + cy.tick(SELECT_MENU_SEARCH_DEBOUNCE_TIME) + + cy.findByLabelText('Reading').should('exist') + cy.findByLabelText('Swimming').should('not.exist') + cy.findByLabelText('Running').should('not.exist') + cy.findByLabelText('Cycling').should('not.exist') + cy.findByLabelText('Cooking').should('not.exist') + cy.findByLabelText('Gardening').should('not.exist') + + cy.findByLabelText('Toggle all options').click() + + cy.findByTestId('toggle-inner-content') + .should('exist') + .within(() => { + cy.findByText('Reading').should('exist') + cy.findByText('Swimming').should('not.exist') + cy.findByText('Running').should('not.exist') + cy.findByText('Cycling').should('not.exist') + cy.findByText('Cooking').should('not.exist') + cy.findByText('Gardening').should('not.exist') + }) + cy.findByText('Select...').should('not.exist') + }) + + it('should unselect only filtered options on multiple selection mode', () => { + cy.mount( + + ) + cy.clock() + + toggleOptionsMenu() + cy.findByLabelText('Toggle all options').click() + + cy.findByLabelText('Reading').should('be.checked') + cy.findByLabelText('Swimming').should('be.checked') + cy.findByLabelText('Running').should('be.checked') + cy.findByLabelText('Cycling').should('be.checked') + cy.findByLabelText('Cooking').should('be.checked') + cy.findByLabelText('Gardening').should('be.checked') + + cy.findByPlaceholderText('Search...').type('Read') + + cy.tick(SELECT_MENU_SEARCH_DEBOUNCE_TIME) + + cy.findByLabelText('Reading').should('exist') + cy.findByLabelText('Swimming').should('not.exist') + cy.findByLabelText('Running').should('not.exist') + cy.findByLabelText('Cycling').should('not.exist') + cy.findByLabelText('Cooking').should('not.exist') + cy.findByLabelText('Gardening').should('not.exist') + + cy.findByLabelText('Toggle all options').click() + + cy.findByTestId('toggle-inner-content') + .should('exist') + .within(() => { + cy.findByText('Reading').should('not.exist') + cy.findByText('Swimming').should('exist') + cy.findByText('Running').should('exist') + cy.findByText('Cycling').should('exist') + cy.findByText('Cooking').should('exist') + cy.findByText('Gardening').should('exist') + }) + }) + + describe('should show correct filtered options when searching for a value', () => { + it('on single selection', () => { + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByPlaceholderText('Search...').type('Read') + + cy.findByText('Reading').should('exist') + cy.findByText('Swimming').should('not.exist') + cy.findByText('Running').should('not.exist') + cy.findByText('Cycling').should('not.exist') + cy.findByText('Cooking').should('not.exist') + cy.findByText('Gardening').should('not.exist') + }) + it('on multiple selection', () => { + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByPlaceholderText('Search...').type('Read') + + cy.findByLabelText('Reading').should('exist') + cy.findByLabelText('Swimming').should('not.exist') + cy.findByLabelText('Running').should('not.exist') + cy.findByLabelText('Cycling').should('not.exist') + cy.findByLabelText('Cooking').should('not.exist') + cy.findByLabelText('Gardening').should('not.exist') + }) + }) + + it('should debounce the search input correctly', () => { + cy.mount( + + ) + + cy.clock() + + toggleOptionsMenu() + cy.findByPlaceholderText('Search...').type('Read') + cy.findByLabelText('Swimming').should('exist') + + cy.tick(SELECT_MENU_SEARCH_DEBOUNCE_TIME) + + cy.findByLabelText('Reading').should('exist') + cy.findByLabelText('Swimming').should('not.exist') + + cy.get('input[aria-label="Search for an option"]').clear() + + cy.tick(SELECT_MENU_SEARCH_DEBOUNCE_TIME) + + cy.findByLabelText('Reading').should('exist') + cy.findByLabelText('Swimming').should('exist') + cy.findByLabelText('Running').should('exist') + cy.findByLabelText('Cycling').should('exist') + cy.findByLabelText('Cooking').should('exist') + cy.findByLabelText('Gardening').should('exist') + + cy.clock().then((clock) => clock.restore()) + }) + + it('should show count of selected options when isSearchable is false on multiple selection mode', () => { + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByLabelText('Reading').click() + cy.findByLabelText('Swimming').click() + + cy.findByText('2 selected').should('exist') + }) + + describe('should not show search when isSearchable is false', () => { + it('on single selection', () => { + cy.mount( + + ) + + toggleOptionsMenu() + + cy.findByTestId('select-advanced-searchable-input').should('not.exist') + }) + + it('on multiple selection', () => { + cy.mount( + + ) + + toggleOptionsMenu() + + cy.findByTestId('select-advanced-searchable-input').should('not.exist') + }) + }) + + it('should not show Selection Count on single selection when isSearchable is false', () => { + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByText('Reading').click() + + cy.findByTestId('select-advanced-searchable-input').should('not.exist') + cy.findByTestId('select-advanced-selected-count').should('not.exist') + }) + + it('should show Selection Count on multiple selection when isSearchable is false', () => { + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByLabelText('Reading').click() + cy.findByLabelText('Swimming').click() + + cy.findByTestId('select-advanced-searchable-input').should('not.exist') + cy.findByTestId('select-advanced-selected-count') + .should('exist') + .should('have.text', '2 selected') + }) + + describe('should show No Options Found when search does not match any option', () => { + it('on single selection', () => { + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByPlaceholderText('Search...').type('Yoga') + + cy.findByText('No options found').should('exist') + }) + it('on multiple selection', () => { + cy.mount( + + ) + toggleOptionsMenu() + cy.findByPlaceholderText('Search...').type('Yoga') + + cy.findByText('No options found').should('exist') + cy.findByLabelText('Toggle all options').should('be.disabled') + }) + }) + + describe('should be disabled when isDisabled is true', () => { + it('on single selection', () => { + cy.mount( + + ) + + cy.findByLabelText('Toggle options menu').should('be.disabled') + }) + it('on multiple selection', () => { + cy.mount( + + ) + + cy.findByLabelText('Toggle options menu').should('be.disabled') + }) + }) + + describe('should be invalid when isInvalid is true', () => { + it('on single selection', () => { + cy.mount( + + ) + + cy.findByLabelText('Toggle options menu').should('have.attr', 'aria-invalid', 'true') + }) + it('on multiple selection', () => { + cy.mount( + + ) + + cy.findByLabelText('Toggle options menu').should('have.attr', 'aria-invalid', 'true') + }) + }) + + describe('when options props changes', () => { + const ADD_OPTION_BUTTON_TEST_ID = 'add-option-button' + const CHANGE_ALL_OPTIONS_BUTTON_TEST_ID = 'change-all-options-button' + const CHANGE_ALL_ONE_KEEP_OPTION_BUTTON_TEST_ID = 'chage-all-one-keep-option-button' + const ALL_NEW_OPTIONS = ['Foo', 'Bar', 'Ron', 'Hermione'] + const NEW_OPTIONS_BUT_ONE_REMAIN_THE_SAME = ['Foo', 'Reading', 'Ron', 'Hermione', 'Harry'] + + const SelectWithButtonsToChangeOptions = ({ + isMultiple, + withDefaultValues, + onChange + }: { + isMultiple: boolean + withDefaultValues: boolean + onChange?: (value: string | string[]) => void + }) => { + const [availableOptions, setAvailableOptions] = useState(['Reading', 'Swimming', 'Running']) + + return ( + <> + + + + + {/* @ts-expect-error type boolean is not assignable to type true */} + + + ) + } + describe('adds one option correctly', () => { + it('on single selection', () => { + cy.mount() + + toggleOptionsMenu() + cy.findByText('Reading').should('exist') + cy.findByText('Swimming').should('exist') + cy.findByText('Running').should('exist') + cy.findByText('Gardening').should('not.exist') + + cy.findByTestId(ADD_OPTION_BUTTON_TEST_ID).click() + + cy.findByText('Reading').should('exist') + cy.findByText('Swimming').should('exist') + cy.findByText('Running').should('exist') + cy.findByText('Gardening').should('exist') + }) + + it('on single selection with default value', () => { + cy.mount() + + toggleOptionsMenu() + cy.findAllByText('Reading').should('exist').should('have.length', 2) + cy.findByText('Swimming').should('exist') + cy.findByText('Running').should('exist') + cy.findByText('Gardening').should('not.exist') + + cy.findByTestId(ADD_OPTION_BUTTON_TEST_ID).click() + + cy.findAllByText('Reading').should('exist').should('have.length', 2) + cy.findByText('Swimming').should('exist') + cy.findByText('Running').should('exist') + cy.findByText('Gardening').should('exist') + }) + + it('on multiple selection', () => { + cy.mount() + + toggleOptionsMenu() + cy.findByLabelText('Reading').should('exist') + cy.findByLabelText('Swimming').should('exist') + cy.findByLabelText('Running').should('exist') + cy.findByLabelText('Gardening').should('not.exist') + + cy.findByTestId(ADD_OPTION_BUTTON_TEST_ID).click() + + cy.findByLabelText('Reading').should('exist') + cy.findByLabelText('Swimming').should('exist') + cy.findByLabelText('Running').should('exist') + cy.findByLabelText('Gardening').should('exist') + }) + it('on multiple selection with default values', () => { + cy.mount() + + toggleOptionsMenu() + cy.findByLabelText('Reading').should('exist') + cy.findByLabelText('Swimming').should('exist') + cy.findByLabelText('Running').should('exist') + cy.findByLabelText('Gardening').should('not.exist') + + cy.findByTestId(ADD_OPTION_BUTTON_TEST_ID).click() + + cy.findByLabelText('Reading').should('exist') + cy.findByLabelText('Swimming').should('exist') + cy.findByLabelText('Running').should('exist') + cy.findByLabelText('Gardening').should('exist') + }) + }) + + describe('adds completely new options correctly', () => { + it('on single selection', () => { + cy.mount() + + toggleOptionsMenu() + cy.findByText('Reading').should('exist') + cy.findByText('Swimming').should('exist') + cy.findByText('Running').should('exist') + + cy.findByTestId(CHANGE_ALL_OPTIONS_BUTTON_TEST_ID).click() + + cy.findByText('Reading').should('not.exist') + cy.findByText('Swimming').should('not.exist') + cy.findByText('Running').should('not.exist') + + ALL_NEW_OPTIONS.forEach((option) => { + cy.findByText(option).should('exist') + }) + }) + + it('on single selection with default value', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + toggleOptionsMenu() + cy.findAllByText('Reading').should('exist').should('have.length', 2) + cy.findByText('Swimming').should('exist') + cy.findByText('Running').should('exist') + + cy.findByTestId(CHANGE_ALL_OPTIONS_BUTTON_TEST_ID).click() + + cy.findByText('Reading').should('not.exist') + cy.findByText('Swimming').should('not.exist') + cy.findByText('Running').should('not.exist') + + ALL_NEW_OPTIONS.forEach((option) => { + cy.findByText(option).should('exist') + }) + + cy.get('@onChange').should('have.been.calledOnce') + }) + + it('on multiple selection', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByLabelText('Reading').should('exist') + cy.findByLabelText('Reading').should('exist') + cy.findByLabelText('Running').should('exist') + + cy.findByTestId(CHANGE_ALL_OPTIONS_BUTTON_TEST_ID).click() + + cy.findByText('Reading').should('not.exist') + cy.findByText('Swimming').should('not.exist') + cy.findByText('Running').should('not.exist') + + ALL_NEW_OPTIONS.forEach((option) => { + cy.findByText(option).should('exist') + }) + + cy.get('@onChange').should('have.been.not.called') + }) + + it('on multiple selection with default values', () => { + const onChange = cy.stub().as('onChange') + cy.mount( + + ) + + toggleOptionsMenu() + cy.findAllByText('Reading').should('exist').should('have.length', 2) + cy.findAllByText('Reading').should('exist').should('have.length', 2) + cy.findByLabelText('Running').should('exist') + + cy.findByTestId(CHANGE_ALL_OPTIONS_BUTTON_TEST_ID).click() + + cy.findByText('Reading').should('not.exist') + cy.findByText('Swimming').should('not.exist') + cy.findByText('Running').should('not.exist') + + ALL_NEW_OPTIONS.forEach((option) => { + cy.findByText(option).should('exist') + }) + + cy.get('@onChange').should('have.been.calledOnce') + }) + }) + + describe('adds new options but keeps one option the same correctly', () => { + it('on single selection', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByText('Reading').should('exist') + cy.findByText('Swimming').should('exist') + cy.findByText('Running').should('exist') + + cy.findByTestId(CHANGE_ALL_ONE_KEEP_OPTION_BUTTON_TEST_ID).click() + // This option remains + cy.findByText('Reading').should('exist') + cy.findByText('Swimming').should('not.exist') + cy.findByText('Running').should('not.exist') + + NEW_OPTIONS_BUT_ONE_REMAIN_THE_SAME.filter((option) => option !== 'Reading').forEach( + (option) => { + cy.findByText(option).should('exist') + } + ) + + cy.get('@onChange').should('have.been.not.called') + }) + + it('on single selection with default value', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + toggleOptionsMenu() + cy.findAllByText('Reading').should('exist').should('have.length', 2) + cy.findByText('Swimming').should('exist') + cy.findByText('Running').should('exist') + + cy.findByTestId(CHANGE_ALL_ONE_KEEP_OPTION_BUTTON_TEST_ID).click() + + // This option remains + cy.findAllByText('Reading').should('exist').should('have.length', 2) + cy.findByText('Swimming').should('not.exist') + cy.findByText('Running').should('not.exist') + + NEW_OPTIONS_BUT_ONE_REMAIN_THE_SAME.filter((option) => option !== 'Reading').forEach( + (option) => { + cy.findByText(option).should('exist') + } + ) + + cy.get('@onChange').should('have.been.not.called') + }) + + it('on multiple selection', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByText('Reading').should('exist') + cy.findByText('Running').should('exist') + cy.findByText('Swimming').should('exist') + + cy.findByTestId(CHANGE_ALL_ONE_KEEP_OPTION_BUTTON_TEST_ID).click() + + // This option remains + cy.findByText('Reading').should('exist') + cy.findByText('Swimming').should('not.exist') + cy.findByText('Running').should('not.exist') + + NEW_OPTIONS_BUT_ONE_REMAIN_THE_SAME.filter((option) => option !== 'Reading').forEach( + (option) => { + cy.findByLabelText(option).should('exist') + } + ) + + cy.get('@onChange').should('have.been.not.called') + }) + it('on multiple selection with default values', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + toggleOptionsMenu() + cy.findAllByText('Reading').should('exist').should('have.length', 2) + cy.findAllByText('Running').should('exist').should('have.length', 2) + cy.findByLabelText('Swimming').should('exist') + + cy.findByTestId(CHANGE_ALL_ONE_KEEP_OPTION_BUTTON_TEST_ID).click() + + // This option remains + cy.findAllByText('Reading').should('exist').should('have.length', 2) + cy.findByText('Swimming').should('not.exist') + cy.findByText('Running').should('not.exist') + + NEW_OPTIONS_BUT_ONE_REMAIN_THE_SAME.filter((option) => option !== 'Reading').forEach( + (option) => { + cy.findByLabelText(option).should('exist') + } + ) + cy.get('@onChange').should('have.been.calledOnce') + }) + }) + }) + + it('selects the "Select..." option correctly', () => { + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByText('Swimming').click() + + toggleOptionsMenu() + cy.findAllByText('Swimming').should('have.length', 2) + + cy.findByText('Select...').click() + + toggleOptionsMenu() + cy.findAllByText('Select...').should('have.length', 2) + }) + + it('selects the custom Select word option correctly', () => { + cy.mount( + + ) + + toggleOptionsMenu() + cy.findByText('Swimming').click() + + toggleOptionsMenu() + cy.findAllByText('Swimming').should('have.length', 2) + + cy.findByText('Selezionare...').click() + + toggleOptionsMenu() + cy.findAllByText('Selezionare...').should('have.length', 2) + }) +}) diff --git a/packages/design-system/tests/component/select-advanced/selectAdvancedReducer.spec.tsx b/packages/design-system/tests/component/select-advanced/selectAdvancedReducer.spec.tsx new file mode 100644 index 000000000..a405df864 --- /dev/null +++ b/packages/design-system/tests/component/select-advanced/selectAdvancedReducer.spec.tsx @@ -0,0 +1,181 @@ +import { + SelectAdvancedState, + getSelectAdvancedInitialState, + selectAdvancedReducer +} from '../../../src/lib/components/select-advanced/selectAdvancedReducer' + +const options = ['Reading', 'Swimming', 'Running'] +const selectWord = 'Select...' + +describe('selectAdvancedReducer', () => { + it('should return state if bad action type is passed', () => { + const expectedInitialState: SelectAdvancedState = { + options: options, + selected: '', + filteredOptions: [], + searchValue: '', + isMultiple: false, + selectWord + } + + const state = selectAdvancedReducer(getSelectAdvancedInitialState(false, options, selectWord), { + // @ts-expect-error - Testing bad action type + type: 'BAD_ACTION' + }) + + expect(state).deep.equal(expectedInitialState) + }) + + describe('should select an option', () => { + it('on single select mode', () => { + const state = selectAdvancedReducer( + getSelectAdvancedInitialState(false, options, selectWord), + { + type: 'SELECT_OPTION', + payload: 'Reading' + } + ) + + expect(state.selected).to.include('Reading') + }) + it('on multiple select mode', () => { + const state = selectAdvancedReducer( + getSelectAdvancedInitialState(true, options, selectWord), + { + type: 'SELECT_OPTION', + payload: 'Reading' + } + ) + + expect(state.selected).to.include('Reading') + }) + }) + + it('should remove an option', () => { + const state = selectAdvancedReducer( + { ...getSelectAdvancedInitialState(true, options, selectWord), selected: ['Reading'] }, + { + type: 'REMOVE_OPTION', + payload: 'Reading' + } + ) + + expect(state.selected).to.not.include('Reading') + }) + + it('should select all available options when there are no current filtered options', () => { + const state = selectAdvancedReducer( + { + ...getSelectAdvancedInitialState(true, options, selectWord), + options: ['Reading', 'Swimming'] + }, + { + type: 'SELECT_ALL_OPTIONS' + } + ) + + expect(state.selected).to.deep.equal(['Reading', 'Swimming']) + }) + + it('should deselect all available options when there are no current filtered options', () => { + const state = selectAdvancedReducer( + { + ...getSelectAdvancedInitialState(true, options, selectWord), + options: ['Reading', 'Swimming'], + selected: ['Reading', 'Swimming'] + }, + { + type: 'DESELECT_ALL_OPTIONS' + } + ) + + expect(state.selected).to.be.empty + }) + + it('should select all filtered options', () => { + const state = selectAdvancedReducer( + { + ...getSelectAdvancedInitialState(true, options, selectWord), + options: ['Reading', 'Swimming', 'Running'], + filteredOptions: ['Reading', 'Swimming'] + }, + { + type: 'SELECT_ALL_OPTIONS' + } + ) + + expect(state.selected).to.deep.equal(['Reading', 'Swimming']) + }) + + it('should deselect all filtered options', () => { + const state = selectAdvancedReducer( + { + ...getSelectAdvancedInitialState(true, options, selectWord), + options: ['Reading', 'Swimming', 'Running'], + selected: ['Reading', 'Swimming'], + filteredOptions: ['Reading', 'Swimming'] + }, + { + type: 'DESELECT_ALL_OPTIONS' + } + ) + + expect(state.selected).to.be.empty + }) + + it('should add filtered options to selected options when selecting all if filtered options are present', () => { + const state = selectAdvancedReducer( + { + ...getSelectAdvancedInitialState(true, options, selectWord), + options: ['Reading', 'Swimming', 'Running'], + selected: ['Reading', 'Swimming'], + filteredOptions: ['Running'] + }, + { + type: 'SELECT_ALL_OPTIONS' + } + ) + + expect(state.selected).to.deep.equal(['Reading', 'Swimming', 'Running']) + }) + + it('should filter options', () => { + const state = selectAdvancedReducer( + { + ...getSelectAdvancedInitialState(true, options, selectWord), + options: ['Reading', 'Swimming', 'Running'] + }, + { + type: 'SEARCH', + payload: 'read' + } + ) + + expect(state.filteredOptions).to.include('Reading') + expect(state.filteredOptions).to.not.include('Swimming', 'Running') + }) + + it('should reset search value when empty string is passed', () => { + const state = selectAdvancedReducer( + { ...getSelectAdvancedInitialState(true, options, selectWord), searchValue: 'read' }, + { + type: 'SEARCH', + payload: '' + } + ) + + expect(state.searchValue).to.equal('') + }) + + it('should update options', () => { + const state = selectAdvancedReducer( + { ...getSelectAdvancedInitialState(true, options, selectWord), options: ['Reading'] }, + { + type: 'UPDATE_OPTIONS', + payload: ['Reading', 'Swimming'] + } + ) + + expect(state.options).to.deep.equal(['Reading', 'Swimming']) + }) +}) diff --git a/packages/design-system/tests/component/select-advanced/utils.spec.ts b/packages/design-system/tests/component/select-advanced/utils.spec.ts new file mode 100644 index 000000000..3d411f5bc --- /dev/null +++ b/packages/design-system/tests/component/select-advanced/utils.spec.ts @@ -0,0 +1,37 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import { areArraysEqual } from '../../../src/lib/components/select-advanced/utils' + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('utils', () => { + describe('areArraysEqual', () => { + it('should return true if arrays are equal', () => { + const case1 = areArraysEqual([], []) + const case2 = areArraysEqual( + ['Option 1', 'Option 2', 'Option 3'], + ['Option 1', 'Option 2', 'Option 3'] + ) + const case3 = areArraysEqual( + ['Option 1', 'Option 2', 'Option 3'], + ['Option 1', 'Option 3', 'Option 2'] + ) + const case4 = areArraysEqual(['0', '1', '2', '10'], ['10', '1', '0', '2']) + + expect(case1).to.be.equal(true) + expect(case2).to.be.equal(true) + expect(case3).to.be.equal(true) + expect(case4).to.be.equal(true) + }) + it('should return false if arrays are not equal', () => { + const case1 = areArraysEqual(['Option 1'], ['Option 1', 'Option 2']) + const case2 = areArraysEqual( + ['Option 1', 'Option 2', 'Option 3'], + ['Option 1', 'Option 2', 'Option 4'] + ) + expect(case1).to.be.equal(false) + expect(case2).to.be.equal(false) + }) + }) +}) diff --git a/packages/design-system/tests/component/select-multiple/SelectMultiple.spec.tsx b/packages/design-system/tests/component/select-multiple/SelectMultiple.spec.tsx deleted file mode 100644 index e8363f8c3..000000000 --- a/packages/design-system/tests/component/select-multiple/SelectMultiple.spec.tsx +++ /dev/null @@ -1,357 +0,0 @@ -import { - SELECT_MENU_SEARCH_DEBOUNCE_TIME, - SelectMultiple -} from '../../../src/lib/components/select-multiple/SelectMultiple' - -describe('SelectMultiple', () => { - it('should render correctly', () => { - cy.mount( - - ) - - cy.findByText('Select...').should('exist') - }) - - it('should render correct options', () => { - cy.mount( - - ) - - cy.findByLabelText('Toggle options menu').click() - - cy.findByText('Reading').should('exist') - cy.findByText('Swimming').should('exist') - cy.findByText('Running').should('exist') - cy.findByText('Cycling').should('exist') - cy.findByText('Cooking').should('exist') - cy.findByText('Gardening').should('exist') - }) - - it('should render with default values', () => { - cy.mount( - - ) - - cy.findByText('Reading').should('exist') - cy.findByText('Running').should('exist') - }) - - it('should call onChange when an option is selected', () => { - const onChange = cy.stub().as('onChange') - - cy.mount( - - ) - - cy.findByLabelText('Toggle options menu').click() - cy.findByLabelText('Reading').click() - - cy.get('@onChange').should('have.been.calledOnce') - }) - - it('should call onChange when an option is deselected', () => { - const onChange = cy.stub().as('onChange') - - cy.mount( - - ) - - cy.findByLabelText('Toggle options menu').click() - cy.findByLabelText('Reading').click() - - cy.get('@onChange').should('have.been.calledOnce') - }) - - it('should not call onChange when passing defaultValues and rendering for first time', () => { - const onChange = cy.stub().as('onChange') - - cy.mount( - - ) - - cy.get('@onChange').should('not.have.been.called') - }) - - it('should select an option and be shown as selected both in the menu as well as in the selected options', () => { - cy.mount( - - ) - - cy.findByLabelText('Toggle options menu').click() - cy.findByLabelText('Reading').click() - cy.findByLabelText('Reading').should('be.checked') - - cy.findByLabelText('List of selected options') - .should('exist') - .within(() => { - cy.findByText('Reading').should('exist') - }) - }) - - it('should remove a selected option by clicking on an item in the selected options', () => { - cy.mount( - - ) - - cy.findByLabelText('Toggle options menu').click() - cy.findByLabelText('Reading').click() - - cy.findByLabelText('List of selected options').within(() => { - cy.findByLabelText('Remove Reading option').click() - cy.findByText('Reading').should('not.exist') - }) - }) - - it('selects all options', () => { - cy.mount( - - ) - - cy.findByLabelText('Toggle options menu').click() - cy.findByLabelText('Toggle all options').click() - - cy.findByLabelText('Reading').should('be.checked') - cy.findByLabelText('Swimming').should('be.checked') - cy.findByLabelText('Running').should('be.checked') - cy.findByLabelText('Cycling').should('be.checked') - cy.findByLabelText('Cooking').should('be.checked') - cy.findByLabelText('Gardening').should('be.checked') - - cy.findByLabelText('List of selected options') - .should('exist') - .within(() => { - cy.findByText('Reading').should('exist') - cy.findByText('Swimming').should('exist') - cy.findByText('Running').should('exist') - cy.findByText('Cycling').should('exist') - cy.findByText('Cooking').should('exist') - cy.findByText('Gardening').should('exist') - }) - cy.findByText('Select...').should('not.exist') - }) - - it('deselects all options', () => { - cy.mount( - - ) - - cy.findByLabelText('Toggle options menu').click() - cy.findByLabelText('Toggle all options').click() - cy.findByLabelText('Toggle all options').click() - - cy.findByLabelText('Reading').should('not.be.checked') - cy.findByLabelText('Swimming').should('not.be.checked') - cy.findByLabelText('Running').should('not.be.checked') - cy.findByLabelText('Cycling').should('not.be.checked') - cy.findByLabelText('Cooking').should('not.be.checked') - cy.findByLabelText('Gardening').should('not.be.checked') - - cy.findByLabelText('List of selected options').should('not.exist') - cy.findByText('Select...').should('exist') - }) - - it('should select all filtered options', () => { - cy.mount( - - ) - cy.clock() - - cy.findByLabelText('Toggle options menu').click() - cy.findByPlaceholderText('Search...').type('Read') - - cy.tick(SELECT_MENU_SEARCH_DEBOUNCE_TIME) - - cy.findByLabelText('Reading').should('exist') - cy.findByLabelText('Swimming').should('not.exist') - cy.findByLabelText('Running').should('not.exist') - cy.findByLabelText('Cycling').should('not.exist') - cy.findByLabelText('Cooking').should('not.exist') - cy.findByLabelText('Gardening').should('not.exist') - - cy.findByLabelText('Toggle all options').click() - - cy.findByLabelText('List of selected options') - .should('exist') - .within(() => { - cy.findByText('Reading').should('exist') - cy.findByText('Swimming').should('not.exist') - cy.findByText('Running').should('not.exist') - cy.findByText('Cycling').should('not.exist') - cy.findByText('Cooking').should('not.exist') - cy.findByText('Gardening').should('not.exist') - }) - cy.findByText('Select...').should('not.exist') - }) - - it('should unselect only filtered options', () => { - cy.mount( - - ) - cy.clock() - - cy.findByLabelText('Toggle options menu').click() - cy.findByLabelText('Toggle all options').click() - - cy.findByLabelText('Reading').should('be.checked') - cy.findByLabelText('Swimming').should('be.checked') - cy.findByLabelText('Running').should('be.checked') - cy.findByLabelText('Cycling').should('be.checked') - cy.findByLabelText('Cooking').should('be.checked') - cy.findByLabelText('Gardening').should('be.checked') - - cy.findByPlaceholderText('Search...').type('Read') - - cy.tick(SELECT_MENU_SEARCH_DEBOUNCE_TIME) - - cy.findByLabelText('Reading').should('exist') - cy.findByLabelText('Swimming').should('not.exist') - cy.findByLabelText('Running').should('not.exist') - cy.findByLabelText('Cycling').should('not.exist') - cy.findByLabelText('Cooking').should('not.exist') - cy.findByLabelText('Gardening').should('not.exist') - - cy.findByLabelText('Toggle all options').click() - - cy.findByLabelText('List of selected options') - .should('exist') - .within(() => { - cy.findByText('Reading').should('not.exist') - cy.findByText('Swimming').should('exist') - cy.findByText('Running').should('exist') - cy.findByText('Cycling').should('exist') - cy.findByText('Cooking').should('exist') - cy.findByText('Gardening').should('exist') - }) - }) - - it('should show correct filtered options when searching for a value', () => { - cy.mount( - - ) - - cy.findByLabelText('Toggle options menu').click() - cy.findByPlaceholderText('Search...').type('Read') - - cy.findByLabelText('Reading').should('exist') - cy.findByLabelText('Swimming').should('not.exist') - cy.findByLabelText('Running').should('not.exist') - cy.findByLabelText('Cycling').should('not.exist') - cy.findByLabelText('Cooking').should('not.exist') - cy.findByLabelText('Gardening').should('not.exist') - }) - - it('should debounce the search input correctly', () => { - cy.mount( - - ) - - cy.clock() - - cy.findByLabelText('Toggle options menu').click() - cy.findByPlaceholderText('Search...').type('Read') - cy.findByLabelText('Swimming').should('exist') - - cy.tick(SELECT_MENU_SEARCH_DEBOUNCE_TIME) - - cy.findByLabelText('Reading').should('exist') - cy.findByLabelText('Swimming').should('not.exist') - - cy.get('input[aria-label="Search for an option"]').clear() - - cy.tick(SELECT_MENU_SEARCH_DEBOUNCE_TIME) - - cy.findByLabelText('Reading').should('exist') - cy.findByLabelText('Swimming').should('exist') - cy.findByLabelText('Running').should('exist') - cy.findByLabelText('Cycling').should('exist') - cy.findByLabelText('Cooking').should('exist') - cy.findByLabelText('Gardening').should('exist') - - cy.clock().then((clock) => clock.restore()) - }) - - it('should show count of selected options when isSearchable is false', () => { - cy.mount( - - ) - - cy.findByLabelText('Toggle options menu').click() - cy.findByLabelText('Reading').click() - cy.findByLabelText('Swimming').click() - - cy.findByText('2 selected').should('exist') - }) - - it('should show No Options Found and toggle all chebox be disabled when search does not match any option', () => { - cy.mount( - - ) - - cy.findByLabelText('Toggle options menu').click() - cy.findByPlaceholderText('Search...').type('Yoga') - - cy.findByText('No options found').should('exist') - cy.findByLabelText('Toggle all options').should('be.disabled') - }) - - it('should be disabled when isDisabled is true', () => { - cy.mount( - - ) - - cy.findByLabelText('Toggle options menu').should('be.disabled') - }) - - it('should be invalid when isInvalid is true', () => { - cy.mount( - - ) - - cy.findByLabelText('Toggle options menu').should('have.attr', 'aria-invalid', 'true') - }) -}) diff --git a/packages/design-system/tests/component/select-multiple/selectMultipleReducer.spec.tsx b/packages/design-system/tests/component/select-multiple/selectMultipleReducer.spec.tsx deleted file mode 100644 index b8a93852d..000000000 --- a/packages/design-system/tests/component/select-multiple/selectMultipleReducer.spec.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { - selectMultipleInitialState, - selectMultipleReducer -} from '../../../src/lib/components/select-multiple/selectMultipleReducer' - -describe('selectMultipleReducer', () => { - it('should return state if bad action type is passed', () => { - const state = selectMultipleReducer(selectMultipleInitialState, { - // @ts-expect-error - Testing bad action type - type: 'BAD_ACTION' - }) - - expect(state).deep.equal(selectMultipleInitialState) - }) - - it('should select an option', () => { - const state = selectMultipleReducer(selectMultipleInitialState, { - type: 'SELECT_OPTION', - payload: 'Reading' - }) - - expect(state.selectedOptions).to.include('Reading') - }) - - it('should remove an option', () => { - const state = selectMultipleReducer( - { ...selectMultipleInitialState, selectedOptions: ['Reading'] }, - { - type: 'REMOVE_OPTION', - payload: 'Reading' - } - ) - - expect(state.selectedOptions).to.not.include('Reading') - }) - - it('should select all available options when there are no current filtered options', () => { - const state = selectMultipleReducer( - { ...selectMultipleInitialState, options: ['Reading', 'Swimming'] }, - { - type: 'SELECT_ALL_OPTIONS' - } - ) - - expect(state.selectedOptions).to.deep.equal(['Reading', 'Swimming']) - }) - - it('should deselect all available options when there are no current filtered options', () => { - const state = selectMultipleReducer( - { - ...selectMultipleInitialState, - options: ['Reading', 'Swimming'], - selectedOptions: ['Reading', 'Swimming'] - }, - { - type: 'DESELECT_ALL_OPTIONS' - } - ) - - expect(state.selectedOptions).to.be.empty - }) - - it('should select all filtered options', () => { - const state = selectMultipleReducer( - { - ...selectMultipleInitialState, - options: ['Reading', 'Swimming', 'Running'], - filteredOptions: ['Reading', 'Swimming'] - }, - { - type: 'SELECT_ALL_OPTIONS' - } - ) - - expect(state.selectedOptions).to.deep.equal(['Reading', 'Swimming']) - }) - - it('should deselect all filtered options', () => { - const state = selectMultipleReducer( - { - ...selectMultipleInitialState, - options: ['Reading', 'Swimming', 'Running'], - selectedOptions: ['Reading', 'Swimming'], - filteredOptions: ['Reading', 'Swimming'] - }, - { - type: 'DESELECT_ALL_OPTIONS' - } - ) - - expect(state.selectedOptions).to.be.empty - }) - - it('should add filtered options to selected options when selecting all if filtered options are present', () => { - const state = selectMultipleReducer( - { - ...selectMultipleInitialState, - options: ['Reading', 'Swimming', 'Running'], - selectedOptions: ['Reading', 'Swimming'], - filteredOptions: ['Running'] - }, - { - type: 'SELECT_ALL_OPTIONS' - } - ) - - expect(state.selectedOptions).to.deep.equal(['Reading', 'Swimming', 'Running']) - }) - - it('should filter options', () => { - const state = selectMultipleReducer( - { ...selectMultipleInitialState, options: ['Reading', 'Swimming', 'Running'] }, - { - type: 'SEARCH', - payload: 'read' - } - ) - - expect(state.filteredOptions).to.include('Reading') - expect(state.filteredOptions).to.not.include('Swimming', 'Running') - }) - - it('should reset search value when empty string is passed', () => { - const state = selectMultipleReducer( - { ...selectMultipleInitialState, searchValue: 'read' }, - { - type: 'SEARCH', - payload: '' - } - ) - - expect(state.searchValue).to.equal('') - }) -}) diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/Vocabulary.tsx b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/Vocabulary.tsx index b0b02ad1d..5fae08fa3 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/Vocabulary.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/Vocabulary.tsx @@ -87,19 +87,31 @@ export const Vocabulary = ({ - - - {options.map((option) => ( - - ))} - + {options.length > 10 ? ( + + ) : ( + + + {options.map((option) => ( + + ))} + + )} + {error?.message} diff --git a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/VocabularyMultiple.tsx b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/VocabularyMultiple.tsx index 6f4c9a51b..39c9bb9f4 100644 --- a/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/VocabularyMultiple.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/MetadataForm/MetadataBlockFormFields/MetadataFormField/Fields/VocabularyMultiple.tsx @@ -53,7 +53,8 @@ export const VocabularyMultiple = ({ - { .should('exist') .closest('.row') .within(() => { + // This is a Select Advanced single, it is shown as a Select with more than 10 options cy.findByLabelText(/Country \/ Nation/) .should('exist') - .select('Argentina', { force: true }) + .click({ force: true }) + cy.findByLabelText(/Country \/ Nation/) + .closest('.dropdown') + .within(() => { + cy.findByText('Argentina').click({ force: true }) + }) }) } @@ -394,12 +400,10 @@ describe('DatasetMetadataForm', () => { .should('have.attr', 'aria-required', 'false') .should('have.data', 'fieldtype', TypeMetadataFieldOptions.Textbox) - cy.findByLabelText('Identifier Type', { exact: true }) - .should('exist') - .should('have.attr', 'aria-required', 'false') - .should('have.prop', 'tagName', 'SELECT') - .children('option') - .should('have.length', 20) + cy.get('[id="citation.publication.0.publicationIDType"]').as( + 'identifierTypeInputButton' + ) + cy.get('@identifierTypeInputButton').should('exist') cy.findByLabelText('Identifier', { exact: true }) .should('exist') @@ -445,10 +449,11 @@ describe('DatasetMetadataForm', () => { .within(() => { cy.findByLabelText(/Country \/ Nation/) .should('exist') - .should('have.attr', 'aria-required', 'true') - .should('have.prop', 'tagName', 'SELECT') - .children('option') - .should('have.length', 250) + .closest('.dropdown') + .within(() => { + cy.findByLabelText('Toggle options menu').click() + cy.findAllByRole('option').should('have.length', 250) + }) cy.findByLabelText(/State \/ Province/) .should('exist') @@ -750,12 +755,7 @@ describe('DatasetMetadataForm', () => { .should('have.attr', 'aria-required', 'false') .should('have.data', 'fieldtype', TypeMetadataFieldOptions.Textbox) - cy.findByLabelText('Identifier Type', { exact: true }) - .should('exist') - .should('have.attr', 'aria-required', 'false') - .should('have.prop', 'tagName', 'SELECT') - .children('option') - .should('have.length', 20) + cy.findByLabelText('Identifier Type', { exact: true }).should('exist') cy.findByLabelText('Identifier', { exact: true }) .should('exist') @@ -857,13 +857,7 @@ describe('DatasetMetadataForm', () => { .should('exist') .closest('.row') .within(() => { - cy.findByLabelText('Type', { exact: true }) - .should('exist') - .should('have.attr', 'aria-required', 'false') - .should('have.value', '') - .should('have.prop', 'tagName', 'SELECT') - .children('option') - .should('have.length', 18) + cy.findByLabelText('Type', { exact: true }).should('exist') cy.findByLabelText('Name', { exact: true }) .should('exist') @@ -1143,13 +1137,16 @@ describe('DatasetMetadataForm', () => { .should('exist') .closest('.row') .within(() => { + // This is a Select Advanced single, it is shown as a Select with more than 10 options cy.findByLabelText(/Country \/ Nation/) .should('exist') - .should('have.attr', 'aria-required', 'true') - .should('have.value', 'United States') - .should('have.prop', 'tagName', 'SELECT') - .children('option') - .should('have.length', 250) + .click({ force: true }) + + cy.findByLabelText(/Country \/ Nation/) + .closest('.dropdown') + .within(() => { + cy.findAllByText('United States').should('exist').should('have.length', 2) + }) cy.findByLabelText(/State \/ Province/) .should('exist')