Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: StudioCodeListEditor - display Numberfield or Checkbox based on value type #14398

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e9b29b2
feat: Add text resource selector to code list editor
TomasEng Jan 3, 2025
d6c13ac
Merge remote-tracking branch 'origin' into add-numberfield-to-code-li…
ErlingHauan Jan 9, 2025
e32b242
add conditional rendering of numberfield
ErlingHauan Jan 9, 2025
e861d29
base new code list item value type on first item in codelist
ErlingHauan Jan 9, 2025
50ea9e5
Merge remote-tracking branch 'origin' into add-numberfield-to-code-li…
ErlingHauan Jan 10, 2025
9e9a73f
renames
ErlingHauan Jan 10, 2025
29079f6
update test name
ErlingHauan Jan 10, 2025
fd25884
Merge main to branch
ErlingHauan Jan 17, 2025
1724951
Merge remote-tracking branch 'origin' into add-numberfield-to-code-li…
ErlingHauan Jan 20, 2025
aa6c8d2
Render checkbox when value is boolean
ErlingHauan Jan 20, 2025
3aed7a8
Render checkbox when value is boolean
ErlingHauan Jan 20, 2025
0882627
split input components
ErlingHauan Jan 20, 2025
8c3861d
Merge branch 'add-numberfield-to-code-list-editor' of https://github.…
ErlingHauan Jan 20, 2025
80fdc7f
base type of new row on last row
ErlingHauan Jan 20, 2025
e5194f8
Forward ref to input cells
ErlingHauan Jan 21, 2025
0bd270e
Add tests
ErlingHauan Jan 21, 2025
954bbbc
Update tests
ErlingHauan Jan 22, 2025
cf17f0b
Merge branch 'main' into add-numberfield-to-code-list-editor
ErlingHauan Jan 22, 2025
0e73d8e
Update tests
ErlingHauan Jan 22, 2025
1d01ce3
Use correct type and refactoring
ErlingHauan Jan 22, 2025
3f0cdc3
Use type assertion in getTypeOfLastValue
ErlingHauan Jan 22, 2025
4f1c8ba
Implement suggestions from rabbit
ErlingHauan Jan 22, 2025
6c4ad49
Replace isNan with undefined check
ErlingHauan Jan 22, 2025
267d8de
Partly fix PR comments
ErlingHauan Jan 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
textResources,
} from './test-data/textResources';
import type { TextResource } from '../../types/TextResource';
import { codeListWithNumberValues } from './test-data/codeListWithNumberValues';
import { codeListWithBooleanValues } from './test-data/codeListWithBooleanValues';

// Test data:
const onAddOrDeleteItem = jest.fn();
Expand Down Expand Up @@ -478,6 +480,92 @@ describe('StudioCodeListEditor', () => {
await user.click(deleteButton);
expect(screen.getByRole('table')).toBeInTheDocument();
});

describe('Type handling', () => {
it('Renders textfield when item value is a string', () => {
renderCodeListEditor();
const textfield = screen.getByRole('textbox', { name: texts.itemValue(1) });
expect(textfield).not.toHaveProperty('inputMode', 'decimal');
});

it('Renders numberfield when item value is a number', () => {
renderCodeListEditor({ codeList: codeListWithNumberValues });
const numberfield = screen.getByRole('textbox', { name: texts.itemValue(1) });
expect(numberfield).toHaveProperty('inputMode', 'decimal');
});

it('Renders checkbox when item value is a boolean', () => {
renderCodeListEditor({ codeList: codeListWithBooleanValues });
expect(screen.getByRole('checkbox', { name: texts.itemValue(1) })).toBeInTheDocument();
});

it('Saves changed item value as string when initial value was string', async () => {
const user = userEvent.setup();
renderCodeListEditor();

const valueInput = screen.getByRole('textbox', { name: texts.itemValue(1) });
const changedValue = 'new text';
await user.type(valueInput, changedValue);
await user.tab();

expect(onBlurAny).toHaveBeenCalledTimes(1);
expect(onBlurAny).toHaveBeenCalledWith([
{ ...codeListWithoutTextResources[0], value: changedValue },
codeListWithoutTextResources[1],
codeListWithoutTextResources[2],
]);
});

it('Saves changed item value as number when initial value was number', async () => {
const user = userEvent.setup();
renderCodeListEditor({ codeList: codeListWithNumberValues });

const valueInput = screen.getByRole('textbox', { name: texts.itemValue(1) });
await user.type(valueInput, '10');
await user.tab();

expect(onBlurAny).toHaveBeenCalledTimes(1);
expect(onBlurAny).toHaveBeenCalledWith([
{ ...codeListWithNumberValues[0], value: 10 },
codeListWithNumberValues[1],
codeListWithNumberValues[2],
]);
});

it('Saves changed item value as boolean when initial value was boolean', async () => {
const user = userEvent.setup();
const codeListWithSingleBooleanValue: CodeList = [codeListWithBooleanValues[0]];
renderCodeListEditor({ codeList: codeListWithSingleBooleanValue });

const valueInput = screen.getByRole('checkbox', { name: texts.itemValue(1) });
await user.click(valueInput);

expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith([{ ...codeListWithBooleanValues[0], value: false }]);
});

it('Numberfield does not change codelist when given string value', async () => {
const user = userEvent.setup();
renderCodeListEditor({ codeList: codeListWithNumberValues });

const valueInput = screen.getByRole('textbox', { name: texts.itemValue(1) });
await user.type(valueInput, 'not-a-number');
await user.tab();

expect(onBlurAny).toHaveBeenCalledWith([...codeListWithNumberValues]);
});

it('Numberfield does not change codelist when given empty input', async () => {
const user = userEvent.setup();
renderCodeListEditor({ codeList: codeListWithNumberValues });

const valueInput = screen.getByRole('textbox', { name: texts.itemValue(1) });
await user.clear(valueInput);
await user.tab();

expect(onBlurAny).toHaveBeenCalledWith([...codeListWithNumberValues]);
Comment on lines +547 to +566
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the onBlur be called at all in these cases? 🤔 Should not the isCodeListValid function catch tese issues?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the validation only checks if there are duplicate items:
bilde

Copy link
Contributor

@standeren standeren Jan 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, agree, but I meant that we should adapt the validator, so it actually does the necessary checks so we wont need to call onBlur if there is no changes 🙈

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I will take a look into it!

});
});
});

