From d1bb949fff5aa0e46cfbe1ba006bac2a8c2122ba Mon Sep 17 00:00:00 2001 From: Tomas Engebretsen Date: Sun, 3 Nov 2024 13:00:55 +0100 Subject: [PATCH] feat(ui-component): Code list validation (#13941) --- .../StudioCodeListEditor.stories.tsx | 4 + .../StudioCodeListEditor.test.tsx | 86 ++++++++++++++++--- .../StudioCodeListEditor.tsx | 50 ++++++++--- .../StudioCodeListEditorRow.module.css | 10 +++ .../StudioCodeListEditorRow.tsx | 45 ++++++++-- .../types/CodeListEditorTexts.ts | 6 ++ .../StudioCodelistEditor/types/ValueError.ts | 1 + .../types/ValueErrorMap.ts | 3 + .../StudioCodelistEditor/validation/index.ts | 1 + .../validation/validation.test.ts | 65 ++++++++++++++ .../validation/validation.ts | 26 ++++++ .../src/ArrayUtils/ArrayUtils.test.ts | 14 +++ .../src/ArrayUtils/ArrayUtils.ts | 4 + 13 files changed, 283 insertions(+), 32 deletions(-) create mode 100644 frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.module.css create mode 100644 frontend/libs/studio-components/src/components/StudioCodelistEditor/types/ValueError.ts create mode 100644 frontend/libs/studio-components/src/components/StudioCodelistEditor/types/ValueErrorMap.ts create mode 100644 frontend/libs/studio-components/src/components/StudioCodelistEditor/validation/index.ts create mode 100644 frontend/libs/studio-components/src/components/StudioCodelistEditor/validation/validation.test.ts create mode 100644 frontend/libs/studio-components/src/components/StudioCodelistEditor/validation/validation.ts diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.stories.tsx b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.stories.tsx index 8174955d64c..314535b9634 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.stories.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.stories.tsx @@ -38,6 +38,10 @@ export const Preview: Story = { deleteItem: (number) => `Delete item number ${number}`, description: 'Description', emptyCodeList: 'The code list is empty.', + valueErrors: { + duplicateValue: 'The value must be unique.', + }, + generalError: 'The code list cannot be saved because it is not valid.', helpText: 'Help text', itemDescription: (number) => `Description for item number ${number}`, itemHelpText: (number) => `Help text for item number ${number}`, diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx index 48c27230a1d..151e9d26f4c 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx @@ -14,6 +14,10 @@ const texts: CodeListEditorTexts = { deleteItem: (number) => `Delete item number ${number}`, description: 'Description', emptyCodeList: 'The code list is empty.', + valueErrors: { + duplicateValue: 'The value must be unique.', + }, + generalError: 'The code list cannot be saved because it is not valid.', helpText: 'Help text', itemDescription: (number) => `Description for item number ${number}`, itemHelpText: (number) => `Help text for item number ${number}`, @@ -48,6 +52,28 @@ const defaultProps: StudioCodeListEditorProps = { texts, onChange, }; +const duplicatedValue = 'duplicate'; +const codeListWithDuplicatedValues: CodeList = [ + { + label: 'Test 1', + value: duplicatedValue, + description: 'Test 1 description', + helpText: 'Test 1 help text', + }, + { + label: 'Test 2', + value: duplicatedValue, + description: 'Test 2 description', + helpText: 'Test 2 help text', + }, + { + label: 'Test 3', + value: 'unique', + description: 'Test 3 description', + helpText: 'Test 3 help text', + }, +]; + const numberOfHeadingRows = 1; describe('StudioCodeListEditor', () => { @@ -89,9 +115,10 @@ describe('StudioCodeListEditor', () => { const user = userEvent.setup(); renderCodeListEditor(); const labelInput = screen.getByRole('textbox', { name: texts.itemLabel(1) }); - const newValue = 'new text'; - await user.type(labelInput, newValue); - expect(onChange).toHaveBeenCalledTimes(newValue.length); + const additionalText = 'new text'; + const newValue = codeList[0].label + additionalText; + await user.type(labelInput, additionalText); + expect(onChange).toHaveBeenCalledTimes(additionalText.length); expect(onChange).toHaveBeenLastCalledWith([ { ...codeList[0], label: newValue }, codeList[1], @@ -103,9 +130,10 @@ describe('StudioCodeListEditor', () => { const user = userEvent.setup(); renderCodeListEditor(); const valueInput = screen.getByRole('textbox', { name: texts.itemValue(1) }); - const newValue = 'new text'; - await user.type(valueInput, newValue); - expect(onChange).toHaveBeenCalledTimes(newValue.length); + const additionalText = 'new text'; + const newValue = codeList[0].value + additionalText; + await user.type(valueInput, additionalText); + expect(onChange).toHaveBeenCalledTimes(additionalText.length); expect(onChange).toHaveBeenLastCalledWith([ { ...codeList[0], value: newValue }, codeList[1], @@ -117,9 +145,10 @@ describe('StudioCodeListEditor', () => { const user = userEvent.setup(); renderCodeListEditor(); const descriptionInput = screen.getByRole('textbox', { name: texts.itemDescription(1) }); - const newValue = 'new text'; - await user.type(descriptionInput, newValue); - expect(onChange).toHaveBeenCalledTimes(newValue.length); + const additionalText = 'new text'; + const newValue = codeList[0].description + additionalText; + await user.type(descriptionInput, additionalText); + expect(onChange).toHaveBeenCalledTimes(additionalText.length); expect(onChange).toHaveBeenLastCalledWith([ { ...codeList[0], description: newValue }, codeList[1], @@ -131,9 +160,10 @@ describe('StudioCodeListEditor', () => { const user = userEvent.setup(); renderCodeListEditor(); const helpTextInput = screen.getByRole('textbox', { name: texts.itemHelpText(1) }); - const newValue = 'new text'; - await user.type(helpTextInput, newValue); - expect(onChange).toHaveBeenCalledTimes(newValue.length); + const additionalText = 'new text'; + const newValue = codeList[0].helpText + additionalText; + await user.type(helpTextInput, additionalText); + expect(onChange).toHaveBeenCalledTimes(additionalText.length); expect(onChange).toHaveBeenLastCalledWith([ { ...codeList[0], helpText: newValue }, codeList[1], @@ -196,6 +226,38 @@ describe('StudioCodeListEditor', () => { const newExpectedNumberOfRows = newNumberOfCodeListItems + numberOfHeadingRows; expect(screen.getAllByRole('row')).toHaveLength(newExpectedNumberOfRows); }); + + it('Applies invalid state to duplicated values', () => { + renderCodeListEditor({ codeList: codeListWithDuplicatedValues }); + const firstDuplicateInput = screen.getByRole('textbox', { name: texts.itemValue(1) }); + const secondDuplicateInput = screen.getByRole('textbox', { name: texts.itemValue(2) }); + expect(firstDuplicateInput).toBeInvalid(); + expect(secondDuplicateInput).toBeInvalid(); + }); + + it('Does not apply invalid state to unique values when other values are duplicated', () => { + renderCodeListEditor(); + const uniqueValueInput = screen.getByRole('textbox', { name: texts.itemValue(3) }); + expect(uniqueValueInput).toBeValid(); + }); + + it('Renders a general error message when there are errors', () => { + renderCodeListEditor({ codeList: codeListWithDuplicatedValues }); + expect(screen.getByText(texts.generalError)).toBeInTheDocument(); + }); + + it('Does not render the error message when the code list is valid', () => { + renderCodeListEditor(); + expect(screen.queryByText(texts.generalError)).not.toBeInTheDocument(); + }); + + it('Does not trigger onChange while the code list is invalid', async () => { + const user = userEvent.setup(); + renderCodeListEditor({ codeList: codeListWithDuplicatedValues }); + const validValueInput = screen.getByRole('textbox', { name: texts.itemValue(3) }); + await user.type(validValueInput, 'new value'); + expect(onChange).not.toHaveBeenCalled(); + }); }); function renderCodeListEditor(props: Partial = {}) { diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx index d960cae3085..231ed3116a1 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx @@ -1,6 +1,6 @@ import type { CodeList } from './types/CodeList'; import type { ReactElement } from 'react'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useCallback, useState } from 'react'; import { StudioInputTable } from '../StudioInputTable'; import type { CodeListItem } from './types/CodeListItem'; import { StudioButton } from '../StudioButton'; @@ -19,6 +19,10 @@ import { import classes from './StudioCodeListEditor.module.css'; import { PlusIcon } from '@studio/icons'; import { StudioParagraph } from '../StudioParagraph'; +import { areThereCodeListErrors, findCodeListErrors, isCodeListValid } from './validation'; +import type { ValueErrorMap } from './types/ValueErrorMap'; +import { StudioFieldset } from '../StudioFieldset'; +import { StudioErrorMessage } from '../StudioErrorMessage'; export type StudioCodeListEditorProps = { codeList: CodeList; @@ -53,7 +57,7 @@ function StatefulCodeListEditor({ const handleChange = useCallback( (newCodeList: CodeList) => { setCodeList(newCodeList); - onChange(newCodeList); + if (isCodeListValid(newCodeList)) onChange(newCodeList); }, [onChange], ); @@ -66,6 +70,9 @@ function ControlledCodeListEditor({ onChange, }: InternalCodeListEditorProps): ReactElement { const { texts } = useStudioCodeListEditorContext(); + const fieldsetRef = useRef(null); + + const errorMap = useMemo(() => findCodeListErrors(codeList), [codeList]); const handleAddButtonClick = useCallback(() => { const updatedCodeList = addEmptyCodeListItem(codeList); @@ -73,19 +80,20 @@ function ControlledCodeListEditor({ }, [codeList, onChange]); return ( -
- {texts.codeList} - + + -
+ + ); } +type InternalCodeListEditorWithErrorsProps = InternalCodeListEditorProps & ErrorsProps; -function CodeListTable({ codeList, onChange }: InternalCodeListEditorProps): ReactElement { - return isCodeListEmpty(codeList) ? ( +function CodeListTable(props: InternalCodeListEditorWithErrorsProps): ReactElement { + return isCodeListEmpty(props.codeList) ? ( ) : ( - + ); } @@ -94,7 +102,7 @@ function EmptyCodeListTable(): ReactElement { return {texts.emptyCodeList}; } -function CodeListTableWithContent(props: InternalCodeListEditorProps): ReactElement { +function CodeListTableWithContent(props: InternalCodeListEditorWithErrorsProps): ReactElement { return ( @@ -119,7 +127,11 @@ function Headings(): ReactElement { ); } -function CodeLists({ codeList, onChange }: InternalCodeListEditorProps): ReactElement { +function CodeLists({ + codeList, + onChange, + errorMap, +}: InternalCodeListEditorWithErrorsProps): ReactElement { const handleDeleteButtonClick = useCallback( (index: number) => { const updatedCodeList = removeCodeListItem(codeList, index); @@ -140,6 +152,7 @@ function CodeLists({ codeList, onChange }: InternalCodeListEditorProps): ReactEl {codeList.map((item, index) => ( {generalError}; + } else { + return null; + } +} + type AddButtonProps = { onClick: () => void; }; diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.module.css b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.module.css new file mode 100644 index 00000000000..2b14b170ba5 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.module.css @@ -0,0 +1,10 @@ +.textfieldCell:has(input:invalid) { + background-color: var(--fds-semantic-surface-danger-subtle); + --fds-semantic-border-input-default: var(--fds-semantic-border-danger-default); + --fds-semantic-border-input-hover: var( + --fds-semantic-border-danger-hover + ); /* Todo: Remove this when https://github.com/digdir/designsystemet/issues/2701 is fixed. */ + --fds-semantic-border-input-active: var( + --fds-semantic-border-danger-active + ); /* Todo: Remove this when https://github.com/digdir/designsystemet/issues/2701 is fixed. */ +} diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.tsx b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.tsx index ac886af7bec..87ef624f9c6 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.tsx @@ -1,11 +1,15 @@ import type { CodeListItem } from '../types/CodeListItem'; import { StudioInputTable } from '../../StudioInputTable'; import { TrashIcon } from '../../../../../studio-icons'; -import React, { useCallback } from 'react'; +import type { FocusEvent, HTMLInputAutoCompleteAttribute } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { changeDescription, changeHelpText, changeLabel, changeValue } from './utils'; import { useStudioCodeListEditorContext } from '../StudioCodeListEditorContext'; +import type { ValueError } from '../types/ValueError'; +import classes from './StudioCodeListEditorRow.module.css'; type StudioCodeListEditorRowProps = { + error: ValueError | null; item: CodeListItem; number: number; onChange: (newItem: CodeListItem) => void; @@ -13,6 +17,7 @@ type StudioCodeListEditorRowProps = { }; export function StudioCodeListEditorRow({ + error, item, number, onChange, @@ -55,24 +60,26 @@ export function StudioCodeListEditorRow({ return ( @@ -80,21 +87,41 @@ export function StudioCodeListEditorRow({ } type TextfieldCellProps = { - value: string; + error?: string; label: string; onChange: (newString: string) => void; + value: string; + autoComplete?: HTMLInputAutoCompleteAttribute; }; -function TextfieldCell({ label, value, onChange }: TextfieldCellProps) { +function TextfieldCell({ error, label, value, onChange, autoComplete }: TextfieldCellProps) { + const ref = useRef(null); + + useEffect((): void => { + ref.current?.setCustomValidity(error || ''); + }, [error]); + const handleChange = useCallback( - (event: React.ChangeEvent) => { + (event: React.ChangeEvent): void => { onChange(event.target.value); }, [onChange], ); + const handleFocus = useCallback((event: FocusEvent): void => { + event.target.reportValidity(); + }, []); + return ( - + ); } diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/types/CodeListEditorTexts.ts b/frontend/libs/studio-components/src/components/StudioCodelistEditor/types/CodeListEditorTexts.ts index a8dbf2233af..725abd82311 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/types/CodeListEditorTexts.ts +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/types/CodeListEditorTexts.ts @@ -1,3 +1,5 @@ +import type { ValueError } from './ValueError'; + export type CodeListEditorTexts = { add: string; codeList: string; @@ -5,6 +7,8 @@ export type CodeListEditorTexts = { deleteItem: (number: number) => string; description: string; emptyCodeList: string; + valueErrors: ValueErrorMessages; + generalError: string; helpText: string; itemDescription: (number: number) => string; itemHelpText: (number: number) => string; @@ -13,3 +17,5 @@ export type CodeListEditorTexts = { label: string; value: string; }; + +type ValueErrorMessages = Record; diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/types/ValueError.ts b/frontend/libs/studio-components/src/components/StudioCodelistEditor/types/ValueError.ts new file mode 100644 index 00000000000..e56c36e1bcb --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/types/ValueError.ts @@ -0,0 +1 @@ +export type ValueError = 'duplicateValue'; diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/types/ValueErrorMap.ts b/frontend/libs/studio-components/src/components/StudioCodelistEditor/types/ValueErrorMap.ts new file mode 100644 index 00000000000..f744f319caa --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/types/ValueErrorMap.ts @@ -0,0 +1,3 @@ +import type { ValueError } from './ValueError'; + +export type ValueErrorMap = (ValueError | null)[]; diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/validation/index.ts b/frontend/libs/studio-components/src/components/StudioCodelistEditor/validation/index.ts new file mode 100644 index 00000000000..4d5ffa36ab3 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/validation/index.ts @@ -0,0 +1 @@ +export * from './validation'; diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/validation/validation.test.ts b/frontend/libs/studio-components/src/components/StudioCodelistEditor/validation/validation.test.ts new file mode 100644 index 00000000000..d4a30f8db78 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/validation/validation.test.ts @@ -0,0 +1,65 @@ +import { areThereCodeListErrors, findCodeListErrors, isCodeListValid } from './validation'; +import type { CodeList } from '../types/CodeList'; +import type { ValueErrorMap } from '../types/ValueErrorMap'; + +const validCodeList: CodeList = [ + { + value: 'value1', + label: 'Label 1', + }, + { + value: 'value2', + label: 'Label 2', + }, +]; +const codeListWithDuplicateValues: CodeList = [ + { + value: 'value1', + label: 'Label 1', + }, + { + value: 'value1', + label: 'Label 2', + }, +]; + +describe('validation', () => { + describe('isCodeListValid', () => { + it('Returns true when there are no errors', () => { + expect(isCodeListValid(validCodeList)).toBe(true); + }); + + it('Returns false when there are errors', () => { + expect(isCodeListValid(codeListWithDuplicateValues)).toBe(false); + }); + }); + + describe('findCodeListErrors', () => { + it('Returns a corresponding array with null values only when there are no errors', () => { + const errors = findCodeListErrors(validCodeList); + expect(errors).toEqual([null, null] satisfies ValueErrorMap); + }); + + it('Returns an array with code word "duplicateValue" corresponding to duplicate values', () => { + const errors = findCodeListErrors(codeListWithDuplicateValues); + expect(errors).toEqual(['duplicateValue', 'duplicateValue'] satisfies ValueErrorMap); + }); + }); + + describe('areThereCodeListErrors', () => { + it('Returns false when the error map consists of null values only', () => { + const errorMap: ValueErrorMap = [null, null]; + expect(areThereCodeListErrors(errorMap)).toBe(false); + }); + + it('Returns false when the error map is empty', () => { + const errorMap: ValueErrorMap = []; + expect(areThereCodeListErrors(errorMap)).toBe(false); + }); + + it('Returns true when the error map contains at least one "duplicateValue" error', () => { + const errorMap: ValueErrorMap = ['duplicateValue', null]; + expect(areThereCodeListErrors(errorMap)).toBe(true); + }); + }); +}); diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/validation/validation.ts b/frontend/libs/studio-components/src/components/StudioCodelistEditor/validation/validation.ts new file mode 100644 index 00000000000..f8c5dedccfb --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/validation/validation.ts @@ -0,0 +1,26 @@ +import type { CodeList } from '../types/CodeList'; +import type { ValueError } from '../types/ValueError'; +import { ArrayUtils } from '@studio/pure-functions'; +import type { ValueErrorMap } from '../types/ValueErrorMap'; + +export function isCodeListValid(codeList: CodeList): boolean { + const errors = findCodeListErrors(codeList); + return !areThereCodeListErrors(errors); +} + +export function findCodeListErrors(codeList: CodeList): ValueErrorMap { + const values = codeList.map((item) => item.value); + return mapValueErrors(values); +} + +function mapValueErrors(values: string[]): ValueErrorMap { + return values.map((value) => findValueError(value, values)); +} + +function findValueError(value: string, allValues: string[]): ValueError | null { + return ArrayUtils.isDuplicate(value, allValues) ? 'duplicateValue' : null; +} + +export function areThereCodeListErrors(errorMap: ValueErrorMap): boolean { + return errorMap.some((item) => item !== null); +} diff --git a/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.test.ts b/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.test.ts index 7e28ccd6d35..fdca9b8ccc8 100644 --- a/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.test.ts +++ b/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.test.ts @@ -118,6 +118,20 @@ describe('ArrayUtils', () => { }); }); + describe('isDuplicate', () => { + it('Returns true when the given value is a duplicate within the array', () => { + expect(ArrayUtils.isDuplicate(2, [1, 2, 3, 2])).toBe(true); + }); + + it('Returns false when the given value is unique within the array', () => { + expect(ArrayUtils.isDuplicate(2, [1, 2, 3])).toBe(false); + }); + + it('Returns false when the given value is not present in the array', () => { + expect(ArrayUtils.isDuplicate(4, [1, 2, 3])).toBe(false); + }); + }); + describe('hasIntersection', () => { it('Returns true when arrays have one common element', () => { expect(ArrayUtils.hasIntersection([1, 2, 3], [3, 4, 5])).toBe(true); diff --git a/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.ts b/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.ts index 876021b059f..ec5561d8537 100644 --- a/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.ts +++ b/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.ts @@ -72,6 +72,10 @@ export class ArrayUtils { return [item, ...array]; } + public static isDuplicate(value: T, valueList: T[]): boolean { + return valueList.filter((item) => item === value).length > 1; + } + /** * Replaces the last item in an array. * @param array The array of interest.