From cb6cbcf6c958fc38ca348b0addf8e826051a5523 Mon Sep 17 00:00:00 2001 From: roman Date: Tue, 18 Aug 2026 14:42:00 +0200 Subject: [PATCH] refactor(select-field): migrate SelectField from Flow to TypeScript --- ...SelectField.js => BaseSelectField.js.flow} | 0 .../select-field/BaseSelectField.tsx | 623 ++++++++++++++++++ ...electField.js => MultiSelectField.js.flow} | 0 .../select-field/MultiSelectField.tsx | 52 ++ .../{SelectField.js => SelectField.js.flow} | 0 src/components/select-field/SelectField.tsx | 65 ++ ...ropdown.js => SelectFieldDropdown.js.flow} | 0 .../select-field/SelectFieldDropdown.tsx | 62 ++ ...lectField.js => SingleSelectField.js.flow} | 0 .../select-field/SingleSelectField.tsx | 92 +++ ...Field.test.js => BaseSelectField.test.tsx} | 282 +++----- ...ield.test.js => MultiSelectField.test.tsx} | 17 +- ...lectField.test.js => SelectField.test.tsx} | 7 +- ...eld.test.js => SingleSelectField.test.tsx} | 36 +- ....js.snap => BaseSelectField.test.tsx.snap} | 0 ...test.js.snap => SelectField.test.tsx.snap} | 0 .../{constants.js => constants.js.flow} | 0 src/components/select-field/constants.ts | 3 + .../select-field/{index.js => index.js.flow} | 0 src/components/select-field/index.ts | 7 + .../{messages.js => messages.js.flow} | 0 src/components/select-field/messages.ts | 22 + .../select-field/{props.js => props.js.flow} | 0 src/components/select-field/props.ts | 10 + 24 files changed, 1058 insertions(+), 220 deletions(-) rename src/components/select-field/{BaseSelectField.js => BaseSelectField.js.flow} (100%) create mode 100644 src/components/select-field/BaseSelectField.tsx rename src/components/select-field/{MultiSelectField.js => MultiSelectField.js.flow} (100%) create mode 100644 src/components/select-field/MultiSelectField.tsx rename src/components/select-field/{SelectField.js => SelectField.js.flow} (100%) create mode 100644 src/components/select-field/SelectField.tsx rename src/components/select-field/{SelectFieldDropdown.js => SelectFieldDropdown.js.flow} (100%) create mode 100644 src/components/select-field/SelectFieldDropdown.tsx rename src/components/select-field/{SingleSelectField.js => SingleSelectField.js.flow} (100%) create mode 100644 src/components/select-field/SingleSelectField.tsx rename src/components/select-field/__tests__/{BaseSelectField.test.js => BaseSelectField.test.tsx} (87%) rename src/components/select-field/__tests__/{MultiSelectField.test.js => MultiSelectField.test.tsx} (71%) rename src/components/select-field/__tests__/{SelectField.test.js => SelectField.test.tsx} (89%) rename src/components/select-field/__tests__/{SingleSelectField.test.js => SingleSelectField.test.tsx} (76%) rename src/components/select-field/__tests__/__snapshots__/{BaseSelectField.test.js.snap => BaseSelectField.test.tsx.snap} (100%) rename src/components/select-field/__tests__/__snapshots__/{SelectField.test.js.snap => SelectField.test.tsx.snap} (100%) rename src/components/select-field/{constants.js => constants.js.flow} (100%) create mode 100644 src/components/select-field/constants.ts rename src/components/select-field/{index.js => index.js.flow} (100%) create mode 100644 src/components/select-field/index.ts rename src/components/select-field/{messages.js => messages.js.flow} (100%) create mode 100644 src/components/select-field/messages.ts rename src/components/select-field/{props.js => props.js.flow} (100%) create mode 100644 src/components/select-field/props.ts diff --git a/src/components/select-field/BaseSelectField.js b/src/components/select-field/BaseSelectField.js.flow similarity index 100% rename from src/components/select-field/BaseSelectField.js rename to src/components/select-field/BaseSelectField.js.flow diff --git a/src/components/select-field/BaseSelectField.tsx b/src/components/select-field/BaseSelectField.tsx new file mode 100644 index 0000000000..81ea74f13a --- /dev/null +++ b/src/components/select-field/BaseSelectField.tsx @@ -0,0 +1,623 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import uniqueId from 'lodash/uniqueId'; +import findIndex from 'lodash/findIndex'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import type { IntlShape } from 'react-intl'; + +import { scrollIntoView } from '../../utils/dom'; +import IconCheck from '../../icons/general/IconCheck'; +import SelectButton from '../select-button'; +import DatalistItem, { type DatalistItemProps } from '../datalist-item/DatalistItem'; +import PopperComponent from '../popper'; +import SelectFieldDropdown from './SelectFieldDropdown'; +import { TooltipPosition } from '../tooltip'; +import type { SelectOptionValueProp, SelectOptionProp } from './props'; +import { PLACEMENT_BOTTOM_END, PLACEMENT_BOTTOM_START } from '../popper/constants'; +import SearchForm from '../search-form/SearchForm'; +import CLEAR from './constants'; +import { ARROW_DOWN, ARROW_UP, ENTER, ESCAPE, SPACE, TAB } from '../../common/keyboard-events'; + +import messages from './messages'; + +import './SelectField.scss'; + +function stopDefaultEvent(event: React.SyntheticEvent) { + event.preventDefault(); + event.stopPropagation(); +} + +function toggleOption(options: Array, value: SelectOptionValueProp) { + const index = options.indexOf(value); + + if (index === -1) { + options.push(value); + } else { + options.splice(index, 1); + } +} + +export interface BaseSelectFieldProps { + /** List of classnames of the relatedTarget that should prevent handleBlur from firing */ + blurExceptionClassNames?: Array; + /** Props to add to the button element */ + buttonProps?: React.ButtonHTMLAttributes & Record; + /** CSS class for the select container */ + className?: string; + /** The fallback option value when other options are all unselected. Default option cannot be selected at the same time as other options. `selectedValues` must not be empty when this option is used. */ + defaultValue?: SelectOptionValueProp; + /** An optional error to show within a tooltip. */ + error?: React.ReactNode; + /** Position of error message tooltip */ + errorTooltipPosition?: TooltipPosition; + /** Intl object provided by injectIntl */ + intl: IntlShape; + /** The select button is disabled if true */ + isDisabled?: boolean; + /** Whether to allow the dropdown to overflow its boundaries and remain attached to its reference */ + isEscapedWithReference?: boolean; + /** Whether to align the dropdown to the right */ + isRightAligned: boolean; + /** The select field overlay (dropdown) will have a scrollbar and max-height if true */ + isScrollable?: boolean; + /** Whether more than one option can be selected */ + multiple: boolean; + /** Function will be called with an array of all selected options after user selects a new option */ + onChange: (selectedItems: Array) => void; + /** Function will be called with the user selected option (even on deselect or when the option was previously selected) */ + onOptionSelect?: (selectedItem: SelectOptionProp) => void; + /** Function that allows custom rendering of select field options. When not provided the component will only render the option's displayText by default */ + optionRenderer: (option: SelectOptionProp) => React.ReactNode; + /** List of options (displayText, value) */ + options: Array; + /** The select button text shown when no options are selected. */ + placeholder?: string | React.ReactNode; + /** The currently selected option values (can be empty) */ + selectedValues: Array; + /** Array of ordered indices indicating where to insert separators (ex. index 2 means insert a separator after option 2) */ + separatorIndices: Array; + /** Boolean to determine whether or not to show the clear option */ + shouldShowClearOption?: boolean; + /** Boolean to determine whether or not to show the search field */ + shouldShowSearchInput?: boolean; + /** The select button text (by default, component will use comma separated list of all selected option displayText) */ + title?: string | React.ReactNode; + /** A CSS class for the tooltip's tether element component */ + tooltipTetherClassName?: string; +} + +interface BaseSelectFieldState { + /** ID of the currently active option for aria-activedescendant */ + activeItemID: string | null; + /** Index of the currently active option, or -1 when none is active */ + activeItemIndex: number; + /** Whether the dropdown is open */ + isOpen: boolean; + /** Current search field text */ + searchText: string; + /** Whether the active option should be scrolled into view */ + shouldScrollIntoView: boolean; +} + +function defaultOptionRenderer({ displayText }: SelectOptionProp) { + return ( + + {displayText} + + ); +} + +class BaseSelectField extends React.Component { + static defaultProps = { + buttonProps: {}, + isDisabled: false, + isRightAligned: false, + isScrollable: false, + multiple: false, + optionRenderer: defaultOptionRenderer, + options: [], + selectedValues: [], + separatorIndices: [], + shouldShowClearOption: false, + shouldShowSearchInput: false, + }; + + constructor(props: BaseSelectFieldProps) { + super(props); + + this.selectFieldID = uniqueId('selectfield'); + + this.selectFieldContainerRef = React.createRef(); + + this.state = { + activeItemID: null, + activeItemIndex: -1, + isOpen: false, + searchText: '', + shouldScrollIntoView: false, + }; + } + + componentWillUnmount() { + if (this.state.isOpen) { + // Clean-up global click handlers + document.removeEventListener('click', this.handleDocumentClick); + } + } + + updateSearchText = (text: string) => { + const { options } = this.props; + const optionIndex = findIndex(options, element => + element.displayText.toLowerCase().includes(text.toLowerCase()), + ); + + if (optionIndex >= 0) { + this.setActiveItem(optionIndex); + } + + this.setState({ + searchText: text, + }); + }; + + handleDocumentClick = (event: MouseEvent) => { + const container = this.selectFieldContainerRef.current; + const isInside = + (container && event.target instanceof Node && container.contains(event.target)) || + container === event.target; + + if (!isInside) { + this.closeDropdown(); + } + }; + + setActiveItem = (index: number, shouldScrollIntoView: boolean = true) => { + this.setState({ activeItemIndex: index, shouldScrollIntoView }); + if (index === -1) { + this.setActiveItemID(null); + } + }; + + setActiveItemID = (id: string | null) => { + const { shouldScrollIntoView } = this.state; + const itemEl = id ? document.getElementById(id) : null; + + this.setState({ activeItemID: id, shouldScrollIntoView: false }, () => { + if (shouldScrollIntoView) { + scrollIntoView(itemEl, { block: 'nearest' }); + } + }); + }; + + selectFieldID: string; + + selectFieldContainerRef: React.RefObject; + + searchInputRef: HTMLInputElement | null | undefined; + + handleChange = (selectedItems: Array) => { + const { onChange } = this.props; + + if (onChange) { + onChange(selectedItems); + } + }; + + handleOptionSelect = (selectedItem: SelectOptionProp) => { + const { onOptionSelect } = this.props; + + if (onOptionSelect) { + onOptionSelect(selectedItem); + } + }; + + handleButtonClick = () => { + if (this.state.isOpen) { + this.closeDropdown(); + } else { + this.openDropdown(); + } + }; + + handleClearClick = () => { + this.handleChange([]); + }; + + handleButtonKeyDown = (event: React.KeyboardEvent) => { + const { activeItemIndex } = this.state; + + // If user is interacting with the select dropdown, don't close on space/enter (i.e. prevent click event) + if ((event.key === SPACE || event.key === ENTER) && activeItemIndex !== -1) { + event.preventDefault(); + } + }; + + handleBlur = (event?: React.FocusEvent) => { + const { isOpen } = this.state; + const { blurExceptionClassNames = [] } = this.props; + + const exceptionClasses = ['search-input', 'select-button', ...blurExceptionClassNames]; + + if ( + isOpen && + event && + event.relatedTarget && + exceptionClasses.every( + className => event && !(event.relatedTarget as HTMLElement).classList.contains(className), + ) + ) { + this.closeDropdown(); + } + }; + + handleKeyDown = (event: React.KeyboardEvent) => { + const { key } = event; + const { options, shouldShowClearOption, shouldShowSearchInput } = this.props; + const { activeItemIndex, isOpen } = this.state; + const itemCount = options.length; + switch (key) { + case ARROW_DOWN: + stopDefaultEvent(event); + if (isOpen) { + const nextIndex = activeItemIndex === itemCount - 1 ? -1 : activeItemIndex + 1; + this.setActiveItem(nextIndex); + } else { + this.openDropdown(); + } + break; + case ARROW_UP: + stopDefaultEvent(event); + if (isOpen) { + const prevIndex = activeItemIndex === -1 ? itemCount - 1 : activeItemIndex - 1; + this.setActiveItem(prevIndex); + } else { + this.openDropdown(); + } + break; + case ENTER: + case SPACE: + if (shouldShowSearchInput) { + // Allow space key presses in the search string when search field is active + if (key === SPACE) { + break; + } + + // Enter presses should be ignored when no item is active + if (key === ENTER && activeItemIndex === -1) { + stopDefaultEvent(event); + break; + } + } + + if (activeItemIndex !== -1 && isOpen) { + stopDefaultEvent(event); + const isClearOption = shouldShowClearOption && activeItemIndex === 0; + if (isClearOption) { + this.handleClearClick(); + } else { + this.selectOption(activeItemIndex); + } + // Enter always closes dropdown (even for multiselect) + if (key === ENTER) { + this.closeDropdown(); + } + } + break; + case ESCAPE: + if (isOpen) { + stopDefaultEvent(event); + this.closeDropdown(); + } + break; + case TAB: + if (isOpen) { + this.closeDropdown(); + } + break; + default: { + if (!shouldShowSearchInput) { + stopDefaultEvent(event); + const lowerCaseKey = key.toLowerCase(); + const optionIndex = findIndex( + options, + option => option.displayText.toLowerCase().indexOf(lowerCaseKey) === 0, + ); + + if (optionIndex >= 0) { + this.setActiveItem(optionIndex); + } + } + } + } + }; + + openDropdown = () => { + const { shouldShowSearchInput } = this.props; + if (!this.state.isOpen) { + this.setState( + { isOpen: true }, + () => shouldShowSearchInput && this.searchInputRef && this.searchInputRef.focus(), + ); + document.addEventListener('click', this.handleDocumentClick); + } + }; + + closeDropdown = () => { + if (this.state.isOpen) { + this.setState({ + activeItemID: null, + activeItemIndex: -1, + isOpen: false, + searchText: '', + }); + document.removeEventListener('click', this.handleDocumentClick); + } + }; + + selectOption = (index: number) => { + const { multiple } = this.props; + + if (multiple) { + this.selectMultiOption(index); + } else { + this.selectSingleOption(index); + this.closeDropdown(); // Close dropdown for single select fields + } + }; + + getFilteredOptions = (): Array => { + const { options } = this.props; + const { searchText } = this.state; + + return options.filter(option => { + const isSubstring = option.displayText.toLowerCase().includes(searchText.toLowerCase()); + const isClearOption = option.value === CLEAR; + + return searchText ? isSubstring && !isClearOption : true; + }); + }; + + selectSingleOption(index: number) { + const { selectedValues } = this.props; + const item = this.getFilteredOptions()[index]; + // If item not previously selected, fire change handler + if (!selectedValues.includes(item.value)) { + this.handleChange([item]); + } + this.handleOptionSelect(item); + } + + selectMultiOption = (index: number) => { + const { defaultValue, options, selectedValues } = this.props; + const hasDefaultValue = defaultValue != null; // Checks if not undefined or null + const item = this.getFilteredOptions()[index]; + + // If we are already using the default option, just return without firing onChange + if (hasDefaultValue && defaultValue === item.value) { + this.selectSingleOption(index); + return; + } + + // Copy the array so we can freely modify it + const newSelectedValues = selectedValues.slice(0); + toggleOption(newSelectedValues, item.value); + + // Apply constraints if a defaultValue is specified + if (hasDefaultValue) { + const defaultOptionIndex = findIndex(options, option => option.value === defaultValue); + + if (defaultOptionIndex !== -1) { + if (newSelectedValues.length === 0) { + // If nothing is selected, we should select the default option + this.selectSingleOption(defaultOptionIndex); + return; + } + if (newSelectedValues.length > 1 && newSelectedValues.includes(defaultValue)) { + // Remove the default option from the selected values when more than one thing is selected + newSelectedValues.splice(defaultOptionIndex, 1); + } + } + } + + // Fire onchange event with selected items + this.handleChange(options.filter(option => newSelectedValues.includes(option.value))); + + this.handleOptionSelect(item); + }; + + renderButtonText = () => { + const { options, placeholder, selectedValues, title } = this.props; + const selectedItemCount = selectedValues.length; + + // When there are no options selected, render placeholder + if (selectedItemCount === 0 && placeholder) { + return placeholder; + } + + // User-specified title when options are selected + if (title) { + return title; + } + + // Auto-generate button title based on selected options + const selectedOptions = options.filter(option => selectedValues.includes(option.value)); + return selectedOptions.map(option => option.displayText).join(', '); + }; + + renderSearchInput = () => { + const { intl } = this.props; + const { searchText } = this.state; + const getSearchInput = (element: HTMLInputElement | null) => { + this.searchInputRef = element; + }; + + return ( + + ); + }; + + renderSelectButton = () => { + const { activeItemID, isOpen } = this.state; + const { + buttonProps: buttonElProps, + isDisabled, + className, + error, + errorTooltipPosition, + tooltipTetherClassName, + } = this.props; + const buttonText = this.renderButtonText(); + const buttonProps = { + ...buttonElProps, + 'aria-activedescendant': activeItemID, + 'aria-autocomplete': 'list', + 'aria-expanded': isOpen, + 'aria-owns': this.selectFieldID, + className, + isDisabled, + onClick: this.handleButtonClick, + onKeyDown: this.handleButtonKeyDown, + // @NOTE: Technically, only text inputs should be combo-boxes but ARIA specs do not cover custom select dropdowns + role: 'listbox', + title: buttonText, + }; + + return ( + // Need to store the select button reference so we can calculate the button width + // in order to set it as the min width of the dropdown list + )} + error={error} + errorTooltipPosition={errorTooltipPosition} + tooltipTetherClassName={tooltipTetherClassName} + > + {buttonText} + + ); + }; + + renderSelectOptions = () => { + const { optionRenderer, selectedValues, separatorIndices, shouldShowClearOption } = this.props; + const { activeItemIndex } = this.state; + + const filteredOptions = this.getFilteredOptions(); + + if (filteredOptions.length === 0) { + return ( + + + + ); + } + + const selectOptions = filteredOptions.map((item, index) => { + const { value } = item; + + const isSelected = selectedValues.includes(value); + + const isClearOption = shouldShowClearOption && value === CLEAR; + + const itemProps: Omit & { + key: number; + onClick: (event: React.MouseEvent) => void; + onMouseEnter: () => void; + } = { + className: classNames('select-option', { 'is-clear-option': isClearOption }), + key: index, + /* preventDefault on click to prevent wrapping label from re-triggering the select button */ + onClick: event => { + event.preventDefault(); + if (isClearOption) { + this.handleClearClick(); + } else { + this.selectOption(index); + } + }, + onMouseEnter: () => { + this.setActiveItem(index, false); + }, + setActiveItemID: this.setActiveItemID, + }; + + if (index === activeItemIndex) { + itemProps.isActive = true; + } + + itemProps.isSelected = isSelected; + + // The below actually does have a key, but eslint can't catch that + /* eslint-disable react/jsx-key */ + return ( + +
+ {isSelected ? : null} +
+ {optionRenderer(item)} +
+ ); + /* eslint-enable react/jsx-key */ + }); + + separatorIndices.forEach((separatorIndex, index) => { + selectOptions.splice(separatorIndex + index, 0,
  • ); + }); + + return selectOptions; + }; + + render() { + const { + className, + multiple, + isEscapedWithReference, + isRightAligned, + isScrollable, + selectedValues, + shouldShowSearchInput, + } = this.props; + const { isOpen } = this.state; + + // @TODO: Need invariants on specific conditions. + // 1) # of options should be non-zero + // 2) selectedValues, if defined, should all exist in options + // 3) defaultValue, if defined, should exist in options + // 4) defaultValue, if defined, should mean selectedValues is never empty + // 5) defaultValue, if defined, cannot be selected in addition to other options (must be exclusive) + + const dropdownPlacement = isRightAligned ? PLACEMENT_BOTTOM_END : PLACEMENT_BOTTOM_START; + // popper.js modifier to allow dropdown to overflow its boundaries and remain attached to its reference + const dropdownModifiers = isEscapedWithReference ? { preventOverflow: { escapeWithReference: true } } : {}; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
    + + {this.renderSelectButton()} + + {shouldShowSearchInput && this.renderSearchInput()} + {this.renderSelectOptions()} + + +
    + ); + } +} + +export { BaseSelectField as BaseSelectFieldBase }; +export default injectIntl(BaseSelectField); diff --git a/src/components/select-field/MultiSelectField.js b/src/components/select-field/MultiSelectField.js.flow similarity index 100% rename from src/components/select-field/MultiSelectField.js rename to src/components/select-field/MultiSelectField.js.flow diff --git a/src/components/select-field/MultiSelectField.tsx b/src/components/select-field/MultiSelectField.tsx new file mode 100644 index 0000000000..ef87d7b94d --- /dev/null +++ b/src/components/select-field/MultiSelectField.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { injectIntl } from 'react-intl'; +import type { IntlShape } from 'react-intl'; + +import type { SelectOptionProp } from './props'; +import { BaseSelectFieldBase } from './BaseSelectField'; +import type { BaseSelectFieldProps } from './BaseSelectField'; +import CLEAR from './constants'; +import messages from './messages'; + +export interface MultiSelectFieldProps + extends Partial> { + /** Intl object provided by injectIntl */ + intl: IntlShape; + /** Function will be called with an array of all selected options after user selects a new option */ + onChange: (selectedOptions: Array) => void; + /** List of options (displayText, value) */ + options: Array; + /** Boolean to determine whether or not to show the clear option */ + shouldShowClearOption?: boolean; + /** Whether to show the search field */ + shouldShowSearchInput?: boolean; +} + +const optionsWithClearOption = ( + options: Array, + shouldShowClearOption: boolean | undefined, + intl: IntlShape, +) => { + return shouldShowClearOption + ? [ + { + value: CLEAR, + displayText: intl.formatMessage(messages.clearAll), + }, + ...options, + ] + : options; +}; + +const MultiSelectField = ({ intl, options, shouldShowClearOption, ...rest }: MultiSelectFieldProps) => ( + +); + +export { MultiSelectField as MultiSelectFieldBase }; +export default injectIntl(MultiSelectField); diff --git a/src/components/select-field/SelectField.js b/src/components/select-field/SelectField.js.flow similarity index 100% rename from src/components/select-field/SelectField.js rename to src/components/select-field/SelectField.js.flow diff --git a/src/components/select-field/SelectField.tsx b/src/components/select-field/SelectField.tsx new file mode 100644 index 0000000000..b0b0e2ec65 --- /dev/null +++ b/src/components/select-field/SelectField.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import getProp from 'lodash/get'; +import type { FieldProps } from 'formik'; + +import SingleSelectPrimitive from './SingleSelectField'; +import MultiSelectPrimitive from './MultiSelectField'; + +import type { SelectOptionValueProp, SelectOptionProp } from './props'; + +export interface SelectFieldProps extends FieldProps { + /** Whether more than one option can be selected */ + multiple?: boolean; + /** List of options (displayText, value) */ + options: Array; +} + +function createFakeSyntheticEvent(name: string, value: SelectOptionValueProp | Array) { + return { + currentTarget: { name, value }, + target: { name, value }, + }; +} + +function onSelect( + name: string, + onChange: (event: ReturnType) => void, + options: { value: SelectOptionValueProp } | Array<{ value: SelectOptionValueProp }>, +) { + const value = Array.isArray(options) ? options.map(option => option.value) : options.value; + onChange(createFakeSyntheticEvent(name, value)); +} + +const SelectField = ({ field, form, multiple, ...rest }: SelectFieldProps) => { + const { onChange, name, value } = field; + const { errors, touched } = form; + const isTouched = getProp(touched, name); + const error = (isTouched ? getProp(errors, name) : null) as React.ReactNode; + + if (multiple) { + return ( + onSelect(name, onChange, options)} + options={rest.options} + selectedValues={value || []} + /> + ); + } + + return ( + onSelect(name, onChange, options)} + options={rest.options} + selectedValue={value || null} + /> + ); +}; + +export { onSelect }; +export default SelectField; diff --git a/src/components/select-field/SelectFieldDropdown.js b/src/components/select-field/SelectFieldDropdown.js.flow similarity index 100% rename from src/components/select-field/SelectFieldDropdown.js rename to src/components/select-field/SelectFieldDropdown.js.flow diff --git a/src/components/select-field/SelectFieldDropdown.tsx b/src/components/select-field/SelectFieldDropdown.tsx new file mode 100644 index 0000000000..eb46bb4993 --- /dev/null +++ b/src/components/select-field/SelectFieldDropdown.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import classNames from 'classnames'; + +import type { SelectOptionValueProp } from './props'; +import type { PopperChildrenProps } from '../popper/props'; + +export const OVERLAY_SCROLLABLE_CLASS = 'bdl-SelectField-overlay--scrollable'; + +export interface SelectFieldDropdownProps extends PopperChildrenProps { + /** Dropdown list content */ + children: React.ReactNode; + /** Ref forwarded to the dropdown list element */ + innerRef?: React.Ref; + /** Whether the dropdown list is scrollable */ + isScrollable?: boolean; + /** Whether more than one option can be selected */ + multiple?: boolean; + /** ID applied to the listbox for aria-owns */ + selectFieldID: string; + /** Currently selected option values */ + selectedValues: Array; +} + +class SelectFieldDropdown extends React.Component { + componentDidUpdate({ selectedValues: prevSelectedValues }: SelectFieldDropdownProps) { + const { multiple, scheduleUpdate, selectedValues } = this.props; + if (multiple && scheduleUpdate && prevSelectedValues !== selectedValues) { + scheduleUpdate(); + } + } + + render() { + const { children, innerRef, style, placement, isScrollable, multiple, selectFieldID } = this.props; + + const listboxProps: { 'aria-multiselectable'?: boolean } = {}; + if (multiple) { + listboxProps['aria-multiselectable'] = true; + } + + return ( +
      event.preventDefault()} + {...listboxProps} + > + {children} +
    + ); + } +} + +export default React.forwardRef((props, ref) => ( + +)); diff --git a/src/components/select-field/SingleSelectField.js b/src/components/select-field/SingleSelectField.js.flow similarity index 100% rename from src/components/select-field/SingleSelectField.js rename to src/components/select-field/SingleSelectField.js.flow diff --git a/src/components/select-field/SingleSelectField.tsx b/src/components/select-field/SingleSelectField.tsx new file mode 100644 index 0000000000..8d4c275884 --- /dev/null +++ b/src/components/select-field/SingleSelectField.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import omit from 'lodash/omit'; +import { injectIntl } from 'react-intl'; +import type { IntlShape } from 'react-intl'; + +import { BaseSelectFieldBase } from './BaseSelectField'; +import type { BaseSelectFieldProps } from './BaseSelectField'; +import type { SelectOptionValueProp, SelectOptionProp } from './props'; +import CLEAR from './constants'; +import messages from './messages'; + +export interface SingleSelectFieldProps + extends Partial< + Omit + > { + /** Multi-select specific prop that is stripped before forwarding to BaseSelectField */ + defaultValue?: SelectOptionValueProp; + /** The type of the field */ + fieldType?: string; + /** Intl object provided by injectIntl */ + intl: IntlShape; + /** The select field is disabled if true */ + isDisabled?: boolean; + /** The select field overlay (dropdown) will have a scrollbar and max-height if true */ + isScrollable?: boolean; + /** Multi-select specific prop that is stripped before forwarding to BaseSelectField */ + multiple?: boolean; + /** Function will be called with the selected option after user selects a new option */ + onChange: (option: SelectOptionProp | { value: null }, fieldType?: string) => void; + /** List of options (displayText, value) */ + options: Array; + /** The placeholder text for the field */ + placeholder?: string | React.ReactNode; + /** The currently selected option value */ + selectedValue?: SelectOptionValueProp; + /** Whether to show the Clear All option */ + shouldShowClearOption?: boolean; +} + +class SingleSelectField extends React.Component { + handleChange = (selectedOptions: Array) => { + const { onChange, fieldType } = this.props; + + // There should only ever be 1 selected item + if (onChange && selectedOptions.length === 1) { + onChange(selectedOptions[0], fieldType); + } else if (selectedOptions.length === 0) { + onChange({ value: null }); + } + }; + + render() { + const { intl, isDisabled, selectedValue, placeholder, shouldShowClearOption, options, ...rest } = this.props; + + // @TODO: Invariant testing + // 1) selectedValue is required to be contained in the options + // 2) # of options should be non-zero + + // Make sure to omit passed props that could be interpreted incorrectly by the base component + const selectFieldProps = omit(rest, ['defaultValue', 'multiple', 'onChange']) as Partial; + + // If selectedValue is passed in, map it to the multi selected equivalent + const isFieldSelected = selectedValue !== null; + selectFieldProps.selectedValues = !isFieldSelected ? [] : [selectedValue]; + + const optionsWithClearOption = shouldShowClearOption + ? [ + { + value: CLEAR, + displayText: intl.formatMessage(messages.clearAll), + }, + ...options, + ] + : options; + + return ( + + ); + } +} + +export { SingleSelectField as SingleSelectFieldBase }; +export default injectIntl(SingleSelectField); diff --git a/src/components/select-field/__tests__/BaseSelectField.test.js b/src/components/select-field/__tests__/BaseSelectField.test.tsx similarity index 87% rename from src/components/select-field/__tests__/BaseSelectField.test.js rename to src/components/select-field/__tests__/BaseSelectField.test.tsx index 6a040f4f2e..20f47cc8e9 100644 --- a/src/components/select-field/__tests__/BaseSelectField.test.js +++ b/src/components/select-field/__tests__/BaseSelectField.test.tsx @@ -1,9 +1,13 @@ import * as React from 'react'; +import { shallow } from 'enzyme'; import sinon from 'sinon'; import { FormattedMessage } from 'react-intl'; +import type { IntlShape } from 'react-intl'; import { scrollIntoView } from '../../../utils/dom'; import { BaseSelectFieldBase as BaseSelectField } from '../BaseSelectField'; +import type { BaseSelectFieldProps } from '../BaseSelectField'; +import { TooltipPosition } from '../../tooltip'; import { OVERLAY_SCROLLABLE_CLASS } from '../SelectFieldDropdown'; import { ARROW_DOWN, ARROW_UP, ENTER, ESCAPE, SPACE, TAB } from '../../../common/keyboard-events'; import CLEAR from '../constants'; @@ -24,7 +28,7 @@ describe('components/select-field/BaseSelectField', () => { const intl = { formatMessage: jest.fn(), - }; + } as unknown as IntlShape; const options = [ { displayText: 'Any Type', value: '' }, @@ -33,12 +37,12 @@ describe('components/select-field/BaseSelectField', () => { { displayText: 'Videos', value: 'video' }, ]; const onOptionSelectSpy = sandbox.stub(); - const shallowRenderSelectField = props => - shallow( + const shallowRenderSelectField = (props: Partial = {}) => + shallow>( {}} + onChange={jest.fn()} onOptionSelect={onOptionSelectSpy} options={options} shouldShowClearOption={false} @@ -93,7 +97,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); const buttonWrapper = wrapper.find('PopperComponent').childAt(0); - expect(buttonWrapper.prop('aria-activedescendant')).toEqual(null); + expect(buttonWrapper.prop('aria-activedescendant')).toBeNull(); expect(buttonWrapper.prop('aria-autocomplete')).toEqual('list'); expect(buttonWrapper.prop('aria-expanded')).toBe(false); expect(buttonWrapper.prop('aria-owns')).toEqual(instance.selectFieldID); @@ -109,7 +113,7 @@ describe('components/select-field/BaseSelectField', () => { }); const buttonWrapper = wrapper.find('PopperComponent').childAt(0); - expect(buttonWrapper.length).toBe(1); + expect(buttonWrapper).toHaveLength(1); expect(buttonWrapper.prop('aria-activedescendant')).toEqual('datalistitem-123'); expect(buttonWrapper.prop('aria-expanded')).toBe(true); }); @@ -139,7 +143,7 @@ describe('components/select-field/BaseSelectField', () => { test('should send error tooltip positon to select button when errorTooltipPosition prop has some value', () => { const wrapper = shallowRenderSelectField({ error: 'error', - errorTooltipPosition: 'middle-left', + errorTooltipPosition: TooltipPosition.MIDDLE_LEFT, }); const buttonWrapper = wrapper.find('PopperComponent').childAt(0); expect(buttonWrapper.prop('errorTooltipPosition')).toBe('middle-left'); @@ -154,7 +158,7 @@ describe('components/select-field/BaseSelectField', () => { const searchForm = wrapper.find('SearchForm'); - expect(searchForm.length).toBe(1); + expect(searchForm).toHaveLength(1); }); }); @@ -183,7 +187,7 @@ describe('components/select-field/BaseSelectField', () => { const itemsWrapper = wrapper.find('DatalistItem'); const option = itemsWrapper.at(0); - expect(itemsWrapper.length).toBe(1); + expect(itemsWrapper).toHaveLength(1); expect(option.find('.bdl-SelectField-optionText').props().title).toEqual(searchText); }); @@ -191,7 +195,7 @@ describe('components/select-field/BaseSelectField', () => { const wrapper = shallowRenderSelectField(); const itemsWrapper = wrapper.find('DatalistItem'); - expect(itemsWrapper.length).toBe(4); + expect(itemsWrapper).toHaveLength(4); // Spot check that props are correct expect(itemsWrapper.at(0).prop('className')).toEqual('select-option'); expect(itemsWrapper.at(0).key()).toEqual('0'); @@ -203,10 +207,10 @@ describe('components/select-field/BaseSelectField', () => { selectedValues: ['audio', 'document'], }); const itemsWrapper = wrapper.find('DatalistItem'); - expect(itemsWrapper.at(0).find('IconCheck').length).toBe(0); - expect(itemsWrapper.at(1).find('IconCheck').length).toBe(1); - expect(itemsWrapper.at(2).find('IconCheck').length).toBe(1); - expect(itemsWrapper.at(3).find('IconCheck').length).toBe(0); + expect(itemsWrapper.at(0).find('IconCheck')).toHaveLength(0); + expect(itemsWrapper.at(1).find('IconCheck')).toHaveLength(1); + expect(itemsWrapper.at(2).find('IconCheck')).toHaveLength(1); + expect(itemsWrapper.at(3).find('IconCheck')).toHaveLength(0); }); test('should set isActive prop on current active index item', () => { @@ -283,13 +287,9 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); // Dive past the ForwardRef and SelectFieldDropdown - const overlay = wrapper - .find('PopperComponent') - .childAt(1) - .dive() - .dive(); + const overlay = wrapper.find('PopperComponent').childAt(1).dive().dive(); - expect(overlay.length).toBe(1); + expect(overlay).toHaveLength(1); expect(overlay.is('ul')).toBe(true); expect(overlay.prop('role')).toEqual('listbox'); expect(overlay.prop('id')).toEqual(instance.selectFieldID); @@ -300,11 +300,7 @@ describe('components/select-field/BaseSelectField', () => { const wrapper = shallowRenderSelectField({ multiple: true }); // Dive past the ForwardRef and SelectFieldDropdown - const overlay = wrapper - .find('PopperComponent') - .childAt(1) - .dive() - .dive(); + const overlay = wrapper.find('PopperComponent').childAt(1).dive().dive(); expect(overlay.prop('aria-multiselectable')).toBe(true); }); @@ -317,11 +313,7 @@ describe('components/select-field/BaseSelectField', () => { (isScrollable, result) => { const wrapper = shallowRenderSelectField({ isScrollable }); // Dive past the ForwardRef and SelectFieldDropdown - const overlay = wrapper - .find('PopperComponent') - .childAt(1) - .dive() - .dive(); + const overlay = wrapper.find('PopperComponent').childAt(1).dive().dive(); expect(overlay.hasClass(OVERLAY_SCROLLABLE_CLASS)).toBe(result); }, ); @@ -329,14 +321,18 @@ describe('components/select-field/BaseSelectField', () => { test('should apply preventOverflow modifier when isEscapedWithReference is true', () => { const wrapper = shallowRenderSelectField({ isEscapedWithReference: true }); - const props = wrapper.find('PopperComponent').props(); + const props = wrapper.find('PopperComponent').props() as { + modifiers?: { preventOverflow?: { escapeWithReference?: boolean } }; + }; expect(props.modifiers.preventOverflow).toEqual({ escapeWithReference: true }); }); test('should not apply preventOverflow modifier when isEscapedWithReference is not set', () => { const wrapper = shallowRenderSelectField(); - const props = wrapper.find('PopperComponent').props(); + const props = wrapper.find('PopperComponent').props() as { + modifiers?: { preventOverflow?: { escapeWithReference?: boolean } }; + }; expect(props.modifiers.preventOverflow).toBeUndefined(); }); }); @@ -347,10 +343,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); wrapper.setState({ isOpen: false }); - sandbox - .mock(instance) - .expects('closeDropdown') - .never(); + sandbox.mock(instance).expects('closeDropdown').never(); wrapper.simulate('blur'); }); @@ -366,7 +359,7 @@ describe('components/select-field/BaseSelectField', () => { }; targetWithClassName.relatedTarget.className = 'not-select-button'; - instance.handleBlur(targetWithClassName); + instance.handleBlur(targetWithClassName as unknown as React.FocusEvent); expect(spy).toHaveBeenCalled(); }); @@ -388,7 +381,7 @@ describe('components/select-field/BaseSelectField', () => { }; targetWithClassName.relatedTarget.className = className; - instance.handleBlur(targetWithClassName); + instance.handleBlur(targetWithClassName as unknown as React.FocusEvent); expect(spy).not.toHaveBeenCalled(); }, @@ -406,7 +399,7 @@ describe('components/select-field/BaseSelectField', () => { }; targetWithClassName.relatedTarget.className = exception; - instance.handleBlur(targetWithClassName); + instance.handleBlur(targetWithClassName as unknown as React.FocusEvent); expect(spy).not.toHaveBeenCalled(); }); @@ -427,10 +420,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); wrapper.setState({ isOpen: true }); - sandbox - .mock(instance) - .expects('setActiveItem') - .withArgs(0); + sandbox.mock(instance).expects('setActiveItem').withArgs(0); wrapper.simulate('keyDown', event); }); @@ -440,10 +430,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); wrapper.setState({ activeItemIndex: 3, isOpen: true }); - sandbox - .mock(instance) - .expects('setActiveItem') - .withArgs(-1); + sandbox.mock(instance).expects('setActiveItem').withArgs(-1); wrapper.simulate('keyDown', event); }); @@ -474,10 +461,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); wrapper.setState({ activeItemIndex: 0, isOpen: true }); - sandbox - .mock(instance) - .expects('setActiveItem') - .withArgs(-1); + sandbox.mock(instance).expects('setActiveItem').withArgs(-1); wrapper.simulate('keyDown', event); }); @@ -487,10 +471,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); wrapper.setState({ isOpen: true }); - sandbox - .mock(instance) - .expects('setActiveItem') - .withArgs(3); + sandbox.mock(instance).expects('setActiveItem').withArgs(3); wrapper.simulate('keyDown', event); }); @@ -512,10 +493,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); wrapper.setState({ isOpen: true }); - sandbox - .mock(instance) - .expects('selectOption') - .never(); + sandbox.mock(instance).expects('selectOption').never(); wrapper.simulate('keyDown', { key: ENTER, @@ -529,10 +507,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); wrapper.setState({ activeItemIndex: 0, isOpen: false }); - sandbox - .mock(instance) - .expects('selectOption') - .never(); + sandbox.mock(instance).expects('selectOption').never(); wrapper.simulate('keyDown', { key: ENTER, @@ -547,10 +522,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); wrapper.setState({ activeItemIndex, isOpen: true }); - sandbox - .mock(instance) - .expects('selectOption') - .withArgs(activeItemIndex); + sandbox.mock(instance).expects('selectOption').withArgs(activeItemIndex); sandbox.mock(instance).expects('closeDropdown'); wrapper.simulate('keyDown', { @@ -613,10 +585,7 @@ describe('components/select-field/BaseSelectField', () => { }); wrapper.setState({ isOpen: true }); - sandbox - .mock(instance) - .expects('selectOption') - .never(); + sandbox.mock(instance).expects('selectOption').never(); wrapper.simulate('keyDown', { key: SPACE, @@ -630,10 +599,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); wrapper.setState({ isOpen: true }); - sandbox - .mock(instance) - .expects('selectOption') - .never(); + sandbox.mock(instance).expects('selectOption').never(); wrapper.simulate('keyDown', { key: SPACE, @@ -647,10 +613,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); wrapper.setState({ activeItemIndex: 0, isOpen: false }); - sandbox - .mock(instance) - .expects('selectOption') - .never(); + sandbox.mock(instance).expects('selectOption').never(); wrapper.simulate('keyDown', { key: SPACE, @@ -665,10 +628,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); wrapper.setState({ activeItemIndex, isOpen: true }); - sandbox - .mock(instance) - .expects('selectOption') - .withArgs(activeItemIndex); + sandbox.mock(instance).expects('selectOption').withArgs(activeItemIndex); wrapper.simulate('keyDown', { key: SPACE, @@ -784,7 +744,7 @@ describe('components/select-field/BaseSelectField', () => { wrapper.setProps({ onChange: sandbox.mock().withArgs(['what', 'is', 'up']), }); - wrapper.instance().handleChange(['what', 'is', 'up']); + wrapper.instance().handleChange(['what', 'is', 'up'] as never); }); }); @@ -793,10 +753,7 @@ describe('components/select-field/BaseSelectField', () => { const wrapper = shallowRenderSelectField(); const instance = wrapper.instance(); - sandbox - .mock(instance) - .expects('handleChange') - .withArgs([]); + sandbox.mock(instance).expects('handleChange').withArgs([]); instance.handleClearClick(); }); @@ -808,7 +765,7 @@ describe('components/select-field/BaseSelectField', () => { wrapper.setProps({ onOptionSelect: sandbox.mock().withArgs('up'), }); - wrapper.instance().handleOptionSelect('up'); + wrapper.instance().handleOptionSelect('up' as never); }); }); @@ -820,10 +777,7 @@ describe('components/select-field/BaseSelectField', () => { sandbox.mock(instance).expects('openDropdown'); - wrapper - .find('PopperComponent') - .childAt(0) - .simulate('click'); + wrapper.find('PopperComponent').childAt(0).simulate('click'); }); test('should close dropdown when it is open', () => { @@ -833,10 +787,7 @@ describe('components/select-field/BaseSelectField', () => { sandbox.mock(instance).expects('closeDropdown'); - wrapper - .find('PopperComponent') - .childAt(0) - .simulate('click'); + wrapper.find('PopperComponent').childAt(0).simulate('click'); }); }); @@ -853,28 +804,22 @@ describe('components/select-field/BaseSelectField', () => { const wrapper = shallowRenderSelectField(); wrapper.setState({ isOpen: true, activeItemIndex: 2 }); - wrapper - .find('PopperComponent') - .childAt(0) - .simulate('keyDown', { - key, - preventDefault: sandbox.mock(), - stopPropagation: sandbox.mock().never(), - }); + wrapper.find('PopperComponent').childAt(0).simulate('keyDown', { + key, + preventDefault: sandbox.mock(), + stopPropagation: sandbox.mock().never(), + }); }); test('should not preventDefault() when key is space or enter and activeItemIndex == -1', () => { const wrapper = shallowRenderSelectField(); wrapper.setState({ isOpen: true, activeItemIndex: -1 }); - wrapper - .find('PopperComponent') - .childAt(0) - .simulate('keyDown', { - key, - preventDefault: sandbox.mock().never(), - stopPropagation: sandbox.mock().never(), - }); + wrapper.find('PopperComponent').childAt(0).simulate('keyDown', { + key, + preventDefault: sandbox.mock().never(), + stopPropagation: sandbox.mock().never(), + }); }); }); @@ -882,14 +827,11 @@ describe('components/select-field/BaseSelectField', () => { const wrapper = shallowRenderSelectField(); wrapper.setState({ isOpen: true, activeItemIndex: 2 }); - wrapper - .find('PopperComponent') - .childAt(0) - .simulate('keyDown', { - key: ARROW_DOWN, - preventDefault: sandbox.mock().never(), - stopPropagation: sandbox.mock().never(), - }); + wrapper.find('PopperComponent').childAt(0).simulate('keyDown', { + key: ARROW_DOWN, + preventDefault: sandbox.mock().never(), + stopPropagation: sandbox.mock().never(), + }); }); }); @@ -906,29 +848,20 @@ describe('components/select-field/BaseSelectField', () => { sandbox.mock(instance).expects('handleClearClick'); - wrapper - .find('DatalistItem') - .at(0) - .simulate('click', { - preventDefault: sandbox.mock(), - }); + wrapper.find('DatalistItem').at(0).simulate('click', { + preventDefault: sandbox.mock(), + }); }); test('should select item and close dropdown when item is clicked', () => { const wrapper = shallowRenderSelectField(); const instance = wrapper.instance(); - sandbox - .mock(instance) - .expects('selectOption') - .withArgs(1); + sandbox.mock(instance).expects('selectOption').withArgs(1); - wrapper - .find('DatalistItem') - .at(1) - .simulate('click', { - preventDefault: sandbox.mock(), - }); + wrapper.find('DatalistItem').at(1).simulate('click', { + preventDefault: sandbox.mock(), + }); }); }); @@ -936,10 +869,7 @@ describe('components/select-field/BaseSelectField', () => { test('should set correct active item index when hovering over item', () => { const wrapper = shallowRenderSelectField(); - wrapper - .find('DatalistItem') - .at(2) - .simulate('mouseEnter'); + wrapper.find('DatalistItem').at(2).simulate('mouseEnter'); expect(wrapper.state('activeItemIndex')).toEqual(2); }); @@ -948,10 +878,7 @@ describe('components/select-field/BaseSelectField', () => { const wrapper = shallowRenderSelectField(); wrapper.setState({ shouldScrollIntoView: true }); - wrapper - .find('DatalistItem') - .at(2) - .simulate('mouseEnter'); + wrapper.find('DatalistItem').at(2).simulate('mouseEnter'); expect(wrapper.state('shouldScrollIntoView')).toBe(false); }); @@ -963,19 +890,13 @@ describe('components/select-field/BaseSelectField', () => { test('should update activeItemIndex state when called', () => { const index = 1; - sandbox - .mock(instance) - .expects('setActiveItemID') - .never(); + sandbox.mock(instance).expects('setActiveItemID').never(); instance.setActiveItem(index); expect(wrapper.state('activeItemIndex')).toEqual(index); }); test('should reset active item ID when index is -1', () => { - sandbox - .mock(instance) - .expects('setActiveItemID') - .withArgs(null); + sandbox.mock(instance).expects('setActiveItemID').withArgs(null); instance.setActiveItem(-1); }); @@ -1035,7 +956,7 @@ describe('components/select-field/BaseSelectField', () => { focus: jest.fn(), }; const instance = wrapper.instance(); - instance.searchInputRef = mockSearchInputRef; + instance.searchInputRef = mockSearchInputRef as unknown as HTMLInputElement; instance.openDropdown(); @@ -1079,10 +1000,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); const index = 1; - sandbox - .mock(instance) - .expects('selectMultiOption') - .withArgs(index); + sandbox.mock(instance).expects('selectMultiOption').withArgs(index); instance.selectOption(index); }); @@ -1092,10 +1010,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); const index = 1; - sandbox - .mock(instance) - .expects('selectSingleOption') - .withArgs(index); + sandbox.mock(instance).expects('selectSingleOption').withArgs(index); sandbox.mock(instance).expects('closeDropdown'); instance.selectOption(index); @@ -1137,7 +1052,7 @@ describe('components/select-field/BaseSelectField', () => { }); const filteredOptions = instance.getFilteredOptions(); - expect(filteredOptions.length).toBe(0); + expect(filteredOptions).toHaveLength(0); }); test('should not filter out the clear option if searchText is empty string', () => { @@ -1147,7 +1062,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); const filteredOptions = instance.getFilteredOptions(); - expect(filteredOptions.length).toBe(1); + expect(filteredOptions).toHaveLength(1); }); }); @@ -1157,10 +1072,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); const index = 1; - sandbox - .mock(instance) - .expects('handleChange') - .withArgs([options[index]]); + sandbox.mock(instance).expects('handleChange').withArgs([options[index]]); instance.selectSingleOption(index); }); @@ -1187,10 +1099,7 @@ describe('components/select-field/BaseSelectField', () => { }); const instance = wrapper.instance(); - sandbox - .mock(instance) - .expects('handleChange') - .never(); + sandbox.mock(instance).expects('handleChange').never(); instance.selectSingleOption(index); }); @@ -1235,10 +1144,7 @@ describe('components/select-field/BaseSelectField', () => { const instance = wrapper.instance(); const index = 0; - sandbox - .mock(instance) - .expects('selectSingleOption') - .withArgs(index); + sandbox.mock(instance).expects('selectSingleOption').withArgs(index); instance.selectMultiOption(index); }); @@ -1253,10 +1159,7 @@ describe('components/select-field/BaseSelectField', () => { const index = 3; // Matches video option - sandbox - .mock(instance) - .expects('selectSingleOption') - .withArgs(defaultIndex); + sandbox.mock(instance).expects('selectSingleOption').withArgs(defaultIndex); instance.selectMultiOption(index); }); @@ -1270,10 +1173,7 @@ describe('components/select-field/BaseSelectField', () => { const index = 3; // Matches video option - sandbox - .mock(instance) - .expects('handleChange') - .withArgs([options[index]]); + sandbox.mock(instance).expects('handleChange').withArgs([options[index]]); instance.selectMultiOption(index); }); @@ -1287,10 +1187,7 @@ describe('components/select-field/BaseSelectField', () => { const index = 3; // Matches video option - sandbox - .mock(instance) - .expects('handleChange') - .withArgs([options[1], options[index]]); // audio + video + sandbox.mock(instance).expects('handleChange').withArgs([options[1], options[index]]); // audio + video instance.selectMultiOption(index); }); @@ -1304,10 +1201,7 @@ describe('components/select-field/BaseSelectField', () => { const index = 3; // Matches video option - sandbox - .mock(instance) - .expects('handleOptionSelect') - .withArgs(options[index]); // audio + video + sandbox.mock(instance).expects('handleOptionSelect').withArgs(options[index]); // audio + video instance.selectMultiOption(index); }); @@ -1371,7 +1265,7 @@ describe('components/select-field/BaseSelectField', () => { instance.handleDocumentClick({ target: document.createElement('div'), - }); + } as unknown as MouseEvent); expect(instance.closeDropdown).toHaveBeenCalled(); }); @@ -1395,7 +1289,7 @@ describe('components/select-field/BaseSelectField', () => { instance.handleDocumentClick({ target: document.getElementById(instance.selectFieldID), - }); + } as unknown as MouseEvent); expect(instance.closeDropdown).not.toHaveBeenCalled(); }); diff --git a/src/components/select-field/__tests__/MultiSelectField.test.js b/src/components/select-field/__tests__/MultiSelectField.test.tsx similarity index 71% rename from src/components/select-field/__tests__/MultiSelectField.test.js rename to src/components/select-field/__tests__/MultiSelectField.test.tsx index d443f696a7..3cb9d1dfab 100644 --- a/src/components/select-field/__tests__/MultiSelectField.test.js +++ b/src/components/select-field/__tests__/MultiSelectField.test.tsx @@ -1,8 +1,14 @@ import * as React from 'react'; +import { shallow } from 'enzyme'; +import type { IntlShape } from 'react-intl'; import { MultiSelectFieldBase } from '../MultiSelectField'; import CLEAR from '../constants'; +const intl = { + formatMessage: jest.fn().mockImplementation(() => 'Clear All'), +} as unknown as IntlShape; + describe('components/select-field/MultiSelectField', () => { const options = [ { displayText: 'Foo', value: 'foo' }, @@ -12,20 +18,17 @@ describe('components/select-field/MultiSelectField', () => { describe('MultiSelectField', () => { test('should render a BaseSelectField with a selectedValues prop matching passed in selected value when called', () => { - const wrapper = shallow( {}} options={options} />); + const wrapper = shallow(); const baseSelectFieldWrapper = wrapper.find('BaseSelectField'); - expect(baseSelectFieldWrapper.length).toBe(1); + expect(baseSelectFieldWrapper).toHaveLength(1); expect(baseSelectFieldWrapper.prop('options')).toEqual(options); expect(baseSelectFieldWrapper.prop('multiple')).toBe(true); }); test('should render a BaseSelectField with an options prop containing a clear option if shouldShowClearOption is true', () => { - const intl = { - formatMessage: jest.fn().mockImplementationOnce(() => 'Clear All'), - }; const wrapper = shallow( - {}} options={options} shouldShowClearOption />, + , ); const expectedOptions = [ { @@ -36,7 +39,7 @@ describe('components/select-field/MultiSelectField', () => { ]; const baseSelectFieldWrapper = wrapper.find('BaseSelectField'); - expect(baseSelectFieldWrapper.length).toBe(1); + expect(baseSelectFieldWrapper).toHaveLength(1); expect(baseSelectFieldWrapper.prop('options')).toEqual(expectedOptions); }); }); diff --git a/src/components/select-field/__tests__/SelectField.test.js b/src/components/select-field/__tests__/SelectField.test.tsx similarity index 89% rename from src/components/select-field/__tests__/SelectField.test.js rename to src/components/select-field/__tests__/SelectField.test.tsx index 56bb8638fe..60735985ec 100644 --- a/src/components/select-field/__tests__/SelectField.test.js +++ b/src/components/select-field/__tests__/SelectField.test.tsx @@ -1,10 +1,11 @@ -// @flow - import * as React from 'react'; +import { shallow } from 'enzyme'; import SelectField, { onSelect } from '../SelectField'; +import type { SelectFieldProps } from '../SelectField'; describe('components/select-feild/SelectField', () => { - const getWrapper = (props = {}) => shallow(); + const getWrapper = (props: Record = {}) => + shallow(); test('should render properly for single select field', () => { const wrapper = getWrapper({ diff --git a/src/components/select-field/__tests__/SingleSelectField.test.js b/src/components/select-field/__tests__/SingleSelectField.test.tsx similarity index 76% rename from src/components/select-field/__tests__/SingleSelectField.test.js rename to src/components/select-field/__tests__/SingleSelectField.test.tsx index be87683320..e8e3e0581d 100644 --- a/src/components/select-field/__tests__/SingleSelectField.test.js +++ b/src/components/select-field/__tests__/SingleSelectField.test.tsx @@ -1,9 +1,15 @@ import * as React from 'react'; +import { shallow } from 'enzyme'; import sinon from 'sinon'; +import type { IntlShape } from 'react-intl'; import { SingleSelectFieldBase } from '../SingleSelectField'; import CLEAR from '../constants'; +const intl = { + formatMessage: jest.fn().mockImplementation(() => 'Clear All'), +} as unknown as IntlShape; + const sandbox = sinon.sandbox.create(); describe('components/select-field/SingleSelectField', () => { @@ -11,7 +17,7 @@ describe('components/select-field/SingleSelectField', () => { sandbox.verifyAndRestore(); }); - const onChangeStub = () => {}; + const onChangeStub = jest.fn(); const options = [ { displayText: 'Foo', value: 'foo' }, @@ -21,8 +27,9 @@ describe('components/select-field/SingleSelectField', () => { describe('render()', () => { test('should render a BaseSelectField with a selectedValues prop matching passed in selected value when called', () => { - const wrapper = shallow( + const wrapper = shallow>( { const instance = wrapper.instance(); const baseSelectFieldWrapper = wrapper.find('BaseSelectField'); - expect(baseSelectFieldWrapper.length).toBe(1); + expect(baseSelectFieldWrapper).toHaveLength(1); expect(baseSelectFieldWrapper.prop('options')).toBe(options); expect(baseSelectFieldWrapper.prop('onChange')).toBe(instance.handleChange); expect(baseSelectFieldWrapper.prop('selectedValues')).toEqual(['bar']); @@ -40,10 +47,6 @@ describe('components/select-field/SingleSelectField', () => { }); test('should render a BaseSelectField with options that includes a clear option if shouldShowClearOption is true', () => { - const intl = { - formatMessage: jest.fn().mockImplementationOnce(() => 'Clear All'), - }; - const wrapper = shallow( { const wrapper = shallow( { ); const baseSelectFieldWrapper = wrapper.find('BaseSelectField'); - expect(baseSelectFieldWrapper.length).toBe(1); + expect(baseSelectFieldWrapper).toHaveLength(1); expect(baseSelectFieldWrapper.prop('options')).toBe(options); expect(baseSelectFieldWrapper.prop('onChange')).not.toBe(onChangeStub); expect(baseSelectFieldWrapper.prop('defaultValue')).toBeFalsy(); @@ -90,8 +94,8 @@ describe('components/select-field/SingleSelectField', () => { describe('handleChange()', () => { test('should call onChange() with an object with value of null when there are no selected items', () => { const onChangeMock = sandbox.mock().withArgs({ value: null }); - const wrapper = shallow( - , + const wrapper = shallow>( + , ); const instance = wrapper.instance(); @@ -100,22 +104,22 @@ describe('components/select-field/SingleSelectField', () => { test('should call onChange() when there is a selected item', () => { const onChangeMock = sandbox.mock().withArgs('foo'); - const wrapper = shallow( - , + const wrapper = shallow>( + , ); const instance = wrapper.instance(); - instance.handleChange(['foo']); + instance.handleChange(['foo'] as never); }); test('should not call onChange() when there are more than 1 selected items (potentially an error)', () => { const onChangeMock = sandbox.mock().never(); - const wrapper = shallow( - , + const wrapper = shallow>( + , ); const instance = wrapper.instance(); - instance.handleChange(['foo', 'bar']); + instance.handleChange(['foo', 'bar'] as never); }); }); }); diff --git a/src/components/select-field/__tests__/__snapshots__/BaseSelectField.test.js.snap b/src/components/select-field/__tests__/__snapshots__/BaseSelectField.test.tsx.snap similarity index 100% rename from src/components/select-field/__tests__/__snapshots__/BaseSelectField.test.js.snap rename to src/components/select-field/__tests__/__snapshots__/BaseSelectField.test.tsx.snap diff --git a/src/components/select-field/__tests__/__snapshots__/SelectField.test.js.snap b/src/components/select-field/__tests__/__snapshots__/SelectField.test.tsx.snap similarity index 100% rename from src/components/select-field/__tests__/__snapshots__/SelectField.test.js.snap rename to src/components/select-field/__tests__/__snapshots__/SelectField.test.tsx.snap diff --git a/src/components/select-field/constants.js b/src/components/select-field/constants.js.flow similarity index 100% rename from src/components/select-field/constants.js rename to src/components/select-field/constants.js.flow diff --git a/src/components/select-field/constants.ts b/src/components/select-field/constants.ts new file mode 100644 index 0000000000..d118ab2eff --- /dev/null +++ b/src/components/select-field/constants.ts @@ -0,0 +1,3 @@ +const CLEAR = '__clear__' as const; // value of clear option used in select-field components + +export default CLEAR; diff --git a/src/components/select-field/index.js b/src/components/select-field/index.js.flow similarity index 100% rename from src/components/select-field/index.js rename to src/components/select-field/index.js.flow diff --git a/src/components/select-field/index.ts b/src/components/select-field/index.ts new file mode 100644 index 0000000000..7233bcf90f --- /dev/null +++ b/src/components/select-field/index.ts @@ -0,0 +1,7 @@ +export { default as SingleSelectField } from './SingleSelectField'; +export type { SingleSelectFieldProps } from './SingleSelectField'; +export { default as MultiSelectField } from './MultiSelectField'; +export type { MultiSelectFieldProps } from './MultiSelectField'; +export { default as SelectField } from './SelectField'; +export type { SelectFieldProps } from './SelectField'; +export type { SelectOptionProp, SelectOptionValueProp } from './props'; diff --git a/src/components/select-field/messages.js b/src/components/select-field/messages.js.flow similarity index 100% rename from src/components/select-field/messages.js rename to src/components/select-field/messages.js.flow diff --git a/src/components/select-field/messages.ts b/src/components/select-field/messages.ts new file mode 100644 index 0000000000..386cb570c6 --- /dev/null +++ b/src/components/select-field/messages.ts @@ -0,0 +1,22 @@ +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + clearAll: { + defaultMessage: 'Clear All', + description: 'text shown on the Clear All option in the options list', + id: 'boxui.selectField.clearAll', + }, + searchPlaceholder: { + defaultMessage: 'Search', + description: 'Placeholder text shown in the search input', + id: 'boxui.selectField.searchPlaceholder', + }, + noResults: { + defaultMessage: 'No Results', + description: + 'Text shown in the select field dropdown when there are no options that match the search field input', + id: 'boxui.selectField.noResults', + }, +}); + +export default messages; diff --git a/src/components/select-field/props.js b/src/components/select-field/props.js.flow similarity index 100% rename from src/components/select-field/props.js rename to src/components/select-field/props.js.flow diff --git a/src/components/select-field/props.ts b/src/components/select-field/props.ts new file mode 100644 index 0000000000..61b1825735 --- /dev/null +++ b/src/components/select-field/props.ts @@ -0,0 +1,10 @@ +export type SelectOptionValueProp = string | number | null; + +export interface SelectOptionProp { + /** Text displayed for the option */ + displayText: string; + /** Optional unique identifier for the option */ + id?: string; + /** Value of the option */ + value: SelectOptionValueProp; +}