function renderCodeListEditor(props: Partial<StudioCodeListEditorProps> = {}): RenderResult {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { CodeListItem } from './types/CodeListItem';
import { StudioButton } from '../StudioButton';
import {
removeCodeListItem,
addEmptyCodeListItem,
addNewCodeListItem,
changeCodeListItem,
isCodeListEmpty,
} from './utils';
Expand Down Expand Up @@ -110,7 +110,7 @@ function ControlledCodeListEditor({
const errorMap = useMemo<ValueErrorMap>(() => findCodeListErrors(codeList), [codeList]);

const handleAddButtonClick = useCallback(() => {
const updatedCodeList = addEmptyCodeListItem(codeList);
const updatedCodeList = addNewCodeListItem(codeList);
onChange(updatedCodeList);
onAddOrDeleteItem?.(updatedCodeList);
}, [codeList, onChange, onAddOrDeleteItem]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { CodeListItemValue } from '../types/CodeListItemValue';
import { StudioInputTable } from '../../StudioInputTable';
import { TrashIcon } from '../../../../../studio-icons';
import type { FocusEvent, HTMLInputAutoCompleteAttribute, ReactElement } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import React, { forwardRef, useCallback, useEffect, useRef } from 'react';
import { changeDescription, changeHelpText, changeLabel, changeValue } from './utils';
import { useStudioCodeListEditorContext } from '../StudioCodeListEditorContext';
import type { ValueError } from '../types/ValueError';
Expand Down Expand Up @@ -49,7 +49,7 @@ export function StudioCodeListEditorRow({
);

const handleValueChange = useCallback(
(value: string) => {
(value: CodeListItemValue) => {
const updatedItem = changeValue(item, value);
onChange(updatedItem);
},
Expand All @@ -66,7 +66,7 @@ export function StudioCodeListEditorRow({

return (
<StudioInputTable.Row>
<TextfieldCell
<TypedInputCell
autoComplete='off'
error={error && texts.valueErrors[error]}
label={texts.itemValue(number)}
Expand Down Expand Up @@ -105,45 +105,139 @@ export function StudioCodeListEditorRow({
);
}

type TextfieldCellProps = {
error?: string;
label: string;
onChange: (newString: string) => void;
type TypedInputCellProps = {
value: CodeListItemValue;
label: string;
onChange: (newValue: CodeListItemValue) => void;
onFocus?: (event: FocusEvent) => void;
autoComplete?: HTMLInputAutoCompleteAttribute;
error?: string;
};

function TextfieldCell({ error, label, value, onChange, autoComplete }: TextfieldCellProps) {
function TypedInputCell({ error, label, value, onChange, autoComplete }: TypedInputCellProps) {
const ref = useRef<HTMLInputElement>(null);

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

const handleChange = useCallback(
(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}
autoComplete={autoComplete}
className={classes.textfieldCell}
onChange={handleChange}
onFocus={handleFocus}
ref={ref}
value={(value as string) ?? ''}
/>
);
switch (typeof value) {
case 'number':
return (
<NumberfieldCell
label={label}
value={value}
autoComplete={autoComplete}
onChange={onChange}
onFocus={handleFocus}
ref={ref}
/>
);
case 'boolean':
return (
<CheckboxCell
label={label}
value={value}
onChange={onChange}
onFocus={handleFocus}
ref={ref}
/>
);
default:
return (
<TextfieldCell
label={label}
value={value}
autoComplete={autoComplete}
onChange={onChange}
onFocus={handleFocus}
ref={ref}
/>
);
}
}

const NumberfieldCell = forwardRef<HTMLInputElement, TypedInputCellProps>(
({ label, value, onChange, onFocus, autoComplete }, ref) => {
const handleNumberChange = useCallback(
(numberValue: number): void => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
(numberValue: number): void => {
(numberValue: number | undefined): void => {

if (numberValue === undefined) return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When will this case happen, and why is the check not present in the others? Should we consider using the same pattern as in the StudioCodeListEditor root - isFieldValid(numberValue) && onChange(numberValue)?

onChange(numberValue);
},
Comment on lines +167 to +170
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Improve number validation consistency.

The undefined check is inconsistent with other input types and might miss invalid cases. Consider using the same validation pattern as suggested in past reviews.

-      (numberValue: number): void => {
-        if (numberValue === undefined) return;
+      (numberValue: number | undefined): void => {
+        if (!Number.isFinite(numberValue)) return;
         onChange(numberValue);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(numberValue: number): void => {
if (numberValue === undefined) return;
onChange(numberValue);
},
(numberValue: number | undefined): void => {
if (!Number.isFinite(numberValue)) return;
onChange(numberValue);
},

[onChange],
);

return (
<StudioInputTable.Cell.Numberfield
ref={ref}
aria-label={label}
autoComplete={autoComplete}
className={classes.textfieldCell}
onChange={handleNumberChange}
onFocus={onFocus}
value={value as number}
/>
);
},
);

NumberfieldCell.displayName = 'NumberfieldCell';

const CheckboxCell = forwardRef<HTMLInputElement, TypedInputCellProps>(
({ label, value, onChange, onFocus }, ref) => {
const handleBooleanChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
onChange(event.target.checked);
},
[onChange],
);

return (
<StudioInputTable.Cell.Checkbox
ref={ref}
aria-label={label}
onChange={handleBooleanChange}
onFocus={onFocus}
checked={value as boolean}
value={String(value)}
>
{String(value)}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are displaying this value in the value-column, maybe it should be translated? 🙈

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you suggest using sann and usann instead?
I think most people are more familiar with true and false, and maybe they can be considered valid borrow words, at least in the context of computers 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are translating these in the expression-tool at least. So using the same translation would provide consistency 🤷
I think expressions uses sann and usann.

</StudioInputTable.Cell.Checkbox>
);
},
);

CheckboxCell.displayName = 'CheckboxCell';

const TextfieldCell = forwardRef<HTMLInputElement, TypedInputCellProps>(
({ label, value, onChange, onFocus, autoComplete }, ref) => {
const handleTextChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
onChange(event.target.value);
},
[onChange],
);

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

TextfieldCell.displayName = 'TextfieldCell';

type TextResourceIdCellProps = {
currentId: string;
label: string;
Expand All @@ -159,7 +253,7 @@ function TextResourceIdCell(props: TextResourceIdCellProps): ReactElement {
if (textResources) {
return <TextResourceSelectorCell {...props} textResources={textResources} />;
} else {
return <TextfieldCell label={label} onChange={onChangeCurrentId} value={currentId || ''} />;
return <TypedInputCell label={label} onChange={onChangeCurrentId} value={currentId || ''} />;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it correct that the value should default to an empty string if no currentId exists? Maybe not a part of the scope of this PR tho, since creating initial typed entries in the codeList is not in the scope 🙈

}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CodeListItem } from '../types/CodeListItem';
import type { CodeListItemValue } from '../types/CodeListItemValue';

export function changeLabel(item: CodeListItem, label: string): CodeListItem {
return { ...item, label };
Expand All @@ -8,7 +9,7 @@ export function changeDescription(item: CodeListItem, description: string): Code
return { ...item, description };
}

export function changeValue(item: CodeListItem, value: string): CodeListItem {
export function changeValue(item: CodeListItem, value: CodeListItemValue): CodeListItem {
return { ...item, value };
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { CodeListItem } from '../types/CodeListItem';
import type { CodeList } from '../types/CodeList';

const item1: CodeListItem = {
description: 'Test 1 description',
helpText: 'Test 1 help text',
label: 'Test 1',
value: true,
};

const item2: CodeListItem = {
description: 'Test 2 description',
helpText: 'Test 2 help text',
label: 'Test 2',
value: false,
};

export const codeListWithBooleanValues: CodeList = [item1, item2];
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { CodeListItem } from '../types/CodeListItem';
import type { CodeList } from '../types/CodeList';

const item1: CodeListItem = {
description: 'Positive number',
helpText: 'Test 1 help text',
label: 'Test 1',
value: 1,
};

const item2: CodeListItem = {
description: 'Decimal',
helpText: 'Test 2 help text',
label: 'Test 2',
value: 3.14,
};

const item3: CodeListItem = {
description: 'Negative number',
helpText: 'Test 3 help text',
label: 'Test 3',
value: -1,
};

export const codeListWithNumberValues: CodeList = [item1, item2, item3];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be named TypeOfResult? With upper case O.

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type TypeofResult =
| 'string'
| 'number'
| 'bigint'
| 'boolean'
| 'symbol'
| 'undefined'
| 'object'
| 'function';
Loading
Loading