Skip to content

Commit

Permalink
feat(ui-component): Code list validation (#13941)
Browse files Browse the repository at this point in the history
  • Loading branch information
TomasEng authored Nov 3, 2024
1 parent 606018e commit d1bb949
Show file tree
Hide file tree
Showing 13 changed files with 283 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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],
Expand All @@ -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],
Expand All @@ -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],
Expand All @@ -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],
Expand Down Expand Up @@ -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<StudioCodeListEditorProps> = {}) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -53,7 +57,7 @@ function StatefulCodeListEditor({
const handleChange = useCallback(
(newCodeList: CodeList) => {
setCodeList(newCodeList);
onChange(newCodeList);
if (isCodeListValid(newCodeList)) onChange(newCodeList);
},
[onChange],
);
Expand All @@ -66,26 +70,30 @@ function ControlledCodeListEditor({
onChange,
}: InternalCodeListEditorProps): ReactElement {
const { texts } = useStudioCodeListEditorContext();
const fieldsetRef = useRef<HTMLFieldSetElement>(null);

const errorMap = useMemo<ValueErrorMap>(() => findCodeListErrors(codeList), [codeList]);

const handleAddButtonClick = useCallback(() => {
const updatedCodeList = addEmptyCodeListItem(codeList);
onChange(updatedCodeList);
}, [codeList, onChange]);

return (
<fieldset className={classes.codeListEditor}>
<legend>{texts.codeList}</legend>
<CodeListTable codeList={codeList} onChange={onChange} />
<StudioFieldset legend={texts.codeList} className={classes.codeListEditor} ref={fieldsetRef}>
<CodeListTable codeList={codeList} errorMap={errorMap} onChange={onChange} />
<AddButton onClick={handleAddButtonClick} />
</fieldset>
<Errors errorMap={errorMap} />
</StudioFieldset>
);
}
type InternalCodeListEditorWithErrorsProps = InternalCodeListEditorProps & ErrorsProps;

function CodeListTable({ codeList, onChange }: InternalCodeListEditorProps): ReactElement {
return isCodeListEmpty(codeList) ? (
function CodeListTable(props: InternalCodeListEditorWithErrorsProps): ReactElement {
return isCodeListEmpty(props.codeList) ? (
<EmptyCodeListTable />
) : (
<CodeListTableWithContent codeList={codeList} onChange={onChange} />
<CodeListTableWithContent {...props} />
);
}

Expand All @@ -94,7 +102,7 @@ function EmptyCodeListTable(): ReactElement {
return <StudioParagraph>{texts.emptyCodeList}</StudioParagraph>;
}

function CodeListTableWithContent(props: InternalCodeListEditorProps): ReactElement {
function CodeListTableWithContent(props: InternalCodeListEditorWithErrorsProps): ReactElement {
return (
<StudioInputTable>
<Headings />
Expand All @@ -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);
Expand All @@ -140,6 +152,7 @@ function CodeLists({ codeList, onChange }: InternalCodeListEditorProps): ReactEl
<StudioInputTable.Body>
{codeList.map((item, index) => (
<StudioCodeListEditorRow
error={errorMap[index]}
item={item}
key={index}
number={index + 1}
Expand All @@ -151,6 +164,21 @@ function CodeLists({ codeList, onChange }: InternalCodeListEditorProps): ReactEl
);
}

type ErrorsProps = {
errorMap: ValueErrorMap;
};

function Errors({ errorMap }: ErrorsProps): ReactElement {
const {
texts: { generalError },
} = useStudioCodeListEditorContext();
if (areThereCodeListErrors(errorMap)) {
return <StudioErrorMessage>{generalError}</StudioErrorMessage>;
} else {
return null;
}
}

type AddButtonProps = {
onClick: () => void;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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. */
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
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;
onDeleteButtonClick: () => void;
};

export function StudioCodeListEditorRow({
error,
item,
number,
onChange,
Expand Down Expand Up @@ -55,46 +60,68 @@ export function StudioCodeListEditorRow({
return (
<StudioInputTable.Row>
<TextfieldCell
autoComplete='off'
error={error && texts.valueErrors[error]}
label={texts.itemValue(number)}
value={item.value}
onChange={handleValueChange}
value={item.value}
/>
<TextfieldCell
label={texts.itemLabel(number)}
value={item.label}
onChange={handleLabelChange}
value={item.label}
/>
<TextfieldCell
label={texts.itemDescription(number)}
value={item.description}
onChange={handleDescriptionChange}
value={item.description}
/>
<TextfieldCell
label={texts.itemHelpText(number)}
value={item.helpText}
onChange={handleHelpTextChange}
value={item.helpText}
/>
<DeleteButtonCell onClick={onDeleteButtonClick} number={number} />
</StudioInputTable.Row>
);
}

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<HTMLInputElement>(null);

useEffect((): void => {
ref.current?.setCustomValidity(error || '');
}, [error]);

const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
(event: React.ChangeEvent<HTMLInputElement>): void => {
onChange(event.target.value);
},
[onChange],
);

const handleFocus = useCallback((event: FocusEvent<HTMLInputElement>): void => {
event.target.reportValidity();
}, []);

return (
<StudioInputTable.Cell.Textfield aria-label={label} onChange={handleChange} value={value} />
<StudioInputTable.Cell.Textfield
aria-label={label}
autoComplete={autoComplete}
className={classes.textfieldCell}
onChange={handleChange}
onFocus={handleFocus}
ref={ref}
value={value}
/>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import type { ValueError } from './ValueError';

export type CodeListEditorTexts = {
add: string;
codeList: string;
delete: string;
deleteItem: (number: number) => string;
description: string;
emptyCodeList: string;
valueErrors: ValueErrorMessages;
generalError: string;
helpText: string;
itemDescription: (number: number) => string;
itemHelpText: (number: number) => string;
Expand All @@ -13,3 +17,5 @@ export type CodeListEditorTexts = {
label: string;
value: string;
};

type ValueErrorMessages = Record<ValueError, string>;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type ValueError = 'duplicateValue';
Loading

0 comments on commit d1bb949

Please sign in to comment.