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 54b8d7466b8..90544b00701 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx @@ -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(); @@ -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]); + }); + }); }); function renderCodeListEditor(props: Partial = {}): RenderResult { diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx index e850617245b..1ce8f550b03 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx @@ -6,7 +6,7 @@ import type { CodeListItem } from './types/CodeListItem'; import { StudioButton } from '../StudioButton'; import { removeCodeListItem, - addEmptyCodeListItem, + addNewCodeListItem, changeCodeListItem, isCodeListEmpty, } from './utils'; @@ -110,7 +110,7 @@ function ControlledCodeListEditor({ const errorMap = useMemo(() => findCodeListErrors(codeList), [codeList]); const handleAddButtonClick = useCallback(() => { - const updatedCodeList = addEmptyCodeListItem(codeList); + const updatedCodeList = addNewCodeListItem(codeList); onChange(updatedCodeList); onAddOrDeleteItem?.(updatedCodeList); }, [codeList, onChange, onAddOrDeleteItem]); 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 9b30bdc3acd..8681982d221 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.tsx @@ -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'; @@ -49,7 +49,7 @@ export function StudioCodeListEditorRow({ ); const handleValueChange = useCallback( - (value: string) => { + (value: CodeListItemValue) => { const updatedItem = changeValue(item, value); onChange(updatedItem); }, @@ -66,7 +66,7 @@ export function StudioCodeListEditorRow({ return ( - 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(null); useEffect((): void => { ref.current?.setCustomValidity(error || ''); }, [error]); - const handleChange = useCallback( - (event: React.ChangeEvent): void => { - onChange(event.target.value); - }, - [onChange], - ); - const handleFocus = useCallback((event: FocusEvent): void => { event.target.reportValidity(); }, []); - return ( - - ); + switch (typeof value) { + case 'number': + return ( + + ); + case 'boolean': + return ( + + ); + default: + return ( + + ); + } } +const NumberfieldCell = forwardRef( + ({ label, value, onChange, onFocus, autoComplete }, ref) => { + const handleNumberChange = useCallback( + (numberValue: number): void => { + if (numberValue === undefined) return; + onChange(numberValue); + }, + [onChange], + ); + + return ( + + ); + }, +); + +NumberfieldCell.displayName = 'NumberfieldCell'; + +const CheckboxCell = forwardRef( + ({ label, value, onChange, onFocus }, ref) => { + const handleBooleanChange = useCallback( + (event: React.ChangeEvent): void => { + onChange(event.target.checked); + }, + [onChange], + ); + + return ( + + {String(value)} + + ); + }, +); + +CheckboxCell.displayName = 'CheckboxCell'; + +const TextfieldCell = forwardRef( + ({ label, value, onChange, onFocus, autoComplete }, ref) => { + const handleTextChange = useCallback( + (event: React.ChangeEvent): void => { + onChange(event.target.value); + }, + [onChange], + ); + + return ( + + ); + }, +); + +TextfieldCell.displayName = 'TextfieldCell'; + type TextResourceIdCellProps = { currentId: string; label: string; @@ -159,7 +253,7 @@ function TextResourceIdCell(props: TextResourceIdCellProps): ReactElement { if (textResources) { return ; } else { - return ; + return ; } } diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/utils.ts b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/utils.ts index 27387f33241..fc2a918c935 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/utils.ts +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/utils.ts @@ -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 }; @@ -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 }; } diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/test-data/codeListWithBooleanValues.ts b/frontend/libs/studio-components/src/components/StudioCodelistEditor/test-data/codeListWithBooleanValues.ts new file mode 100644 index 00000000000..e655690a08a --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/test-data/codeListWithBooleanValues.ts @@ -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]; diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/test-data/codeListWithNumberValues.ts b/frontend/libs/studio-components/src/components/StudioCodelistEditor/test-data/codeListWithNumberValues.ts new file mode 100644 index 00000000000..65c358ffa13 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/test-data/codeListWithNumberValues.ts @@ -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]; diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/types/TypeofResult.ts b/frontend/libs/studio-components/src/components/StudioCodelistEditor/types/TypeofResult.ts new file mode 100644 index 00000000000..c7323f64580 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/types/TypeofResult.ts @@ -0,0 +1,9 @@ +export type TypeofResult = + | 'string' + | 'number' + | 'bigint' + | 'boolean' + | 'symbol' + | 'undefined' + | 'object' + | 'function'; diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/utils.test.ts b/frontend/libs/studio-components/src/components/StudioCodelistEditor/utils.test.ts index 9ceeb7b7857..0c7a7b804e2 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/utils.test.ts +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/utils.test.ts @@ -1,7 +1,10 @@ import type { CodeList } from './types/CodeList'; import { - addEmptyCodeListItem, + addNewCodeListItem, changeCodeListItem, + emptyBooleanItem, + emptyNumberItem, + emptyStringItem, isCodeListEmpty, removeCodeListItem, } from './utils'; @@ -24,21 +27,42 @@ const createTestCodeList = (): CodeList => ObjectUtils.deepCopy(testCodeList); describe('StudioCodelistEditor utils', () => { describe('addEmptyCodeListItem', () => { - it('Adds an empty item to the code list', () => { - const codeList = createTestCodeList(); - const updatedCodeList = addEmptyCodeListItem(codeList); - expect(updatedCodeList).toEqual([ - ...codeList, - { - label: '', - value: '', - }, - ]); + it('Adds an empty string item when the code list is empty', () => { + const codeList: CodeList = []; + const updatedCodeList = addNewCodeListItem(codeList); + expect(updatedCodeList).toEqual([...codeList, emptyStringItem]); + }); + + it("Adds an empty string item when the last item's value is a string", () => { + const codeList: CodeList = [ + { value: 1, label: 'numberItem' }, + { value: 'two', label: 'stringItem' }, + ]; + const updatedCodeList = addNewCodeListItem(codeList); + expect(updatedCodeList).toEqual([...codeList, emptyStringItem]); + }); + + it("Adds an empty number item when the last item's value is a number", () => { + const codeList: CodeList = [ + { value: 'one', label: 'stringItem' }, + { value: 2, label: 'numberItem' }, + ]; + const updatedCodeList = addNewCodeListItem(codeList); + expect(updatedCodeList).toEqual([...codeList, emptyNumberItem]); + }); + + it("Adds an empty boolean item when the last item's value is a boolean", () => { + const codeList: CodeList = [ + { value: 0, label: 'numberItem' }, + { value: true, label: 'booleanItem' }, + ]; + const updatedCodeList = addNewCodeListItem(codeList); + expect(updatedCodeList).toEqual([...codeList, emptyBooleanItem]); }); it('Returns a new instance', () => { const codeList = createTestCodeList(); - const updatedCodeList = addEmptyCodeListItem(codeList); + const updatedCodeList = addNewCodeListItem(codeList); expect(updatedCodeList).not.toBe(codeList); }); }); diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/utils.ts b/frontend/libs/studio-components/src/components/StudioCodelistEditor/utils.ts index 11357c82b65..163494cb65d 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/utils.ts +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/utils.ts @@ -1,13 +1,44 @@ import type { CodeListItem } from './types/CodeListItem'; import type { CodeList } from './types/CodeList'; +import type { TypeofResult } from './types/TypeofResult'; import { ArrayUtils } from '@studio/pure-functions'; -export function addEmptyCodeListItem(codeList: CodeList): CodeList { - const emptyItem: CodeListItem = { - value: '', - label: '', - }; - return addCodeListItem(codeList, emptyItem); +export const emptyStringItem: CodeListItem = { + value: '', + label: '', +}; + +export const emptyNumberItem: CodeListItem = { + value: 0, + label: '', +}; + +export const emptyBooleanItem: CodeListItem = { + value: false, + label: '', +}; + +export function addNewCodeListItem(codeList: CodeList): CodeList { + const newEmptyItem: CodeListItem = getNewEmptyItem(codeList); + return addCodeListItem(codeList, newEmptyItem); +} + +function getNewEmptyItem(codeList: CodeList): CodeListItem { + if (codeList.length === 0) return emptyStringItem; + + switch (getTypeOfLastValue(codeList)) { + case 'number': + return emptyNumberItem; + case 'boolean': + return emptyBooleanItem; + default: + return emptyStringItem; + } +} + +function getTypeOfLastValue(codeList: CodeList): TypeofResult { + const lastCodeListItem = codeList[codeList.length - 1]; + return typeof lastCodeListItem.value; } function addCodeListItem(codeList: CodeList, item: CodeListItem): CodeList {