Skip to content

Commit

Permalink
Merge branch 'main' into validate-create-subform-in-nextrecommendedac…
Browse files Browse the repository at this point in the history
…tion
  • Loading branch information
lassopicasso committed Nov 4, 2024
2 parents b8cc0fd + 5a3082b commit 0649363
Show file tree
Hide file tree
Showing 16 changed files with 299 additions and 36 deletions.
5 changes: 5 additions & 0 deletions frontend/libs/studio-components/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ const preview: Preview = {
</DocsContainer>
),
},
options: {
storySort: {
method: 'alphabetical',
},
},
},
};

Expand Down
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
Loading

0 comments on commit 0649363

Please sign in to comment.