Skip to content

Commit

Permalink
PSP-7157 Project dropdowns (#3594)
Browse files Browse the repository at this point in the history
* Test updates

* Show <Code> - <Code description> for selectable options

* Create type-ahead select component

* Replace dropdowns with typeahead

* Refactor project update forms to use typeahead

* Forward ref property to inner typeahead component

* Expose onBlur callback

* Add disabled prop to TypeaheadSelect

* Implement unit tests for common component

* Fix failing tests
  • Loading branch information
asanchezr authored Nov 16, 2023
1 parent 5a8afb2 commit 3337769
Show file tree
Hide file tree
Showing 11 changed files with 779 additions and 242 deletions.
125 changes: 125 additions & 0 deletions source/frontend/src/components/common/form/TypeaheadSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ITypeaheadSelectProps> } & {
initialValues?: { country: SelectOption };
} = {},
) => {
const formikRef = React.createRef<FormikProps<any>>();

const utils = render(
<Formik
innerRef={formikRef}
initialValues={options?.initialValues ?? { country: undefined }}
onSubmit={jest.fn()}
>
<TypeaheadSelect
field="country"
options={countries}
placeholder={options?.props?.placeholder ?? 'Select a country'}
{...(options?.props ?? {})}
onChange={onChange}
onBlur={onBlur}
/>
</Formik>,
{ ...options },
);

return {
...utils,
getFormikRef: () => formikRef,
findInput: async () => screen.findByRole<HTMLInputElement>('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);
});
});
127 changes: 127 additions & 0 deletions source/frontend/src/components/common/form/TypeaheadSelect.tsx
Original file line number Diff line number Diff line change
@@ -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 <input> 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 <Select> form control with type-ahead capabilities.
*/
export const TypeaheadSelect = React.forwardRef<Typeahead<SelectOption>, ITypeaheadSelectProps>(
(props, ref) => {
const {
field,
label,
required,
disabled,
tooltip,
displayErrorTooltips,
className,
placeholder,
minLength = 0,
options,
onChange,
onBlur,
} = props;
const { errors, touched, values, setFieldValue, setFieldTouched } = useFormikContext();
const value = getIn(values, field);
const error = getIn(errors, field);
const touch = getIn(touched, field);
const errorTooltip = error && touch && displayErrorTooltips ? error : undefined;

const onSelectChange = React.useCallback(
(selectedArray: SelectOption[]) => {
const selected = selectedArray.length ? selectedArray[0] : undefined;
setFieldValue(field, selected);
if (typeof onChange === 'function') {
onChange(selected);
}
},
[field, setFieldValue, onChange],
);

const onSelectBlur = React.useCallback(
(e: Event) => {
setFieldTouched(field, true);
if (typeof onBlur === 'function') {
onBlur(e);
}
},
[field, onBlur, setFieldTouched],
);

return (
<StyledFormGroup
className={cx({ required: required }, className)}
data-testid={`typeahead-select-${field}`}
>
{label && <Form.Label htmlFor={`typeahead-select-${field}`}>{label}</Form.Label>}
{tooltip && <TooltipIcon toolTipId={`${field}-tooltip`} toolTip={tooltip} />}
<TooltipWrapper toolTipId={`${field}-error-tooltip}`} toolTip={errorTooltip}>
<Typeahead
ref={ref}
id={`typeahead-select-${field}`}
inputProps={{
name: `typeahead-select-${field}`,
id: `typeahead-select-${field}`,
}}
disabled={disabled}
flip
clearButton
highlightOnlyResult
multiple={false}
isInvalid={touch && error}
placeholder={placeholder ?? 'Type to search...'}
options={options}
labelKey="label"
selected={value ? [].concat(value) : []}
minLength={minLength}
onChange={onSelectChange}
onBlur={onSelectBlur}
></Typeahead>
</TooltipWrapper>
{!displayErrorTooltips && <DisplayError field={field} />}
</StyledFormGroup>
);
},
);

const StyledFormGroup = styled(Form.Group)`
// This is the close button of the type-ahead
button.close {
font-size: 2.4rem;
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`TypeaheadSelect component renders as expected 1`] = `
<DocumentFragment>
<div
class="Toastify"
/>
<div />
.c0 button.close {
font-size: 2.4rem;
}
<div
class="c0 form-group"
data-testid="typeahead-select-country"
>
<div
class="rbt"
style="outline: none; position: relative;"
tabindex="-1"
>
<div
style="display: flex; flex: 1; height: 100%; position: relative;"
>
<input
aria-autocomplete="both"
aria-expanded="false"
aria-haspopup="listbox"
autocomplete="off"
class="rbt-input-main form-control rbt-input"
id="typeahead-select-country"
name="typeahead-select-country"
placeholder="Select a country"
role="combobox"
type="text"
value=""
/>
<input
aria-hidden="true"
class="rbt-input-hint"
readonly=""
style="background-color: transparent; border-color: transparent; box-shadow: none; color: rgba(0, 0, 0, 0.35); left: 0px; pointer-events: none; position: absolute; top: 0px; width: 100%;"
tabindex="-1"
value=""
/>
</div>
</div>
</div>
</DocumentFragment>
`;
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
};

Expand Down Expand Up @@ -131,28 +133,28 @@ describe('AddProjectForm component', () => {

const input = getNameTextbox();
const number = getNumberTextbox();
const select = getRegionDropdown();
const region = getRegionDropdown();
const status = getStatusDropdown();
const summary = getSummaryTextbox();
const costType = getCostTypeDropdown();
const workActivity = getWorkActivityDropdown();
const businessFunction = getBusinessFunctionDropdown();

expect(input).toBeVisible();
expect(select).toBeVisible();
expect(region).toBeVisible();
expect(number).toBeVisible();
expect(status).toBeVisible();
expect(summary).toBeVisible();

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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -88,21 +89,13 @@ const AddProjectForm = React.forwardRef<FormikProps<ProjectForm>, IAddProjectFor
</Section>
<Section header="Associated Codes">
<SectionField label="Cost type" labelWidth="2">
<Select field="costTypeCode" options={costTypeOptions} placeholder="Select..." />
<TypeaheadSelect field="costTypeCode" options={costTypeOptions} />
</SectionField>
<SectionField label="Work activity" labelWidth="2">
<Select
field="workActivityCode"
options={workActivityOptions}
placeholder="Select..."
/>
<TypeaheadSelect field="workActivityCode" options={workActivityOptions} />
</SectionField>
<SectionField label="Business function" labelWidth="2">
<Select
field="businessFunctionCode"
options={businessFunctionOptions}
placeholder="Select..."
/>
<TypeaheadSelect field="businessFunctionCode" options={businessFunctionOptions} />
</SectionField>
</Section>
<ProductsArrayForm formikProps={formikProps} field="products" />
Expand Down
Loading

0 comments on commit 3337769

Please sign in to comment.