Skip to content

Commit

Permalink
Code list validation
Browse files Browse the repository at this point in the history
  • Loading branch information
TomasEng committed Oct 29, 2024
1 parent e8b4208 commit ebd8e6d
Show file tree
Hide file tree
Showing 13 changed files with 319 additions and 35 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: 'There are duplicated values.',
},
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 @@ -196,6 +222,30 @@ 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();
});
});

function renderCodeListEditor(props: Partial<StudioCodeListEditorProps> = {}) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { CodeList } from './types/CodeList';
import type { ReactElement } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import React, { ReactElement, 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 +18,10 @@ import {
import classes from './StudioCodeListEditor.module.css';
import { PlusIcon } from '@studio/icons';
import { StudioParagraph } from '../StudioParagraph';
import { areThereCodeListErrors, findCodeListErrors, isCodeListValid } from './validation';
import { ValueErrorMap } from './types/ValueErrorMap';
import { StudioFieldset } from '../StudioFieldset';
import { ErrorMessage } from '@digdir/designsystemet-react';

export type StudioCodeListEditorProps = {
codeList: CodeList;
Expand Down Expand Up @@ -53,7 +56,7 @@ function StatefulCodeListEditor({
const handleChange = useCallback(
(newCodeList: CodeList) => {
setCodeList(newCodeList);
onChange(newCodeList);
if (isCodeListValid(newCodeList)) onChange(newCodeList);
},
[onChange],
);
Expand All @@ -66,26 +69,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,12 +101,14 @@ function EmptyCodeListTable(): ReactElement {
return <StudioParagraph>{texts.emptyCodeList}</StudioParagraph>;
}

function CodeListTableWithContent(props: InternalCodeListEditorProps): ReactElement {
function CodeListTableWithContent(props: InternalCodeListEditorWithErrorsProps): ReactElement {
return (
<StudioInputTable>
<Headings />
<CodeLists {...props} />
</StudioInputTable>
<>
<StudioInputTable>
<Headings />
<CodeLists {...props} />
</StudioInputTable>
</>
);
}

Expand All @@ -119,7 +128,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 +153,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 +165,21 @@ function CodeLists({ codeList, onChange }: InternalCodeListEditorProps): ReactEl
);
}

type ErrorsProps = {
errorMap: ValueErrorMap;
};

function Errors({ errorMap }: ErrorsProps): ReactElement {
const {
texts: { generalError },
} = useStudioCodeListEditorContext();
if (areThereCodeListErrors(errorMap)) {
return <ErrorMessage>{generalError}</ErrorMessage>;
} 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,28 @@
import type { CodeListItem } from '../types/CodeListItem';
import { StudioInputTable } from '../../StudioInputTable';
import { TrashIcon } from '../../../../../studio-icons';
import React, { useCallback } from 'react';
import React, {
FocusEvent,
HTMLInputAutoCompleteAttribute,
useCallback,
useEffect,
useRef,
} from 'react';
import { changeDescription, changeHelpText, changeLabel, changeValue } from './utils';
import { useStudioCodeListEditorContext } from '../StudioCodeListEditorContext';
import { 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 +65,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(() => {
ref.current?.setCustomValidity(error || '');
}, [error]);

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

const handleFocus = useCallback((event: FocusEvent<HTMLInputElement>) => {
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 { 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';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { ValueError } from './ValueError';

export type ValueErrorMap = (ValueError | null)[];
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './validation';
Loading

0 comments on commit ebd8e6d

Please sign in to comment.