diff --git a/frontend/src/components/Dropdown/SelectContext.tsx b/frontend/src/components/Dropdown/SelectContext.tsx index 75dd224bbf..b9b79a8a99 100644 --- a/frontend/src/components/Dropdown/SelectContext.tsx +++ b/frontend/src/components/Dropdown/SelectContext.tsx @@ -21,6 +21,11 @@ export interface SharedSelectContextReturnProps< name: string /** Item data used to render items in dropdown */ items: Item[] + /** aria-describedby to be attached to the combobox input, if any. */ + inputAria?: { + id: string + label: string + } } interface SelectContextReturn @@ -34,6 +39,7 @@ interface SelectContextReturn styles: Record isFocused: boolean setIsFocused: (isFocused: boolean) => void + resetInputValue: () => void } export const SelectContext = createContext( diff --git a/frontend/src/components/Dropdown/SingleSelect/SingleSelect.stories.tsx b/frontend/src/components/Dropdown/SingleSelect/SingleSelect.stories.tsx index 36228d0de2..d1e63bba0a 100644 --- a/frontend/src/components/Dropdown/SingleSelect/SingleSelect.stories.tsx +++ b/frontend/src/components/Dropdown/SingleSelect/SingleSelect.stories.tsx @@ -91,14 +91,16 @@ NotClearable.args = { export const HasValueSelected = Template.bind({}) HasValueSelected.args = { value: itemToLabelString(INITIAL_COMBOBOX_ITEMS[0]), - defaultIsOpen: true, + initialIsOpen: true, } export const StringValues = Template.bind({}) StringValues.args = { items: ['this only has only string values', 'this is cool'], - value: 'this', - defaultIsOpen: true, + comboboxProps: { + initialInputValue: 'this', + }, + initialIsOpen: true, } export const WithIconSelected = Template.bind({}) @@ -121,14 +123,16 @@ WithIconSelected.args = { }, ], value: 'Radio button', - defaultIsOpen: true, + initialIsOpen: true, isDisabled: false, } export const WithHalfFilledValue = Template.bind({}) WithHalfFilledValue.args = { - value: 'Multiple words and', - defaultIsOpen: true, + comboboxProps: { + initialInputValue: 'Multiple words and', + }, + initialIsOpen: true, } export const Invalid = Template.bind({}) @@ -162,6 +166,7 @@ export const Playground: Story = ({ items, isReadOnly }) => { return (
extends SharedSelectContextReturnProps, FormControlOptions { - /** Controlled input value */ + /** Controlled selected value */ value: string - /** Controlled input onChange handler */ + /** Controlled selected item onChange handler */ onChange: (value: string) => void /** Function based on which items in dropdown are filtered. Default filter filters by fuzzy match. */ filter?(items: Item[], value: string): Item[] - /** Initial dropdown opened state. Defaults to `false`. */ - defaultIsOpen?: boolean + /** Initial dropdown opened state. */ + initialIsOpen?: boolean + /** Props to override default useComboboxProps, if any. */ + comboboxProps?: Partial> + /** aria-describedby to be attached to the combobox input, if any. */ + inputAria?: { + id: string + label: string + } children: React.ReactNode } export const SingleSelectProvider = ({ @@ -30,29 +37,42 @@ export const SingleSelectProvider = ({ filter = defaultFilter, nothingFoundLabel = 'No matching results', placeholder = 'Select an option', - clearButtonLabel = 'Clear dropdown', + clearButtonLabel = 'Clear dropdown input', isClearable = true, isSearchable = true, - defaultIsOpen, + initialIsOpen, isInvalid, isReadOnly, isDisabled, isRequired, children, + inputAria: inputAriaProp, + comboboxProps = {}, }: SingleSelectProviderProps): JSX.Element => { const { items, getItemByValue } = useItems({ rawItems }) const [isFocused, setIsFocused] = useState(false) - const filteredItems = useMemo( - () => (value ? filter(items, value) : items), - [filter, value, items], + const getFilteredItems = useCallback( + (filterValue?: string) => + filterValue ? filter(items, filterValue) : items, + [filter, items], + ) + const [filteredItems, setFilteredItems] = useState( + getFilteredItems( + comboboxProps.initialInputValue ?? comboboxProps.inputValue, + ), ) - const getDefaultSelectedValue = useCallback( + const getInitialSelectedValue = useCallback( () => getItemByValue(value)?.item ?? null, [getItemByValue, value], ) + const resetItems = useCallback( + () => setFilteredItems(getFilteredItems()), + [getFilteredItems], + ) + const { toggleMenu, isOpen, @@ -65,21 +85,49 @@ export const SingleSelectProvider = ({ highlightedIndex, selectItem, selectedItem, + inputValue, + setInputValue, } = useCombobox({ + labelId: `${name}-label`, + inputId: name, + defaultInputValue: '', + defaultHighlightedIndex: 0, items: filteredItems, - inputValue: value, - defaultIsOpen, - onInputValueChange: ({ inputValue }) => { - if (!inputValue) { - selectItem(null) - } - onChange(inputValue ?? '') - }, - defaultSelectedItem: getDefaultSelectedValue(), + initialIsOpen, + initialSelectedItem: getInitialSelectedValue(), itemToString: itemToValue, onSelectedItemChange: ({ selectedItem }) => { onChange(itemToValue(selectedItem)) }, + onStateChange: ({ inputValue, type }) => { + switch (type) { + case useCombobox.stateChangeTypes.FunctionSetInputValue: + case useCombobox.stateChangeTypes.InputChange: + setFilteredItems(getFilteredItems(inputValue)) + break + default: + return + } + }, + stateReducer: (_state, { changes, type }) => { + switch (type) { + case useCombobox.stateChangeTypes.FunctionSelectItem: + case useCombobox.stateChangeTypes.InputKeyDownEnter: + case useCombobox.stateChangeTypes.InputBlur: + case useCombobox.stateChangeTypes.ItemClick: { + resetItems() + return { + ...changes, + // Clear inputValue on item selection + inputValue: '', + isOpen: false, + } + } + default: + return changes + } + }, + ...comboboxProps, }) const isItemSelected = useCallback( @@ -89,7 +137,23 @@ export const SingleSelectProvider = ({ [selectedItem], ) - const styles = useMultiStyleConfig('SingleSelect', { isClearable }) + const resetInputValue = useCallback(() => setInputValue(''), [setInputValue]) + + const styles = useMultiStyleConfig('SingleSelect', { + isClearable, + }) + + const inputAria = useMemo(() => { + if (inputAriaProp) return inputAriaProp + let label = 'No option selected' + if (selectedItem) { + label = `Option ${itemToValue(selectedItem)}, selected` + } + return { + id: `${name}-current-selection`, + label, + } + }, [inputAriaProp, name, selectedItem]) return ( {children} diff --git a/frontend/src/components/Dropdown/components/SelectCombobox/ComboboxClearButton.tsx b/frontend/src/components/Dropdown/components/SelectCombobox/ComboboxClearButton.tsx index 61c18bdb8a..9fbc0fd3d5 100644 --- a/frontend/src/components/Dropdown/components/SelectCombobox/ComboboxClearButton.tsx +++ b/frontend/src/components/Dropdown/components/SelectCombobox/ComboboxClearButton.tsx @@ -20,6 +20,8 @@ export const ComboboxClearButton = (): JSX.Element | null => { return ( { - const { styles, isDisabled } = useSelectContext() - - return ( - - - - ) -} - -// So input group knows to add right padding to the inner input. -LabelIcon.id = InputLeftElement.id diff --git a/frontend/src/components/Dropdown/components/SelectCombobox/SelectCombobox.tsx b/frontend/src/components/Dropdown/components/SelectCombobox/SelectCombobox.tsx index eab4ee29eb..53a77daaf4 100644 --- a/frontend/src/components/Dropdown/components/SelectCombobox/SelectCombobox.tsx +++ b/frontend/src/components/Dropdown/components/SelectCombobox/SelectCombobox.tsx @@ -1,13 +1,19 @@ import { forwardRef, useMemo } from 'react' -import { Flex, InputGroup } from '@chakra-ui/react' +import { + Flex, + Icon, + InputGroup, + Stack, + Text, + VisuallyHidden, +} from '@chakra-ui/react' import Input from '~components/Input' import { useSelectContext } from '../../SelectContext' -import { itemToIcon } from '../../utils/itemUtils' +import { itemToIcon, itemToLabelString } from '../../utils/itemUtils' import { ComboboxClearButton } from './ComboboxClearButton' -import { LabelIcon } from './LabelIcon' import { ToggleChevron } from './ToggleChevron' export const SelectCombobox = forwardRef( @@ -22,20 +28,30 @@ export const SelectCombobox = forwardRef( isSearchable, isReadOnly, isInvalid, + inputValue, isRequired, placeholder, setIsFocused, isOpen, + resetInputValue, + inputAria, } = useSelectContext() - const selectedItemIcon = useMemo( - () => itemToIcon(selectedItem), + const selectedItemMeta = useMemo( + () => ({ + icon: itemToIcon(selectedItem), + label: itemToLabelString(selectedItem), + }), [selectedItem], ) + return ( + {inputAria.label} ( onFocus: () => setIsFocused(true), })} > - {selectedItemIcon ? : null} + + {selectedItemMeta.icon ? ( + + ) : null} + + {selectedItemMeta.label} + + !isOpen && resetInputValue(), ref, + 'aria-describedby': inputAria.id, })} /> diff --git a/frontend/src/constants/validation.ts b/frontend/src/constants/validation.ts index 79f03db192..6f788ae951 100644 --- a/frontend/src/constants/validation.ts +++ b/frontend/src/constants/validation.ts @@ -3,3 +3,6 @@ export const REQUIRED_ERROR = 'This field is required' export const INVALID_EMAIL_ERROR = 'Please enter a valid email' export const INVALID_EMAIL_DOMAIN_ERROR = 'The entered email does not belong to an allowed email domain' + +export const INVALID_DROPDOWN_OPTION_ERROR = + 'Entered value is not a valid dropdown option' diff --git a/frontend/src/features/public-form/components/FormFields/FormField.tsx b/frontend/src/features/public-form/components/FormFields/FormField.tsx index bd883287f0..35193fbb52 100644 --- a/frontend/src/features/public-form/components/FormFields/FormField.tsx +++ b/frontend/src/features/public-form/components/FormFields/FormField.tsx @@ -7,6 +7,7 @@ import { AttachmentField, CheckboxField, DecimalField, + DropdownField, EmailField, HomeNoField, ImageField, @@ -110,6 +111,10 @@ export const FormField = ({ field, colorTheme }: FormFieldProps) => { return ( ) + case BasicField.Dropdown: + return ( + + ) case BasicField.Table: return default: diff --git a/frontend/src/templates/Field/Dropdown/DropdownField.stories.tsx b/frontend/src/templates/Field/Dropdown/DropdownField.stories.tsx new file mode 100644 index 0000000000..b908996314 --- /dev/null +++ b/frontend/src/templates/Field/Dropdown/DropdownField.stories.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { Text } from '@chakra-ui/react' +import { Meta, Story } from '@storybook/react' + +import { BasicField } from '~shared/types/field' + +import Button from '~components/Button' + +import { + DropdownField as DropdownFieldComponent, + DropdownFieldProps, + DropdownFieldSchema, +} from './DropdownField' + +const baseSchema: DropdownFieldSchema = { + title: 'Favourite dropdown option', + description: '', + required: true, + disabled: false, + fieldType: BasicField.Dropdown, + fieldOptions: [ + 'This is the first option', + 'This is the second option', + 'Short third option', + ], + _id: 'random-id', +} + +export default { + title: 'Templates/Field/DropdownField', + component: DropdownFieldComponent, + decorators: [], + parameters: { + docs: { + // Required in this story due to react-hook-form conflicting with + // Storybook somehow. + // See https://github.com/storybookjs/storybook/issues/12747. + source: { + type: 'code', + }, + }, + }, + args: { + schema: baseSchema, + }, +} as Meta + +interface StoryDropdownFieldProps extends DropdownFieldProps { + defaultValue?: string +} + +const Template: Story = ({ + defaultValue, + ...args +}) => { + const formMethods = useForm({ + defaultValues: { + [args.schema._id]: defaultValue, + }, + }) + + const [submitValues, setSubmitValues] = useState() + + const onSubmit = (values: Record) => { + setSubmitValues(values[args.schema._id] || 'Nothing was selected') + } + + useEffect(() => { + if (defaultValue) { + formMethods.trigger() + } + // Only want it to run once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + + + + {submitValues && You have submitted: {submitValues}} + + + ) +} + +export const ValidationRequired = Template.bind({}) + +export const ValidationOptional = Template.bind({}) +ValidationOptional.args = { + schema: { ...baseSchema, required: false }, +} + +export const ValidationInvalidValue = Template.bind({}) +ValidationInvalidValue.args = { + defaultValue: 'This is not a valid option', +} diff --git a/frontend/src/templates/Field/Dropdown/DropdownField.test.tsx b/frontend/src/templates/Field/Dropdown/DropdownField.test.tsx new file mode 100644 index 0000000000..6d71efa574 --- /dev/null +++ b/frontend/src/templates/Field/Dropdown/DropdownField.test.tsx @@ -0,0 +1,134 @@ +import { composeStories } from '@storybook/testing-react' +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { REQUIRED_ERROR } from '~constants/validation' + +import * as stories from './DropdownField.stories' + +const { ValidationOptional, ValidationRequired } = composeStories(stories) + +describe('required field', () => { + it('renders error when field is not selected before submitting', async () => { + // Arrange + render() + const submitButton = screen.getByRole('button', { name: /submit/i }) + + // Act + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show error message. + expect(screen.getByText(REQUIRED_ERROR)).toBeInTheDocument() + }) + + it('renders success when a valid option is typed', async () => { + // Arrange + render() + + const dropdownOptions = ValidationRequired.args?.schema + ?.fieldOptions as string[] + const optionToType = dropdownOptions[0] + const submitButton = screen.getByRole('button', { name: /submit/i }) + const input = screen.getByRole('textbox') as HTMLInputElement + // Act + userEvent.type(input, `${optionToType}{enter}`) + // Act required due to react-hook-form usage. + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show success message. + expect( + screen.getByText(`You have submitted: ${optionToType}`), + ).toBeInTheDocument() + }) + + it('renders success when a valid option is selected', async () => { + // Arrange + render() + + const dropdownOptions = ValidationRequired.args?.schema + ?.fieldOptions as string[] + const expectedOption = dropdownOptions[1] + const submitButton = screen.getByRole('button', { name: /submit/i }) + const input = screen.getByRole('textbox') as HTMLInputElement + // Act + userEvent.click(input) + // Arrow down twice and select input + userEvent.type(input, '{arrowdown}{arrowdown}{enter}') + // Act required due to react-hook-form usage. + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show success message. + expect( + screen.getByText(`You have submitted: ${expectedOption}`), + ).toBeInTheDocument() + }) +}) + +describe('optional field', () => { + it('renders success even when field has no input before submitting', async () => { + // Arrange + render() + const submitButton = screen.getByRole('button', { name: /submit/i }) + + // Act + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show success message. + expect(screen.getByText(/you have submitted/i)).toBeInTheDocument() + }) + + it('renders success when a valid option is partially typed then selected', async () => { + // Arrange + render() + + const dropdownOptions = ValidationRequired.args?.schema + ?.fieldOptions as string[] + const expectedOption = dropdownOptions[1] + const submitButton = screen.getByRole('button', { name: /submit/i }) + const input = screen.getByRole('textbox') as HTMLInputElement + // Act + userEvent.click(input) + // Type the middle few characters of the option; dropdown should match properly, + // then select the option. + userEvent.type(input, `${expectedOption.slice(5, 16)}{arrowdown}{enter}`) + // Act required due to react-hook-form usage. + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show success message. + expect( + screen.getByText(`You have submitted: ${expectedOption}`), + ).toBeInTheDocument() + }) +}) + +describe('dropdown validation', () => { + it('renders error when input does not match any dropdown option', async () => { + // Arrange + render() + + const dropdownOptions = ValidationRequired.args?.schema + ?.fieldOptions as string[] + const submitButton = screen.getByRole('button', { name: /submit/i }) + const inputElement = screen.getByRole('textbox') as HTMLInputElement + const inputToType = 'this is not a valid option' + + expect(dropdownOptions.includes(inputToType)).toEqual(false) + + // Act + userEvent.click(inputElement) + userEvent.type(inputElement, inputToType) + userEvent.tab() + // Input should blur and input value should be cleared (since nothing was selected). + expect(inputElement.value).toEqual('') + // Act required due to react-hook-form usage. + await act(async () => userEvent.click(submitButton)) + + // Assert + expect(screen.getByText(REQUIRED_ERROR)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/templates/Field/Dropdown/DropdownField.tsx b/frontend/src/templates/Field/Dropdown/DropdownField.tsx new file mode 100644 index 0000000000..68fa63faf7 --- /dev/null +++ b/frontend/src/templates/Field/Dropdown/DropdownField.tsx @@ -0,0 +1,40 @@ +import { useMemo } from 'react' +import { Controller } from 'react-hook-form' + +import { DropdownFieldBase, FormFieldWithId } from '~shared/types/field' + +import { createDropdownValidationRules } from '~utils/fieldValidation' +import { SingleSelect } from '~components/Dropdown/SingleSelect' + +import { BaseFieldProps, FieldContainer } from '../FieldContainer' + +export type DropdownFieldSchema = FormFieldWithId +export interface DropdownFieldProps extends BaseFieldProps { + schema: DropdownFieldSchema +} + +/** + * @precondition Must have a parent `react-hook-form#FormProvider` component. + */ +export const DropdownField = ({ + schema, + questionNumber, +}: DropdownFieldProps): JSX.Element => { + const validationRules = useMemo( + () => createDropdownValidationRules(schema), + [schema], + ) + + return ( + + ( + + )} + /> + + ) +} diff --git a/frontend/src/templates/Field/Dropdown/index.ts b/frontend/src/templates/Field/Dropdown/index.ts new file mode 100644 index 0000000000..389e13004b --- /dev/null +++ b/frontend/src/templates/Field/Dropdown/index.ts @@ -0,0 +1 @@ +export { DropdownField as default } from './DropdownField' diff --git a/frontend/src/templates/Field/FieldContainer.tsx b/frontend/src/templates/Field/FieldContainer.tsx index 8ab620581b..4088aff056 100644 --- a/frontend/src/templates/Field/FieldContainer.tsx +++ b/frontend/src/templates/Field/FieldContainer.tsx @@ -51,6 +51,7 @@ export const FieldContainer = ({ isDisabled={schema.disabled} isReadOnly={isValid && isSubmitting} isInvalid={!!error} + id={schema._id} > = (props) => { field: merge(inputVariantOutline.field, { zIndex: 1, borderRightRadius: isClearable ? 0 : undefined, + bg: 'white', + gridArea: '1 / 1 / 2 / 3', }), clearbutton: { ml: '-1px', diff --git a/frontend/src/utils/fieldValidation.ts b/frontend/src/utils/fieldValidation.ts index e86959aa2d..ac2378fe21 100644 --- a/frontend/src/utils/fieldValidation.ts +++ b/frontend/src/utils/fieldValidation.ts @@ -10,6 +10,7 @@ import { AttachmentFieldBase, CheckboxFieldBase, DecimalFieldBase, + DropdownFieldBase, EmailFieldBase, FieldBase, HomenoFieldBase, @@ -32,6 +33,7 @@ import { import { isUenValid } from '~shared/utils/uen-validation' import { + INVALID_DROPDOWN_OPTION_ERROR, INVALID_EMAIL_DOMAIN_ERROR, INVALID_EMAIL_ERROR, REQUIRED_ERROR, @@ -76,6 +78,23 @@ export const createBaseValidationRules = ( } } +export const createDropdownValidationRules: ValidationRuleFn< + DropdownFieldBase +> = (schema): RegisterOptions => { + // TODO(#3360): Handle MyInfo dropdown validation + return { + ...createBaseValidationRules(schema), + validate: { + validOptions: (value: string) => { + if (!value) return + return ( + schema.fieldOptions.includes(value) || INVALID_DROPDOWN_OPTION_ERROR + ) + }, + }, + } +} + export const createRatingValidationRules: ValidationRuleFn = ( schema, ): RegisterOptions => {