From 9a852e18616cc7759a72672b140ee48d151ab582 Mon Sep 17 00:00:00 2001 From: Tomas Date: Mon, 25 Nov 2024 13:15:15 +0100 Subject: [PATCH] Add text resource picker to input table --- .../StudioInputTable/Cell/Cell.module.css | 23 +++++- .../Cell/CellTextResource.tsx | 78 +++++++++++++++++++ .../components/StudioInputTable/Cell/index.ts | 6 ++ .../StudioInputTable.test.tsx | 76 ++++++++++++++++-- .../dom-utils/isCombobox.test.tsx | 17 ++++ .../StudioInputTable/dom-utils/isCombobox.ts | 3 + .../StudioInputTable/test-data/TestTable.tsx | 11 +++ .../test-data/testTableData.ts | 26 +++++++ .../studio-hooks/src/hooks/usePropState.ts | 2 +- 9 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellTextResource.tsx create mode 100644 frontend/libs/studio-components/src/components/StudioInputTable/dom-utils/isCombobox.test.tsx create mode 100644 frontend/libs/studio-components/src/components/StudioInputTable/dom-utils/isCombobox.ts diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/Cell.module.css b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/Cell.module.css index de30179c56d..4c8d0739351 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/Cell.module.css +++ b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/Cell.module.css @@ -1,5 +1,6 @@ .textfieldCell, -.textareaCell { +.textareaCell, +.textResourceCell { padding: var(--fds-spacing-1) 0; font-size: var(--studio-input-table-font-size); } @@ -12,8 +13,24 @@ display: inline-flex; } -.textfieldCell:not(:hover) input:not(:hover):not(:active):not(:focus), -.textareaCell:not(:hover) textarea:not(:hover):not(:active):not(:focus) { +:is( + .textfieldCell:not(:hover) input, + .textareaCell:not(:hover) textarea, + .textResourceCell:not(:hover) input + ):not(:hover):not(:active):not(:focus), +.textResourceCell:not(:hover) div:has(input:not(:hover):not(:active):not(:focus)) { background-color: transparent; border-color: transparent; } + +.textResourceCell:not(:hover) .textInput div:has(input:not(:hover):not(:active):not(:focus)) svg { + visibility: hidden; +} + +.textResourceCell:not(:hover):not(:focus-within) .toggle { + visibility: hidden; +} + +.textResourceCell .currentTextId { + padding-inline: var(--fds-spacing-2); +} diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellTextResource.tsx b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellTextResource.tsx new file mode 100644 index 00000000000..4d42fc635e1 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellTextResource.tsx @@ -0,0 +1,78 @@ +import { StudioTable } from '../../StudioTable'; +import type { ForwardedRef, ReactElement, FocusEvent } from 'react'; +import React, { useCallback } from 'react'; +import { BaseInputCell } from './BaseInputCell'; +import type { StudioTextResourceInputProps } from '../../StudioTextResourceInput/StudioTextResourceInput'; +import { StudioTextResourceInput } from '../../StudioTextResourceInput/StudioTextResourceInput'; +import cn from 'classnames'; +import classes from './Cell.module.css'; +import { useEventProps } from './useEventProps'; +import { isCaretAtEnd, isCaretAtStart, isSomethingSelected } from '../dom-utils/caretUtils'; +import { isCombobox } from '../dom-utils/isCombobox'; + +export type CellTextResourceInputProps = StudioTextResourceInputProps & { + className?: string; +}; + +export class CellTextResource extends BaseInputCell { + render( + { className: givenClass, onFocus, ...rest }: CellTextResourceInputProps, + ref: ForwardedRef, + ): ReactElement { + /* eslint-disable react-hooks/rules-of-hooks */ + /* Eslint misinterprets this as a class component, while it's really just a functional component within a class */ + + const handleFocus = useCallback( + (event: FocusEvent): void => { + onFocus?.(event); + event.currentTarget.select(); + }, + [onFocus], + ); + + const eventProps = useEventProps({ onFocus: handleFocus, ...rest }); + + const className = cn(classes.textResourceCell, givenClass); + + return ( + + + + ); + } + + shouldMoveFocusOnArrowKey({ key, currentTarget }): boolean { + if (isSomethingSelected(currentTarget)) return false; + switch (key) { + case 'ArrowUp': + return this.shouldMoveFocusOnArrowUpKey(currentTarget); + case 'ArrowDown': + return this.shouldMoveFocusOnArrowDownKey(currentTarget); + case 'ArrowLeft': + return this.shouldMoveFocusOnArrowLeftKey(currentTarget); + case 'ArrowRight': + return this.shouldMoveFocusOnArrowRightKey(currentTarget); + } + } + + private shouldMoveFocusOnArrowUpKey = (element: HTMLInputElement): boolean => + !isCombobox(element) && isCaretAtStart(element); + + private shouldMoveFocusOnArrowDownKey = (element: HTMLInputElement): boolean => + !isCombobox(element) && isCaretAtEnd(element); + + private shouldMoveFocusOnArrowLeftKey = (element: HTMLInputElement): boolean => + isCaretAtStart(element); + + private shouldMoveFocusOnArrowRightKey = (element: HTMLInputElement): boolean => + isCaretAtEnd(element); + + shouldMoveFocusOnEnterKey = ({ currentTarget }): boolean => !isCombobox(currentTarget); +} diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/index.ts b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/index.ts index 1af726ee49a..cb68f8e3a4d 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/index.ts +++ b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/index.ts @@ -8,12 +8,15 @@ import { Cell } from './Cell'; import type { CellCheckboxProps } from './CellCheckbox'; import { CellCheckbox } from './CellCheckbox'; import type { InputCellComponent } from '../types/InputCellComponent'; +import type { CellTextResourceInputProps } from './CellTextResource'; +import { CellTextResource } from './CellTextResource'; type CellComponent = typeof Cell & { Textfield: InputCellComponent; Textarea: InputCellComponent; Button: InputCellComponent; Checkbox: InputCellComponent; + TextResource: InputCellComponent; }; export const StudioInputTableCell = Cell as CellComponent; @@ -22,5 +25,8 @@ StudioInputTableCell.Textfield = new CellTextfield('StudioInputTable.Cell.Textfi StudioInputTableCell.Textarea = new CellTextarea('StudioInputTable.Cell.Textarea').component(); StudioInputTableCell.Button = new CellButton('StudioInputTable.Cell.Button').component(); StudioInputTableCell.Checkbox = new CellCheckbox('StudioInputTable.Cell.Checkbox').component(); +StudioInputTableCell.TextResource = new CellTextResource( + 'StudioInputTable.Cell.TextResource', +).component(); StudioInputTableCell.displayName = 'StudioInputTable.Cell'; diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTable.test.tsx b/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTable.test.tsx index a6c99c36970..b6223967325 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTable.test.tsx +++ b/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTable.test.tsx @@ -15,6 +15,10 @@ import { headerCheckboxLabel, textareaLabel, textfieldLabel, + textResourcePickerLabel, + textResourceProps, + textResourceSearchLabel, + textResourceValueLabel, } from './test-data/testTableData'; import type { UserEvent } from '@testing-library/user-event'; import userEvent from '@testing-library/user-event'; @@ -27,13 +31,15 @@ import type { EventName } from './types/EventName'; import type { EventProps } from './types/EventProps'; import type { EventPropName } from './types/EventPropName'; import { StringUtils } from '@studio/pure-functions'; +import type { CellTextResourceInputProps } from './Cell/CellTextResource'; -type ElementName = 'checkbox' | 'textfield' | 'textarea' | 'button'; +type ElementName = 'checkbox' | 'textfield' | 'textarea' | 'button' | 'textResource'; type NativeElement = { checkbox: HTMLInputElement; textfield: HTMLInputElement; textarea: HTMLTextAreaElement; button: HTMLButtonElement; + textResource: HTMLInputElement; }[Name]; // Test data: @@ -108,18 +114,23 @@ describe('StudioInputTable', () => { expect(getTextfieldInRow(2)).toHaveFocus(); await user.keyboard('{ArrowRight}'); // Move right to textarea 2 expect(getTextareaInRow(2)).toHaveFocus(); + await user.keyboard('{ArrowRight}'); // Move right to text resource 2 + expect(getTextResourceValueInRow(2)).toHaveFocus(); + await user.keyboard('{ArrowRight}'); // Unselect text in text resource 2 + expect(getTextResourceValueInRow(2)).toHaveFocus(); await user.keyboard('{ArrowRight}'); // Move right to button 2 expect(getButtonInRow(2)).toHaveFocus(); await user.keyboard('{ArrowUp}'); // Move up to button 1 expect(getButtonInRow(1)).toHaveFocus(); - await user.keyboard('{ArrowLeft}'); // Move left to textarea 1 - expect(getTextareaInRow(1)).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); // Move left to text resource 1 + expect(getTextResourceValueInRow(1)).toHaveFocus(); }); type TextboxTestCase = () => HTMLInputElement | HTMLTextAreaElement; const textboxTestCases: { [key: string]: TextboxTestCase } = { textfield: () => getTextfieldInRow(2), textarea: () => getTextareaInRow(2), + textResource: () => getTextResourceValueInRow(2), }; type TextboxTestCaseName = keyof typeof textboxTestCases; const textboxTestCaseNames: TextboxTestCaseName[] = Object.keys(textboxTestCases); @@ -147,8 +158,8 @@ describe('StudioInputTable', () => { render(); const textbox = textboxTestCases[key](); await user.type(textbox, 'test'); - await user.keyboard('{ArrowRight}'); // Move focus out - await user.keyboard('{ArrowLeft}'); // Move focus back in - now the text should be selected + await user.click(document.body); // Move focus out + await user.click(textbox); // Move focus back in - now the text should be selected expect(textbox.selectionStart).toBe(0); expect(textbox.selectionEnd).toBe(4); }); @@ -221,6 +232,21 @@ describe('StudioInputTable', () => { }, ); + const keysThatShouldNotMoveFocusInSearchMode: MovementKey[] = ['ArrowUp', 'ArrowDown', 'Enter']; + + it.each(keysThatShouldNotMoveFocusInSearchMode)( + 'Does not move focus when the user presses the %s key in a text resource input element in search mode', + async (key) => { + const user = userEvent.setup(); + render(); + await user.click(getSearchButtonInRow(2)); + const combobox = getTextResourceComboboxInRow(2); + await user.click(combobox); + await user.keyboard(`{${key}}`); + expect(combobox).toHaveFocus(); + }, + ); + describe('Forwards the refs to the input elements', () => { type TestCase = { render: (ref: ForwardedRef) => RenderResult; @@ -246,6 +272,10 @@ describe('StudioInputTable', () => { render: (ref) => renderSingleButtonCell({ children: testLabel }, ref), getElement: () => getButton(testLabel), }, + textResource: { + render: (ref) => renderSingleTextResourceCell(textResourceProps(0), ref), + getElement: () => getTextbox(textResourceValueLabel(0)) as HTMLInputElement, + }, }; test.each(Object.keys(testCases))('%s', (key) => { @@ -332,6 +362,23 @@ describe('StudioInputTable', () => { }, }, }, + textResource: { + change: { + render: (onChange) => renderSingleTextResourceCell({ ...textResourceProps(0), onChange }), + action: (user) => user.type(screen.getByRole('textbox'), 'a'), + }, + focus: { + render: (onFocus) => renderSingleTextResourceCell({ ...textResourceProps(0), onFocus }), + action: (user) => user.click(screen.getByRole('textbox')), + }, + blur: { + render: (onBlur) => renderSingleTextResourceCell({ ...textResourceProps(0), onBlur }), + action: async (user) => { + await user.click(screen.getByRole('textbox')); + await user.tab(); + }, + }, + }, }; describe.each(Object.keys(testCases))('%s', (key) => { @@ -357,6 +404,7 @@ describe('StudioInputTable', () => { }); type ArrowKey = 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight'; +type MovementKey = ArrowKey | 'Enter' | 'Tab'; const renderStudioInputTable = (props: StudioInputTableProps = {}): RenderResult => render(); @@ -401,6 +449,16 @@ const renderSingleCheckboxCell = ( , ); +const renderSingleTextResourceCell = ( + props: CellTextResourceInputProps, + ref?: ForwardedRef, +): RenderResult => + render( + + + , + ); + const getTable = (): HTMLTableElement => screen.getByRole('table'); const getCheckbox = (name: string): HTMLInputElement => screen.getByRole('checkbox', { name }) as HTMLInputElement; @@ -415,6 +473,12 @@ const getButton = (name: string): HTMLButtonElement => screen.getByRole('button', { name }) as HTMLButtonElement; const getButtonInRow = (rowNumber: number): HTMLButtonElement => getButton(buttonLabel(rowNumber)) as HTMLButtonElement; +const getTextResourceValueInRow = (rowNumber: number): HTMLInputElement => + getTextbox(textResourceValueLabel(rowNumber)) as HTMLInputElement; +const getTextResourceComboboxInRow = (rowNumber: number): HTMLInputElement => + screen.getByRole('combobox', { name: textResourcePickerLabel(rowNumber) }) as HTMLInputElement; +const getSearchButtonInRow = (rowNumber: number): HTMLButtonElement => + screen.getByRole('radio', { name: textResourceSearchLabel(rowNumber) }) as HTMLButtonElement; const expectCaretPosition = ( element: HTMLInputElement | HTMLTextAreaElement, @@ -438,7 +502,7 @@ const placeCaretAtPosition = ( position: number, ): void => element.setSelectionRange(position, position); -const expectedNumberOfColumns = 5; +const expectedNumberOfColumns = 6; const expectedNumberOfHeaderRows = 1; const expectedNumberOfBodyRows = 3; const expectedNumberOfRows = expectedNumberOfBodyRows + expectedNumberOfHeaderRows; diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/dom-utils/isCombobox.test.tsx b/frontend/libs/studio-components/src/components/StudioInputTable/dom-utils/isCombobox.test.tsx new file mode 100644 index 00000000000..7b4a1c4b356 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioInputTable/dom-utils/isCombobox.test.tsx @@ -0,0 +1,17 @@ +import { isCombobox } from './isCombobox'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +describe('isCombobox', () => { + it('Returns true when the element is a combobox', () => { + render(); + const element: HTMLInputElement = screen.getByRole('combobox'); + expect(isCombobox(element)).toBe(true); + }); + + it('Returns false when the element is not a combobox', () => { + render(); + const element: HTMLInputElement = screen.getByRole('textbox'); + expect(isCombobox(element)).toBe(false); + }); +}); diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/dom-utils/isCombobox.ts b/frontend/libs/studio-components/src/components/StudioInputTable/dom-utils/isCombobox.ts new file mode 100644 index 00000000000..0daebff689a --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioInputTable/dom-utils/isCombobox.ts @@ -0,0 +1,3 @@ +export function isCombobox(element: HTMLInputElement): boolean { + return element.getAttribute('role') === 'combobox'; +} diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/test-data/TestTable.tsx b/frontend/libs/studio-components/src/components/StudioInputTable/test-data/TestTable.tsx index f9555904705..15d9861c004 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/test-data/TestTable.tsx +++ b/frontend/libs/studio-components/src/components/StudioInputTable/test-data/TestTable.tsx @@ -9,7 +9,10 @@ import { textareaHeader, textfieldHeader, textHeader, + textResourceHeader, + textResourceTexts, } from './testTableData'; +import { textResourcesMock } from '../../../test-data/textResourcesMock'; export function TestTable(props: StudioInputTableProps): ReactElement { return ( @@ -20,6 +23,7 @@ export function TestTable(props: StudioInputTableProps): ReactElement { {textHeader} {textfieldHeader} {textareaHeader} + {textResourceHeader} {buttonHeader} @@ -53,6 +57,13 @@ function TestRow({ rowNumber: rn }: TestRowProps): ReactElement { name={testData.textareaName(rn)} label={testData.textareaLabel(rn)} /> + {}} + onChangeTextResource={() => {}} + texts={textResourceTexts(rn)} + /> {testData.buttonLabel(rn)} ); diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/test-data/testTableData.ts b/frontend/libs/studio-components/src/components/StudioInputTable/test-data/testTableData.ts index 4597ada6041..06bc1a70799 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/test-data/testTableData.ts +++ b/frontend/libs/studio-components/src/components/StudioInputTable/test-data/testTableData.ts @@ -1,8 +1,13 @@ +import type { CellTextResourceInputProps } from '../Cell/CellTextResource'; +import type { TextResourceInputTexts } from '../../StudioTextResourceInput/types/TextResourceInputTexts'; +import { textResourcesMock } from '../../../test-data/textResourcesMock'; + export const headerCheckboxLabel = 'Select all'; export const textHeader = 'Text'; export const textfieldHeader = 'Textfield'; export const textareaHeader = 'Textarea'; export const buttonHeader = 'Button'; +export const textResourceHeader = 'Text Resource'; export const checkboxValue = (rowNumber: number) => `checkboxValue${rowNumber}`; export const checkboxName = (rowNumber: number) => `checkboxName${rowNumber}`; export const checkboxLabel = (rowNumber: number) => `Checkbox ${rowNumber}`; @@ -12,3 +17,24 @@ export const textfieldLabel = (rowNumber: number) => `Textfield ${rowNumber}`; export const textareaName = (rowNumber: number) => `textarea${rowNumber}`; export const textareaLabel = (rowNumber: number) => `Textarea ${rowNumber}`; export const buttonLabel = (rowNumber: number) => `Button ${rowNumber}`; +export const textResourcePickerLabel = (rowNumber: number) => `Text resource ${rowNumber}`; +export const textResourceValueLabel = (rowNumber: number) => `Text value ${rowNumber}`; +export const textResourceEditLabel = (rowNumber: number) => `Edit text ${rowNumber}`; +export const textResourceSearchLabel = (rowNumber: number) => `Search for text ${rowNumber}`; + +export const textResourceProps = (rowNumber: number): CellTextResourceInputProps => ({ + textResources: textResourcesMock, + texts: textResourceTexts(rowNumber), + currentId: 'land.NO', + onChangeCurrentId: jest.fn(), + onChangeTextResource: jest.fn(), +}); + +export const textResourceTexts = (rowNumber: number): TextResourceInputTexts => ({ + editValue: textResourceEditLabel(rowNumber), + emptyResourceList: 'Fant ingen tekstressurser', + idLabel: 'ID:', + search: textResourceSearchLabel(rowNumber), + textResourcePickerLabel: textResourcePickerLabel(rowNumber), + valueLabel: textResourceValueLabel(rowNumber), +}); diff --git a/frontend/libs/studio-hooks/src/hooks/usePropState.ts b/frontend/libs/studio-hooks/src/hooks/usePropState.ts index 6df9aba30bd..552a7c7f9f6 100644 --- a/frontend/libs/studio-hooks/src/hooks/usePropState.ts +++ b/frontend/libs/studio-hooks/src/hooks/usePropState.ts @@ -2,7 +2,7 @@ import type { Dispatch, SetStateAction } from 'react'; import { useEffect, useState } from 'react'; export function usePropState(prop: T): [T, Dispatch>] { - const [state, setState] = useState(prop); + const [state, setState] = useState(prop); useEffect(() => { setState(prop);