From d3a81c641c11b0c3a619624b0be43659d0eb10f4 Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 23 Oct 2024 14:17:41 +0200 Subject: [PATCH] feat(StudioInputTable): Add event listeners on table level --- .../StudioInputTable/Cell/CellButton.tsx | 9 +- .../StudioInputTable/Cell/CellCheckbox.tsx | 8 +- .../StudioInputTable/Cell/CellTextarea.tsx | 22 +- .../StudioInputTable/Cell/CellTextfield.tsx | 30 ++- .../StudioInputTable/Cell/useEventProps.ts | 31 +++ .../StudioInputTable.test.tsx | 206 +++++++++++++++--- .../StudioInputTable/StudioInputTable.tsx | 14 +- .../StudioInputTableContext.tsx | 16 ++ .../StudioInputTable/types/EventName.ts | 1 + .../StudioInputTable/types/EventPropName.ts | 3 + .../StudioInputTable/types/EventProps.ts | 8 + .../src/StringUtils/StringUtils.test.ts | 12 + .../src/StringUtils/StringUtils.ts | 4 + 13 files changed, 313 insertions(+), 51 deletions(-) create mode 100644 frontend/libs/studio-components/src/components/StudioInputTable/Cell/useEventProps.ts create mode 100644 frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTableContext.tsx create mode 100644 frontend/libs/studio-components/src/components/StudioInputTable/types/EventName.ts create mode 100644 frontend/libs/studio-components/src/components/StudioInputTable/types/EventPropName.ts create mode 100644 frontend/libs/studio-components/src/components/StudioInputTable/types/EventProps.ts diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellButton.tsx b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellButton.tsx index f6cc34dbfc2..6b99065440a 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellButton.tsx +++ b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellButton.tsx @@ -6,6 +6,7 @@ import type { StudioButtonProps } from '../../StudioButton'; import { StudioButton } from '../../StudioButton'; import { BaseInputCell } from './BaseInputCell'; import cn from 'classnames'; +import { useEventProps } from './useEventProps'; export type CellButtonProps = StudioButtonProps; @@ -14,10 +15,16 @@ export class CellButton extends BaseInputCell, ): 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 eventProps = useEventProps(rest); + const className = cn(classes.buttonCell, givenClass); + return ( - + ); } diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellCheckbox.tsx b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellCheckbox.tsx index 7ba87a57754..4a1697d3491 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellCheckbox.tsx +++ b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellCheckbox.tsx @@ -4,6 +4,7 @@ import React from 'react'; import type { StudioCheckboxProps } from '../../StudioCheckbox'; import { StudioCheckbox } from '../../StudioCheckbox'; import { BaseInputCell } from './BaseInputCell'; +import { useEventProps } from './useEventProps'; export type CellCheckboxProps = StudioCheckboxProps; @@ -12,9 +13,14 @@ export class CellCheckbox extends BaseInputCell, ): 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 eventProps = useEventProps(rest); + return ( - + ); } diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellTextarea.tsx b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellTextarea.tsx index 9aa158da3ff..8e8ba7d6a82 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellTextarea.tsx +++ b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellTextarea.tsx @@ -1,20 +1,35 @@ import { StudioTable } from '../../StudioTable'; -import type { ForwardedRef, ReactElement } from 'react'; -import React from 'react'; +import type { FocusEvent, ForwardedRef, ReactElement } from 'react'; +import React, { useCallback } from 'react'; + import classes from './Cell.module.css'; import type { StudioTextareaProps } from '../../StudioTextarea'; import { StudioTextarea } from '../../StudioTextarea'; import { BaseInputCell } from './BaseInputCell'; import cn from 'classnames'; import { isCaretAtEnd, isCaretAtStart, isSomethingSelected } from '../dom-utils/caretUtils'; +import { useEventProps } from './useEventProps'; export type CellTextareaProps = StudioTextareaProps; export class CellTextarea extends BaseInputCell { render( - { className: givenClass, ...rest }: CellTextareaProps, + { className: givenClass, onFocus, ...rest }: CellTextareaProps, 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.textareaCell, givenClass); return ( @@ -24,6 +39,7 @@ export class CellTextarea extends BaseInputCell ); diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellTextfield.tsx b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellTextfield.tsx index afe8e96c050..f85e5eef088 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellTextfield.tsx +++ b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/CellTextfield.tsx @@ -1,30 +1,40 @@ import { StudioTable } from '../../StudioTable'; -import type { ForwardedRef, ReactElement } from 'react'; -import React from 'react'; +import type { FocusEvent, ForwardedRef, ReactElement } from 'react'; +import React, { useCallback } from 'react'; + import type { StudioTextfieldProps } from '../../StudioTextfield'; import { StudioTextfield } from '../../StudioTextfield'; import classes from './Cell.module.css'; import { BaseInputCell } from './BaseInputCell'; import cn from 'classnames'; import { isCaretAtEnd, isCaretAtStart, isSomethingSelected } from '../dom-utils/caretUtils'; +import { useEventProps } from './useEventProps'; export type CellTextfieldProps = StudioTextfieldProps; export class CellTextfield extends BaseInputCell { render( - { className: givenClass, ...rest }: CellTextfieldProps, + { className: givenClass, onFocus, ...rest }: CellTextfieldProps, 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.textfieldCell, givenClass); + return ( - event.currentTarget.select()} - ref={ref} - size='small' - {...rest} - /> + ); } diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/Cell/useEventProps.ts b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/useEventProps.ts new file mode 100644 index 00000000000..8b8ea60307b --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioInputTable/Cell/useEventProps.ts @@ -0,0 +1,31 @@ +import type { HTMLAttributes } from 'react'; +import { useMemo } from 'react'; +import { useStudioInputTableContext } from '../StudioInputTableContext'; +import type { HTMLCellInputElement } from '../types/HTMLCellInputElement'; +import type { EventProps } from '../types/EventProps'; + +export function useEventProps({ + onBlur, + onFocus, + onChange, +}: Partial>): EventProps { + const { onChangeAny, onBlurAny, onFocusAny } = useStudioInputTableContext(); + + return useMemo>( + () => ({ + onChange: (event) => { + onChange?.(event); + onChangeAny?.(event); + }, + onFocus: (event) => { + onFocus?.(event); + onFocusAny?.(event); + }, + onBlur: (event) => { + onBlur?.(event); + onBlurAny?.(event); + }, + }), + [onChange, onFocus, onBlur, onChangeAny, onBlurAny, onFocusAny], + ); +} 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 068d8509a29..06c4c848bbd 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTable.test.tsx +++ b/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTable.test.tsx @@ -16,9 +16,35 @@ import { textareaLabel, textfieldLabel, } from './test-data/testTableData'; +import type { UserEvent } from '@testing-library/user-event'; import userEvent from '@testing-library/user-event'; +import type { CellTextfieldProps } from './Cell/CellTextfield'; +import type { CellTextareaProps } from './Cell/CellTextarea'; +import type { CellCheckboxProps } from './Cell/CellCheckbox'; +import type { CellButtonProps } from './Cell/CellButton'; +import type { HTMLCellInputElement } from './types/HTMLCellInputElement'; +import type { EventName } from './types/EventName'; +import type { EventProps } from './types/EventProps'; +import type { EventPropName } from './types/EventPropName'; +import { StringUtils } from '@studio/pure-functions'; + +type ElementName = 'checkbox' | 'textfield' | 'textarea' | 'button'; +type NativeElement = { + checkbox: HTMLInputElement; + textfield: HTMLInputElement; + textarea: HTMLTextAreaElement; + button: HTMLButtonElement; +}[Name]; + +// Test data: +const onChangeAny = jest.fn(); +const onFocusAny = jest.fn(); +const onBlurAny = jest.fn(); +const defaultProps: StudioInputTableProps = { onChangeAny, onFocusAny, onBlurAny }; describe('StudioInputTable', () => { + afterEach(jest.clearAllMocks); + it('Renders a table', () => { renderStudioInputTable(); expect(getTable()).toBeInTheDocument(); @@ -210,45 +236,22 @@ describe('StudioInputTable', () => { }; const testLabel = 'test'; const testCases: { - checkbox: TestCase; - textfield: TestCase; - textarea: TestCase; - button: TestCase; + [Name in ElementName]: TestCase>; } = { checkbox: { - render: (ref) => - render( - - - , - ), + render: (ref) => renderSingleCheckboxCell({ value: 'test', 'aria-label': testLabel }, ref), getElement: () => getCheckbox(testLabel), }, textfield: { - render: (ref) => - render( - - - , - ), + render: (ref) => renderSingleTextfieldCell({ label: testLabel }, ref), getElement: () => getTextbox(testLabel) as HTMLInputElement, }, textarea: { - render: (ref) => - render( - - - , - ), + render: (ref) => renderSingleTextareaCell({ label: testLabel }, ref), getElement: () => getTextbox(testLabel) as HTMLTextAreaElement, }, button: { - render: (ref) => - render( - - {testLabel} - , - ), + render: (ref) => renderSingleButtonCell({ children: testLabel }, ref), getElement: () => getButton(testLabel), }, }; @@ -258,12 +261,153 @@ describe('StudioInputTable', () => { testRefForwarding(renderComponent, getElement); }); }); + + describe('Triggers input level and table level event functions with the same events when the user performs a corresponding action', () => { + type TestCase = { + render: (mockFn: EventProps[EventPropName]) => RenderResult; + action: (user: UserEvent) => Promise; + }; + + const testCases: { + [Name in ElementName]: { + [Event in EventName]?: TestCase, Event>; + }; + } = { + textfield: { + change: { + render: (onChange) => renderSingleTextfieldCell({ label: 'test', onChange }), + action: (user) => user.type(screen.getByRole('textbox'), 'a'), + }, + focus: { + render: (onFocus) => renderSingleTextfieldCell({ label: 'test', onFocus }), + action: (user) => user.click(screen.getByRole('textbox')), + }, + blur: { + render: (onBlur) => renderSingleTextfieldCell({ label: 'test', onBlur }), + action: async (user) => { + await user.click(screen.getByRole('textbox')); + await user.tab(); + }, + }, + }, + textarea: { + change: { + render: (onChange) => renderSingleTextareaCell({ label: 'test', onChange }), + action: (user) => user.type(screen.getByRole('textbox'), 'a'), + }, + focus: { + render: (onFocus) => renderSingleTextareaCell({ label: 'test', onFocus }), + action: (user) => user.click(screen.getByRole('textbox')), + }, + blur: { + render: (onBlur) => renderSingleTextareaCell({ label: 'test', onBlur }), + action: async (user) => { + await user.click(screen.getByRole('textbox')); + await user.tab(); + }, + }, + }, + button: { + focus: { + render: (onFocus) => renderSingleButtonCell({ children: 'test', onFocus }), + action: (user) => user.click(screen.getByRole('button')), + }, + blur: { + render: (onBlur) => renderSingleButtonCell({ children: 'test', onBlur }), + action: async (user) => { + await user.click(screen.getByRole('button')); + await user.tab(); + }, + }, + }, + checkbox: { + change: { + render: (onChange) => + renderSingleCheckboxCell({ value: 'test', 'aria-label': 'test', onChange }), + action: (user) => user.click(screen.getByRole('checkbox')), + }, + focus: { + render: (onFocus) => + renderSingleCheckboxCell({ value: 'test', 'aria-label': 'test', onFocus }), + action: (user) => user.click(screen.getByRole('checkbox')), + }, + blur: { + render: (onBlur) => + renderSingleCheckboxCell({ value: 'test', 'aria-label': 'test', onBlur }), + action: async (user) => { + await user.click(screen.getByRole('checkbox')); + await user.tab(); + }, + }, + }, + }; + + describe.each(Object.keys(testCases))('%s', (key) => { + const testCasesForElement = testCases[key]; + + test.each(Object.keys(testCasesForElement))('%s', async (eventName) => { + const user = userEvent.setup(); + const onEvent = jest.fn(); + const { render: renderComponent, action } = testCasesForElement[eventName]; + renderComponent(onEvent); + await action(user); + await expect(onEvent).toHaveBeenCalledTimes(1); + + const tablePropName = 'on' + StringUtils.capitalize(eventName) + 'Any'; + const tableProp = defaultProps[tablePropName]; + await expect(tableProp).toHaveBeenCalledTimes(1); + const inputEvent = onEvent.mock.calls[0][0]; + const tableEvent = tableProp.mock.calls[0][0]; + await expect(inputEvent).toBe(tableEvent); + }); + }); + }); }); type ArrowKey = 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight'; -const renderStudioInputTable = (props: StudioInputTableProps = {}) => - render(); +const renderStudioInputTable = (props: StudioInputTableProps = {}): RenderResult => + render(); + +const renderSingleTextfieldCell = ( + props: CellTextfieldProps, + ref?: ForwardedRef, +): RenderResult => + render( + + + , + ); + +const renderSingleTextareaCell = ( + props: CellTextareaProps, + ref?: ForwardedRef, +): RenderResult => + render( + + + , + ); + +const renderSingleButtonCell = ( + props: CellButtonProps, + ref?: ForwardedRef, +): RenderResult => + render( + + + , + ); + +const renderSingleCheckboxCell = ( + props: CellCheckboxProps, + ref?: ForwardedRef, +): RenderResult => + render( + + + , + ); const getTable = (): HTMLTableElement => screen.getByRole('table'); const getCheckbox = (name: string): HTMLInputElement => @@ -309,7 +453,7 @@ const expectedNumberOfRows = expectedNumberOfBodyRows + expectedNumberOfHeaderRo function SingleRow({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTable.tsx b/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTable.tsx index 3430e575e45..66bfff53fb0 100644 --- a/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTable.tsx +++ b/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTable.tsx @@ -5,11 +5,13 @@ import classes from './StudioInputTable.module.css'; import cn from 'classnames'; import { useForwardedRef } from '@studio/hooks'; import { activateTabbingOnFirstInputElement } from './dom-utils/activateTabbingOnFirstInputElement'; +import type { StudioInputTableContextValue } from './StudioInputTableContext'; +import { StudioInputTableContext } from './StudioInputTableContext'; -export type StudioInputTableProps = StudioTableProps; +export type StudioInputTableProps = StudioTableProps & StudioInputTableContextValue; export const StudioInputTable = forwardRef( - ({ className: givenClass, children, ...rest }, ref) => { + ({ onChangeAny, onFocusAny, onBlurAny, className: givenClass, children, ...rest }, ref) => { const className = cn(classes.inputTable, givenClass); const forwardedRef = useForwardedRef(ref); const internalRef = useCallback( @@ -20,9 +22,11 @@ export const StudioInputTable = forwardRef - {children} - + + + {children} + + ); }, ); diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTableContext.tsx b/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTableContext.tsx new file mode 100644 index 00000000000..0132d2e2464 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioInputTable/StudioInputTableContext.tsx @@ -0,0 +1,16 @@ +import { createContext, useContext } from 'react'; +import type { HTMLCellInputElement } from './types/HTMLCellInputElement'; +import type { EventProps } from './types/EventProps'; +import type { EventPropName } from './types/EventPropName'; + +export type StudioInputTableContextValue< + Element extends HTMLCellInputElement = HTMLCellInputElement, +> = { + [Key in EventPropName as `${Key}Any`]?: EventProps[Key]; +}; + +export const StudioInputTableContext = createContext(null); + +export function useStudioInputTableContext() { + return useContext>(StudioInputTableContext); +} diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/types/EventName.ts b/frontend/libs/studio-components/src/components/StudioInputTable/types/EventName.ts new file mode 100644 index 00000000000..a7a2cd6e5fa --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioInputTable/types/EventName.ts @@ -0,0 +1 @@ +export type EventName = 'blur' | 'focus' | 'change'; diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/types/EventPropName.ts b/frontend/libs/studio-components/src/components/StudioInputTable/types/EventPropName.ts new file mode 100644 index 00000000000..78c4c179ce9 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioInputTable/types/EventPropName.ts @@ -0,0 +1,3 @@ +import type { EventName } from './EventName'; + +export type EventPropName = `on${Capitalize}`; diff --git a/frontend/libs/studio-components/src/components/StudioInputTable/types/EventProps.ts b/frontend/libs/studio-components/src/components/StudioInputTable/types/EventProps.ts new file mode 100644 index 00000000000..6883ce7368e --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioInputTable/types/EventProps.ts @@ -0,0 +1,8 @@ +import type { HTMLCellInputElement } from './HTMLCellInputElement'; +import type { HTMLAttributes } from 'react'; +import type { EventPropName } from './EventPropName'; + +export type EventProps = Pick< + HTMLAttributes, + EventPropName +>; diff --git a/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.test.ts b/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.test.ts index 73bf38ab0ec..17211ec8fce 100644 --- a/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.test.ts +++ b/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.test.ts @@ -104,4 +104,16 @@ describe('StringUtils', () => { expect(StringUtils.substringAfterLast('abc/def/', '/')).toBe(''); }); }); + + describe('capitalize', () => { + it('Capitalizes the first letter of the string', () => { + expect(StringUtils.capitalize('abc')).toBe('Abc'); + expect(StringUtils.capitalize('a')).toBe('A'); + expect(StringUtils.capitalize('')).toBe(''); + }); + + it('Works with empty strings', () => { + expect(StringUtils.capitalize('')).toBe(''); + }); + }); }); diff --git a/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.ts b/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.ts index b5a3927feb4..94e5e581acd 100644 --- a/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.ts +++ b/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.ts @@ -78,4 +78,8 @@ export class StringUtils { */ static substringAfterLast = (str: string, separator: string): string => ArrayUtils.last(str.split(separator)) || ''; + + static capitalize = (string: string): string => { + return string.charAt(0).toUpperCase() + string.slice(1); + }; }