From c8db045634b70f971e4acf81936f7e351a220ea7 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Thu, 9 Nov 2023 17:16:03 -0800 Subject: [PATCH 01/10] Test updates --- .../detail/ProjectSummaryView.test.tsx | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/ProjectSummaryView.test.tsx b/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/ProjectSummaryView.test.tsx index 5002c280bf..49b5b67357 100644 --- a/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/ProjectSummaryView.test.tsx +++ b/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/ProjectSummaryView.test.tsx @@ -1,3 +1,4 @@ +import { Claims } from '@/constants'; import { mockProjectGetResponse } from '@/mocks/projects.mock'; import { render, RenderOptions } from '@/utils/test-utils'; @@ -9,24 +10,41 @@ const onEdit = jest.fn(); describe('ProjectSummaryView component', () => { // render component under test - const setup = (props: IProjectSummaryViewProps, renderOptions: RenderOptions = {}) => { - const utils = render(, { - useMockAuthentication: true, - ...renderOptions, - }); + const setup = ( + renderOptions: RenderOptions & { props?: Partial } = {}, + ) => { + const utils = render( + , + { + ...renderOptions, + useMockAuthentication: true, + }, + ); return { ...utils }; }; afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('matches snapshot', () => { - const { asFragment } = setup({ - project: mockProjectGetResponse(), - onEdit, - }); + const { asFragment } = setup(); expect(asFragment()).toMatchSnapshot(); }); + + it('renders the edit button when the user project edit permissions', () => { + const { getByTitle } = setup({ claims: [Claims.PROJECT_EDIT] }); + const editButton = getByTitle('Edit project'); + expect(editButton).toBeVisible(); + }); + + it('does not render the edit button when the user does not have project edit permissions', () => { + const { queryByTitle } = setup({ claims: [] }); + const editButton = queryByTitle('Edit project'); + expect(editButton).toBeNull(); + }); }); From bd0786b268b3253a76953bec1680b9d1de925b4e Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Fri, 10 Nov 2023 17:23:27 -0800 Subject: [PATCH 02/10] Show - for selectable options --- source/frontend/src/utils/financialCodeUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/frontend/src/utils/financialCodeUtils.ts b/source/frontend/src/utils/financialCodeUtils.ts index a8aaeeb330..fc49de6b37 100644 --- a/source/frontend/src/utils/financialCodeUtils.ts +++ b/source/frontend/src/utils/financialCodeUtils.ts @@ -12,7 +12,7 @@ export function toDropDownOptions( .filter(c => includeExpired || !isExpiredCode(c)) .map(c => { return { - label: c.description!, + label: `${c.code} - ${c.description}`, value: c.id!, }; }); From 09bcf196ae30128913af941a54f2bd74c8cffd6f Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Fri, 10 Nov 2023 22:25:01 -0800 Subject: [PATCH 03/10] Create type-ahead select component --- .../common/form/TypeaheadSelect.tsx | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 source/frontend/src/components/common/form/TypeaheadSelect.tsx 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..b05dee2c70 --- /dev/null +++ b/source/frontend/src/components/common/form/TypeaheadSelect.tsx @@ -0,0 +1,111 @@ +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; + /* 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; +} + +/** + * Formik-connected + + - form control with type-ahead capabilities. */ -export const TypeaheadSelect: React.FC = ({ - field, - label, - required, - tooltip, - displayErrorTooltips, - className, - placeholder, - minLength = 0, - options, - onChange, -}) => { - 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; +export const TypeaheadSelect = React.forwardRef, ITypeaheadSelectProps>( + (props, ref) => { + const { + field, + label, + required, + tooltip, + displayErrorTooltips, + className, + placeholder, + minLength = 0, + options, + onChange, + } = 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 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( - () => setFieldTouched(field, true), - [field, setFieldTouched], - ); + const onSelectBlur = React.useCallback( + () => setFieldTouched(field, true), + [field, setFieldTouched], + ); - return ( - - {label && {label}} - {tooltip && } - - - - {!displayErrorTooltips && } - - ); -}; + return ( + + {label && {label}} + {tooltip && } + + + + {!displayErrorTooltips && } + + ); + }, +); const StyledFormGroup = styled(Form.Group)` // This is the close button of the type-ahead From 662d1f9757bb4c847addf948762b2618eb5903a5 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Wed, 15 Nov 2023 18:17:39 -0800 Subject: [PATCH 07/10] Expose onBlur callback --- .../src/components/common/form/TypeaheadSelect.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/source/frontend/src/components/common/form/TypeaheadSelect.tsx b/source/frontend/src/components/common/form/TypeaheadSelect.tsx index ef157c5d63..f9c5c4aa42 100644 --- a/source/frontend/src/components/common/form/TypeaheadSelect.tsx +++ b/source/frontend/src/components/common/form/TypeaheadSelect.tsx @@ -31,6 +31,8 @@ export interface ITypeaheadSelectProps { 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; } /** @@ -49,6 +51,7 @@ export const TypeaheadSelect = React.forwardRef, ITypeah minLength = 0, options, onChange, + onBlur, } = props; const { errors, touched, values, setFieldValue, setFieldTouched } = useFormikContext(); const value = getIn(values, field); @@ -68,8 +71,13 @@ export const TypeaheadSelect = React.forwardRef, ITypeah ); const onSelectBlur = React.useCallback( - () => setFieldTouched(field, true), - [field, setFieldTouched], + (e: Event) => { + setFieldTouched(field, true); + if (typeof onBlur === 'function') { + onBlur(e); + } + }, + [field, onBlur, setFieldTouched], ); return ( From 4ced403c2dd2735a9e293a4a38315fc8377cc419 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Wed, 15 Nov 2023 22:08:32 -0800 Subject: [PATCH 08/10] Add disabled prop to TypeaheadSelect --- .../frontend/src/components/common/form/TypeaheadSelect.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/source/frontend/src/components/common/form/TypeaheadSelect.tsx b/source/frontend/src/components/common/form/TypeaheadSelect.tsx index f9c5c4aa42..a70fc1e27a 100644 --- a/source/frontend/src/components/common/form/TypeaheadSelect.tsx +++ b/source/frontend/src/components/common/form/TypeaheadSelect.tsx @@ -17,6 +17,8 @@ export interface ITypeaheadSelectProps { 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 */ @@ -44,6 +46,7 @@ export const TypeaheadSelect = React.forwardRef, ITypeah field, label, required, + disabled, tooltip, displayErrorTooltips, className, @@ -95,6 +98,7 @@ export const TypeaheadSelect = React.forwardRef, ITypeah name: `typeahead-select-${field}`, id: `typeahead-select-${field}`, }} + disabled={disabled} flip clearButton highlightOnlyResult From 727f4c1b30be40d3d7d6c62c97ebbf6a8ea6a4fe Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Wed, 15 Nov 2023 22:11:01 -0800 Subject: [PATCH 09/10] Implement unit tests for common component --- .../common/form/TypeaheadSelect.test.tsx | 125 ++++++++++++++++++ .../TypeaheadSelect.test.tsx.snap | 50 +++++++ 2 files changed, 175 insertions(+) create mode 100644 source/frontend/src/components/common/form/TypeaheadSelect.test.tsx create mode 100644 source/frontend/src/components/common/form/__snapshots__/TypeaheadSelect.test.tsx.snap 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/__snapshots__/TypeaheadSelect.test.tsx.snap b/source/frontend/src/components/common/form/__snapshots__/TypeaheadSelect.test.tsx.snap new file mode 100644 index 0000000000..05d7c88728 --- /dev/null +++ b/source/frontend/src/components/common/form/__snapshots__/TypeaheadSelect.test.tsx.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TypeaheadSelect component renders as expected 1`] = ` + +
+
+ .c0 button.close { + font-size: 2.4rem; +} + +
+
+
+ + +
+
+
+ +`; From 0528bb232e6cbcbbe976bb4ce675f90920b652b1 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Thu, 16 Nov 2023 12:43:10 -0800 Subject: [PATCH 10/10] Fix failing tests --- .../project/add/AddProjectForm.test.tsx | 20 +- .../AddProjectContainer.test.tsx.snap | 173 +++++--- .../AddProjectForm.test.tsx.snap | 406 ++++++++++++------ 3 files changed, 401 insertions(+), 198 deletions(-) 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/__snapshots__/AddProjectContainer.test.tsx.snap b/source/frontend/src/features/mapSideBar/project/add/__snapshots__/AddProjectContainer.test.tsx.snap index ef8a1f86b6..e9312ce892 100644 --- a/source/frontend/src/features/mapSideBar/project/add/__snapshots__/AddProjectContainer.test.tsx.snap +++ b/source/frontend/src/features/mapSideBar/project/add/__snapshots__/AddProjectContainer.test.tsx.snap @@ -80,7 +80,7 @@ exports[`AddProjectContainer component renders as expected 1`] = ` class="c2" />
- .c9.btn { + .c10.btn { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -110,49 +110,49 @@ exports[`AddProjectContainer component renders as expected 1`] = ` cursor: pointer; } -.c9.btn:hover { +.c10.btn:hover { -webkit-text-decoration: underline; text-decoration: underline; opacity: 0.8; } -.c9.btn:focus { +.c10.btn:focus { outline-width: 0.4rem; outline-style: solid; outline-offset: 1px; box-shadow: none; } -.c9.btn.btn-primary { +.c10.btn.btn-primary { border: none; } -.c9.btn.btn-secondary { +.c10.btn.btn-secondary { background: none; } -.c9.btn.btn-info { +.c10.btn.btn-info { border: none; background: none; padding-left: 0.6rem; padding-right: 0.6rem; } -.c9.btn.btn-info:hover, -.c9.btn.btn-info:active, -.c9.btn.btn-info:focus { +.c10.btn.btn-info:hover, +.c10.btn.btn-info:active, +.c10.btn.btn-info:focus { background: none; } -.c9.btn.btn-light { +.c10.btn.btn-light { border: none; } -.c9.btn.btn-dark { +.c10.btn.btn-dark { border: none; } -.c9.btn.btn-link { +.c10.btn.btn-link { font-size: 1.6rem; font-weight: 400; background: none; @@ -173,9 +173,9 @@ exports[`AddProjectContainer component renders as expected 1`] = ` padding: 0; } -.c9.btn.btn-link:hover, -.c9.btn.btn-link:active, -.c9.btn.btn-link:focus { +.c10.btn.btn-link:hover, +.c10.btn.btn-link:active, +.c10.btn.btn-link:focus { -webkit-text-decoration: underline; text-decoration: underline; border: none; @@ -184,14 +184,14 @@ exports[`AddProjectContainer component renders as expected 1`] = ` outline: none; } -.c9.btn.btn-link:disabled, -.c9.btn.btn-link.disabled { +.c10.btn.btn-link:disabled, +.c10.btn.btn-link.disabled { background: none; pointer-events: none; } -.c9.btn:disabled, -.c9.btn:disabled:hover { +.c10.btn:disabled, +.c10.btn:disabled:hover { box-shadow: none; -webkit-user-select: none; -moz-user-select: none; @@ -202,15 +202,15 @@ exports[`AddProjectContainer component renders as expected 1`] = ` opacity: 0.65; } -.c9.Button .Button__icon { +.c10.Button .Button__icon { margin-right: 1.6rem; } -.c9.Button--icon-only:focus { +.c10.Button--icon-only:focus { outline: none; } -.c9.Button--icon-only .Button__icon { +.c10.Button--icon-only .Button__icon { margin-right: 0; } @@ -228,6 +228,10 @@ exports[`AddProjectContainer component renders as expected 1`] = ` width: 100%; } +.c9 button.close { + font-size: 2.4rem; +} + .c4 { font-weight: bold; border-bottom: 0.2rem solid; @@ -561,19 +565,40 @@ exports[`AddProjectContainer component renders as expected 1`] = ` class="c7 text-left col" >
- + + +
+
@@ -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" >