diff --git a/source/frontend/src/components/common/form/TypeaheadSelect.test.tsx b/source/frontend/src/components/common/form/TypeaheadSelect.test.tsx new file mode 100644 index 0000000000..56c2134ae2 --- /dev/null +++ b/source/frontend/src/components/common/form/TypeaheadSelect.test.tsx @@ -0,0 +1,125 @@ +import { Formik, FormikProps } from 'formik'; +import React from 'react'; + +import { act, render, RenderOptions, screen, userEvent } from '@/utils/test-utils'; + +import { SelectOption } from './Select'; +import { ITypeaheadSelectProps, TypeaheadSelect } from './TypeaheadSelect'; + +const countries: SelectOption[] = [ + { label: 'Austria', value: 'AT' }, + { label: 'United States', value: 'US' }, + { label: 'Ireland', value: 'IE' }, +]; + +const onChange = jest.fn(); +const onBlur = jest.fn(); + +describe('TypeaheadSelect component', () => { + const setup = ( + options: RenderOptions & { props?: Partial } & { + initialValues?: { country: SelectOption }; + } = {}, + ) => { + const formikRef = React.createRef>(); + + const utils = render( + + + , + { ...options }, + ); + + return { + ...utils, + getFormikRef: () => formikRef, + findInput: async () => screen.findByRole('combobox'), + findMenu: async () => screen.findByRole('listbox'), + findItems: async () => screen.findAllByRole('option'), + }; + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders as expected', () => { + const { asFragment } = setup(); + expect(asFragment()).toMatchSnapshot(); + }); + + it('displays the placeholder text', async () => { + const { findInput } = setup({ props: { placeholder: 'test placeholder' } }); + const input = await findInput(); + expect(input.placeholder).toBe('test placeholder'); + }); + + it('shows a tooltip as intended', () => { + setup({ props: { tooltip: 'test tooltip' } }); + expect(document.querySelector('svg[class="tooltip-icon"]')).toBeInTheDocument(); + }); + + it('should disable the input if the component is disabled', async () => { + const { findInput } = setup({ props: { disabled: true } }); + const input = await findInput(); + expect(input).toBeDisabled(); + }); + + it('shows the menu when the input is focused', async () => { + const { findInput, findMenu } = setup(); + const input = await findInput(); + await act(async () => input.focus()); + expect(await findMenu()).toBeInTheDocument(); + }); + + it('displays the correct number of options', async () => { + const { findInput } = setup(); + const input = await findInput(); + await act(async () => input.focus()); + expect(screen.getAllByRole('option').length).toBe(countries.length); + }); + + it(`changes the selected value and calls 'onChange' when a menu item is clicked`, async () => { + const { findInput, findItems, getFormikRef } = setup(); + + const input = await findInput(); + await act(async () => input.focus()); + const items = await findItems(); + items[0].style.pointerEvents = 'auto'; + await act(async () => userEvent.click(items[0])); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(getFormikRef().current?.values?.country).toBe(countries[0]); + }); + + it(`changes the selected value and calls 'onChange' when a menu item is selected via keyboard`, async () => { + const { findInput, getFormikRef } = setup(); + + const input = await findInput(); + await act(async () => input.focus()); + await act(async () => userEvent.keyboard('{arrowdown}')); + await act(async () => userEvent.keyboard('{Enter}')); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(getFormikRef().current?.values?.country).toBe(countries[0]); + }); + + it(`calls 'onBlur' when input looses focus`, async () => { + const { findInput } = setup(); + const input = await findInput(); + await act(async () => input.focus()); + await act(async () => input.blur()); + expect(onBlur).toHaveBeenCalledTimes(1); + }); +}); diff --git a/source/frontend/src/components/common/form/TypeaheadSelect.tsx b/source/frontend/src/components/common/form/TypeaheadSelect.tsx new file mode 100644 index 0000000000..a70fc1e27a --- /dev/null +++ b/source/frontend/src/components/common/form/TypeaheadSelect.tsx @@ -0,0 +1,127 @@ +import cx from 'classnames'; +import { getIn, useFormikContext } from 'formik'; +import React from 'react'; +import Form from 'react-bootstrap/Form'; +import { Typeahead } from 'react-bootstrap-typeahead'; +import styled from 'styled-components'; + +import TooltipIcon from '../TooltipIcon'; +import TooltipWrapper from '../TooltipWrapper'; +import { DisplayError } from './DisplayError'; +import { SelectOption } from './Select'; + +export interface ITypeaheadSelectProps { + /* The form field name */ + field: string; + /* The form component label */ + label?: string; + /* Whether or not the field is required. */ + required?: boolean; + /** Specifies that the HTML element should be disabled */ + disabled?: boolean; + /* Optional tooltip text to display after the label */ + tooltip?: string; + /* Whether or not to display errors in a tooltip instead of in a div */ + displayErrorTooltips?: boolean; + /* Class names to apply to the outer form group wrapper */ + className?: string; + /* Short hint that describes the expected value of an element */ + placeholder?: string; + /* Minimum user input before displaying results. */ + minLength?: number; + /* Array in the shape of [ { value: string | number, label: string } ] */ + options: SelectOption[]; + /* Invoked when a user changes the selected option */ + onChange?: (selected: SelectOption | undefined) => void; + /* Invoked when the typeahead loses focus */ + onBlur?: (e: Event) => void; +} + +/** + * Formik-connected + + + + + +`; diff --git a/source/frontend/src/features/mapSideBar/project/add/AddProjectForm.test.tsx b/source/frontend/src/features/mapSideBar/project/add/AddProjectForm.test.tsx index 72a9b733c9..308640fecf 100644 --- a/source/frontend/src/features/mapSideBar/project/add/AddProjectForm.test.tsx +++ b/source/frontend/src/features/mapSideBar/project/add/AddProjectForm.test.tsx @@ -88,11 +88,13 @@ describe('AddProjectForm component', () => { getProductCodeTextBox: (index: number) => utils.container.querySelector(`input[name="products.${index}.code"]`) as HTMLInputElement, getCostTypeDropdown: () => - utils.container.querySelector(`select[name="costTypeCode"]`) as HTMLSelectElement, + utils.container.querySelector(`[name="typeahead-select-costTypeCode"]`) as HTMLElement, getWorkActivityDropdown: () => - utils.container.querySelector(`select[name="workActivityCode"]`) as HTMLSelectElement, + utils.container.querySelector(`[name="typeahead-select-workActivityCode"]`) as HTMLElement, getBusinessFunctionDropdown: () => - utils.container.querySelector(`select[name="businessFunctionCode"]`) as HTMLSelectElement, + utils.container.querySelector( + `[name="typeahead-select-businessFunctionCode"]`, + ) as HTMLElement, }; }; @@ -131,7 +133,7 @@ describe('AddProjectForm component', () => { const input = getNameTextbox(); const number = getNumberTextbox(); - const select = getRegionDropdown(); + const region = getRegionDropdown(); const status = getStatusDropdown(); const summary = getSummaryTextbox(); const costType = getCostTypeDropdown(); @@ -139,7 +141,7 @@ describe('AddProjectForm component', () => { const businessFunction = getBusinessFunctionDropdown(); expect(input).toBeVisible(); - expect(select).toBeVisible(); + expect(region).toBeVisible(); expect(number).toBeVisible(); expect(status).toBeVisible(); expect(summary).toBeVisible(); @@ -147,12 +149,12 @@ describe('AddProjectForm component', () => { expect(input.tagName).toBe('INPUT'); expect(number.tagName).toBe('INPUT'); expect(summary.tagName).toBe('TEXTAREA'); - expect(select.tagName).toBe('SELECT'); + expect(region.tagName).toBe('SELECT'); expect(status.tagName).toBe('SELECT'); - expect(costType.tagName).toBe('SELECT'); - expect(workActivity.tagName).toBe('SELECT'); - expect(businessFunction.tagName).toBe('SELECT'); + expect(costType.tagName).toBe('INPUT'); + expect(workActivity.tagName).toBe('INPUT'); + expect(businessFunction.tagName).toBe('INPUT'); }); it('should validate character limits', async () => { diff --git a/source/frontend/src/features/mapSideBar/project/add/AddProjectForm.tsx b/source/frontend/src/features/mapSideBar/project/add/AddProjectForm.tsx index fc839d790f..cd737793ae 100644 --- a/source/frontend/src/features/mapSideBar/project/add/AddProjectForm.tsx +++ b/source/frontend/src/features/mapSideBar/project/add/AddProjectForm.tsx @@ -5,6 +5,7 @@ import { Link } from 'react-router-dom'; import styled from 'styled-components'; import { Form, Input, Select, SelectOption, TextArea } from '@/components/common/form'; +import { TypeaheadSelect } from '@/components/common/form/TypeaheadSelect'; import { UserRegionSelectContainer } from '@/components/common/form/UserRegionSelect/UserRegionSelectContainer'; import { Section } from '@/components/common/Section/Section'; import { SectionField } from '@/components/common/Section/SectionField'; @@ -88,21 +89,13 @@ const AddProjectForm = React.forwardRef, IAddProjectFor
- + - - - + + + + @@ -593,19 +618,40 @@ exports[`AddProjectContainer component renders as expected 1`] = ` class="c7 text-left col" >
- + + +
+ @@ -625,19 +671,40 @@ exports[`AddProjectContainer component renders as expected 1`] = ` class="c7 text-left col" >
- + + +
+ @@ -663,7 +730,7 @@ exports[`AddProjectContainer component renders as expected 1`] = ` class="collapse show" >