diff --git a/.nycrc.json b/.nycrc.json index b6bf7d4..c0dcf40 100644 --- a/.nycrc.json +++ b/.nycrc.json @@ -5,5 +5,11 @@ "statements": 90, "lines": 90, "include": ["src/**/*.ts", "src/**/*.tsx"], - "exclude": ["cypress/**/*.*", "**/*.d.ts", "**/*.cy.tsx", "**/*.cy.ts","src/utils"] + "exclude": [ + "cypress/**/*.*", + "**/*.d.ts", + "**/*.cy.tsx", + "**/*.cy.ts", + "src/utils" + ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 321a3e6..a9bcee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed -- `StringListArrayWidget`, `StringListWidget`, `StringChoiceWidget` now fail gracefully in case of a mismatched persisted selection +- `StringListArrayWidget`, `StringListWidget`, `StringChoiceWidget` now fail gracefully in case of a mismatched persisted selection ## [6.1.14] 2023-06-14 diff --git a/__tests__/factories.ts b/__tests__/factories.ts index 94bad96..c1c06d8 100644 --- a/__tests__/factories.ts +++ b/__tests__/factories.ts @@ -147,3 +147,16 @@ export const getStringChoiceWidgetConfiguration = () => { type: 'StringChoiceWidget' as const } } + +export const getFreeformInputWidgetConfiguration = () => { + return { + type: 'FreeformInputWidget' as const, + label: 'Freeform input', + name: 'freeform_input', + help: 'Enter a freeform input', + details: { + dtype: 'string' as const + }, + required: true + } +} diff --git a/__tests__/widgets/FreeformInputWidget.spec.ts b/__tests__/widgets/FreeformInputWidget.spec.ts new file mode 100644 index 0000000..cea5004 --- /dev/null +++ b/__tests__/widgets/FreeformInputWidget.spec.ts @@ -0,0 +1,81 @@ +/** + * Unit tests for FreeformInputWidget validation: string, integer and float values. + */ +import { expect } from '@jest/globals' + +import { isDigitKey, isInteger, isFloat, keyDownHandler } from '../../src' + +const asEvent = (key: string): React.KeyboardEvent => { + return { + key, + preventDefault: jest.fn(), + stopPropagation: jest.fn() + } as unknown as React.KeyboardEvent +} + +describe('', () => { + describe('Validation', () => { + describe('Input validation', () => { + it('accepts digit input', () => { + expect(isDigitKey('1')).toBeTruthy() + expect(isDigitKey('0')).toBeTruthy() + expect(isDigitKey('9')).toBeTruthy() + }) + + it('accepts float input', () => { + expect(isFloat('Delete')).toBeTruthy() + expect(isFloat('Backspace')).toBeTruthy() + expect(isFloat('ArrowLeft')).toBeTruthy() + expect(isFloat('ArrowRight')).toBeTruthy() + expect(isFloat('ArrowUp')).toBeTruthy() + expect(isFloat('ArrowDown')).toBeTruthy() + + expect(isFloat('.')).toBeTruthy() + expect(isFloat('0')).toBeTruthy() + expect(isFloat('1')).toBeTruthy() + expect(isFloat('9')).toBeTruthy() + }) + + it('accepts integer input', () => { + expect(isInteger('Delete')).toBeTruthy() + expect(isInteger('Backspace')).toBeTruthy() + expect(isInteger('ArrowLeft')).toBeTruthy() + expect(isInteger('ArrowRight')).toBeTruthy() + expect(isInteger('ArrowUp')).toBeTruthy() + expect(isInteger('ArrowDown')).toBeTruthy() + + expect(isInteger('1')).toBeTruthy() + expect(isInteger('0')).toBeTruthy() + expect(isInteger('9')).toBeTruthy() + }) + + it('rejects digit input', () => { + expect(isDigitKey('a')).toBeFalsy() + expect(isDigitKey('b')).toBeFalsy() + expect(isDigitKey('A')).toBeFalsy() + }) + + it('rejects not int input', () => { + expect(isInteger('.')).toBeFalsy() + expect(isInteger('a')).toBeFalsy() + expect(isInteger('_')).toBeFalsy() + }) + + it('rejects not float input', () => { + expect(isFloat('a')).toBeFalsy() + expect(isFloat('_')).toBeFalsy() + }) + + it('keyDownHandler works as expected', () => { + const validator = keyDownHandler(isFloat) + let ev = asEvent('A') + expect(validator(ev)) + expect(ev.preventDefault).toBeCalled() + + ev = asEvent('2') + expect(validator(ev)) + expect(ev.preventDefault).not.toBeCalled() + }) + }) + }) +}) diff --git a/cypress/component/ExclusiveGroupWidget.cy.tsx b/cypress/component/ExclusiveGroupWidget.cy.tsx index c8349fc..839384b 100644 --- a/cypress/component/ExclusiveGroupWidget.cy.tsx +++ b/cypress/component/ExclusiveGroupWidget.cy.tsx @@ -9,7 +9,8 @@ import { getStringListArrayWidgetConfiguration, getGeographicExtentWidgetConfiguration, getTextWidgetConfiguration, - getStringChoiceWidgetConfiguration + getStringChoiceWidgetConfiguration, + getFreeformInputWidgetConfiguration } from '../../__tests__/factories' const Form = ({ @@ -384,6 +385,202 @@ describe('', () => { ]) }) + it('with FreeformInputWidget and StringListArrayWidget', () => { + const configuration = { + type: 'ExclusiveGroupWidget' as const, + label: 'Generic selections', + help: null, + name: 'checkbox_groups', + children: ['variable', 'freeform_input'], + details: { + default: 'variable' + } + } + + const formConfiguration = [ + configuration, + getStringListArrayWidgetConfiguration(), + getFreeformInputWidgetConfiguration() + ] + + const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') + + cy.viewport(800, 600) + cy.mount( + +
+ + +
+ ).then(({ rerender }) => { + cy.findByLabelText('Lake shape factor').click() + cy.findByLabelText('Soil temperature level 3').click() + + cy.findByText('submit').click() + + cy.get('@stubbedHandleSubmit').should('have.been.calledOnceWith', [ + ['variable', 'soil_temperature_level_3'], + ['variable', 'lake_shape_factor'] + ]) + + cy.log('Re-render with constraints.') + rerender( + +
+ + +
+ ) + + cy.findByLabelText('2m dewpoint temperature').should('be.disabled') + cy.findByLabelText('Lake bottom temperature').should('not.be.disabled') + + cy.findByLabelText('Lake ice depth').click() + cy.findByText('Lakes').click() + cy.findByText('1 selected item') + }) + }) + + it('with FreeformInputWidget and StringListWidget', () => { + const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') + + cy.viewport(1200, 900) + const configuration = { + type: 'ExclusiveGroupWidget' as const, + label: 'Generic selections', + help: null, + name: 'checkbox_groups', + children: ['freeform_input', 'product_type'], + details: { + default: 'freeform_input' + } + } + + const formConfiguration = [ + configuration, + getFreeformInputWidgetConfiguration(), + getStringListWidgetConfiguration() + ] + + cy.mount( + +
+ + +
+ ) + + cy.findByLabelText('Freeform input') + + cy.get('input[name="freeform_input"]').type('a value') + + cy.findByText('submit').click() + + cy.get('@stubbedHandleSubmit').should('have.been.calledOnceWith', [ + ['freeform_input', 'a value'] + ]) + }) + + it('with FreeformInputWidget and TextWidget', () => { + cy.viewport(1200, 900) + const configuration = { + type: 'ExclusiveGroupWidget' as const, + label: 'Generic selections', + help: null, + name: 'checkbox_groups', + children: ['freeform_input', 'surface_help'], + details: { + default: 'freeform_input' + } + } + + const formConfiguration = [ + configuration, + getFreeformInputWidgetConfiguration(), + getTextWidgetConfiguration() + ] + + cy.mount( + + + + ) + + cy.findByLabelText('Freeform input') + }) + + it('with FreeformInputWidget and StringChoiceWidget', () => { + cy.viewport(1200, 900) + const configuration = { + type: 'ExclusiveGroupWidget' as const, + label: 'Generic selections', + help: null, + name: 'checkbox_groups', + children: ['freeform_input', 'format'], + details: { + default: 'freeform_input' + } + } + + const formConfiguration = [ + configuration, + getFreeformInputWidgetConfiguration(), + getStringChoiceWidgetConfiguration() + ] + + cy.mount( + + + + ) + + cy.findByLabelText('Freeform input') + }) + it('multiple ExclusiveGroupWidget', () => { const thisExclusive = { type: 'ExclusiveGroupWidget' as const, @@ -412,6 +609,7 @@ describe('', () => { otherExclusive, getStringChoiceWidgetConfiguration(), getTextWidgetConfiguration(), + getFreeformInputWidgetConfiguration(), getStringListWidgetConfiguration(), getGeographicExtentWidgetConfiguration(), { diff --git a/cypress/component/FreeformInputWidget.cy.tsx b/cypress/component/FreeformInputWidget.cy.tsx new file mode 100644 index 0000000..c4fc804 --- /dev/null +++ b/cypress/component/FreeformInputWidget.cy.tsx @@ -0,0 +1,366 @@ +import React from 'react' + +import { FreeformInputWidget, TooltipProvider } from '../../src' + +const Form = ({ + children, + handleSubmit +}: { + children: React.ReactNode + handleSubmit?: (...args: any) => void +}) => { + return ( +
{ + ev.preventDefault() + const formData = new FormData(ev.currentTarget) + handleSubmit([...formData.entries()]) + }} + > + {children} + +
+ ) +} + +const Wrapper = ({ + children, + handleSubmit +}: { + children: React.ReactNode + handleSubmit?: (...args: any) => void +}) => { + return ( + +
{children}
+
+ ) +} + +describe('', () => { + afterEach(() => { + cy.clearLocalStorage() + }) + + it('renders a FreeformInputWidget', () => { + cy.mount( + + + + ) + }) + + it('can write a string', () => { + cy.mount( + + + + ) + + cy.get('input').type('a value') + cy.get('input').should('have.value', 'a value') + }) + + it('can write a float', () => { + cy.mount( + + + + ) + + cy.get('input').type('a value') + cy.get('input').should('have.value', '') + + cy.get('input').clear().type('3.14') + cy.get('input').should('have.value', '3.14') + + cy.get('input').clear().type('3.14e-2') + cy.get('input').should('have.value', '3.14e-2') + }) + + it('can write an integer', () => { + cy.mount( + + + + ) + + cy.get('input').type('a value') + cy.get('input').should('have.value', '') + + cy.get('input').clear().type('3.14') + cy.get('input').should('have.value', '314') + + cy.get('input').clear().type('3e10') + cy.get('input').should('have.value', '3e10') + + cy.get('input').clear().type('2') + cy.get('input').should('have.value', '2') + + /* Check with Up/Down arrows */ + cy.get('input').clear().type('2') + cy.get('input').should('have.value', '2') + cy.get('input').type('{uparrow}') + cy.get('input').should('have.value', '3') + cy.get('input').type('{downarrow}') + cy.get('input').type('{downarrow}') + cy.get('input').should('have.value', '1') + }) + + it('can write a string', () => { + cy.mount( + + + + ) + + cy.get('input').type('a value') + cy.get('input').should('have.value', 'a value') + + /* Check with Up/Down arrows */ + cy.get('input').clear().type('a value') + cy.get('input').should('have.value', 'a value') + cy.get('input').type('{uparrow}') + cy.get('input').should('have.value', 'a value') + cy.get('input').type('{downarrow}') + cy.get('input').type('{downarrow}') + cy.get('input').should('have.value', 'a value') + }) + + /* + * hydration tests + */ + + it('hydrates its selection (string)', () => { + localStorage.setItem( + 'formSelection', + JSON.stringify({ + dataset: 'fake', + inputs: { freeform: 'a value' } + }) + ) + + cy.mount( + + + + ) + + cy.get('input').should('have.value', 'a value') + }) + + it('hydrates its selection (float)', () => { + localStorage.setItem( + 'formSelection', + JSON.stringify({ + dataset: 'fake', + inputs: { freeform: 3.14 } + }) + ) + + cy.mount( + + + + ) + + cy.get('input').should('have.value', '3.14') + }) + + it('hydrates its selection (int)', () => { + localStorage.setItem( + 'formSelection', + JSON.stringify({ + dataset: 'fake', + inputs: { freeform: 3 } + }) + ) + + cy.mount( + + + + ) + + cy.get('input').should('have.value', '3') + }) + + /* + * Test to prevent different types from being hydrated. + */ + + it('hydrates its selection - but failed due to different types (string > float)', () => { + localStorage.setItem( + 'formSelection', + JSON.stringify({ + dataset: 'fake', + inputs: { freeform: 'a value' } + }) + ) + + cy.mount( + + + + ) + + cy.get('input').should('have.value', '') + }) + + it('hydrates its selection - but failed due to different types (float > string)', () => { + localStorage.setItem( + 'formSelection', + JSON.stringify({ + dataset: 'fake', + inputs: { freeform: 3.14 } + }) + ) + + cy.mount( + + + + ) + + cy.get('input').should('have.value', '') + }) + + it('hydrates its selection - but failed due to different types (int > string)', () => { + localStorage.setItem( + 'formSelection', + JSON.stringify({ + dataset: 'fake', + inputs: { freeform: 3 } + }) + ) + + cy.mount( + + + + ) + + cy.get('input').should('have.value', '') + }) +}) diff --git a/package.json b/package.json index 2f78839..90b3c67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ecmwf-projects/cads-ui-library", - "version": "6.1.16", + "version": "7.0.0", "description": "Common UI kit library", "repository": { "type": "git", diff --git a/src/index.ts b/src/index.ts index 6d79572..eb237e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ export { ExclusiveGroupWidget } from './widgets/ExclusiveGroupWidget' export { StringListArrayWidget } from './widgets/StringListArrayWidget' export { StringListWidget } from './widgets/StringListWidget' export { StringChoiceWidget } from './widgets/StringChoiceWidget' +export { FreeformInputWidget } from './widgets/FreeformInputWidget' /** * Widget utils. @@ -34,4 +35,11 @@ export { getGeoExtentFieldValue } from './widgets/GeographicExtentWidget' +export { + isDigitKey, + isInteger, + isFloat, + keyDownHandler +} from './widgets/FreeformInputWidget' + export { createWidget } from './utils/widgetFactory' diff --git a/src/types/Form.ts b/src/types/Form.ts index c7526f8..cc841de 100644 --- a/src/types/Form.ts +++ b/src/types/Form.ts @@ -5,6 +5,7 @@ import { GeographicExtentWidgetConfiguration } from '../widgets/GeographicExtent import { TextWidgetConfiguration } from '../widgets/TextWidget' import { LicenceWidgetConfiguration } from '../widgets/LicenceWidget' import { ExclusiveGroupWidgetConfiguration } from '../widgets/ExclusiveGroupWidget' +import { FreeformInputWidgetConfiguration } from '../widgets/FreeformInputWidget' export type FormConfiguration = | ExclusiveGroupWidgetConfiguration @@ -14,3 +15,4 @@ export type FormConfiguration = | GeographicExtentWidgetConfiguration | TextWidgetConfiguration | LicenceWidgetConfiguration + | FreeformInputWidgetConfiguration diff --git a/src/utils/widgetFactory.tsx b/src/utils/widgetFactory.tsx index 2faf6dc..ab33e2f 100644 --- a/src/utils/widgetFactory.tsx +++ b/src/utils/widgetFactory.tsx @@ -4,7 +4,8 @@ import { StringListArrayWidget, StringListWidget, StringChoiceWidget, - TextWidget + TextWidget, + FreeformInputWidget } from '../index' import type { FormConfiguration } from '../types/Form' @@ -58,6 +59,16 @@ const createWidget: CreateWidget = (configuration, constraints, opts) => { {...props} /> ) + case 'FreeformInputWidget': + // eslint-disable-next-line react/display-name + return props => ( + + ) case 'StringListWidget': // eslint-disable-next-line react/display-name return props => ( diff --git a/src/widgets/FreeformInputWidget.tsx b/src/widgets/FreeformInputWidget.tsx new file mode 100644 index 0000000..6952076 --- /dev/null +++ b/src/widgets/FreeformInputWidget.tsx @@ -0,0 +1,320 @@ +/* istanbul ignore file */ +/* See cypress/component/FreeformInputWidget.cy.tsx */ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import styled from 'styled-components' + +import { + Fieldset, + Legend, + ReservedSpace, + Widget, + WidgetHeader, + WidgetTitle, + Error +} from './Widget' +import { WidgetTooltip } from '..' +import { useEventListener, useReadLocalStorage } from 'usehooks-ts' + +interface FreeformInputWidgetDetailsCommon { + comment?: string +} + +interface FreeformInputWidgetDetailsFloat { + dtype: 'float' + default?: number +} + +interface FreeformInputWidgetDetailsInteger { + dtype: 'int' + default?: number +} + +interface FreeformInputWidgetDetailsString { + dtype: 'string' + default?: string +} + +export type FreeformInputWidgetDetails = FreeformInputWidgetDetailsCommon & + ( + | FreeformInputWidgetDetailsFloat + | FreeformInputWidgetDetailsInteger + | FreeformInputWidgetDetailsString + ) + +export interface FreeformInputWidgetConfiguration { + type: 'FreeformInputWidget' + name: string + label: string + help?: string | null + required: boolean + details: FreeformInputWidgetDetails +} + +export interface FreeformInputWidgetProps { + configuration: FreeformInputWidgetConfiguration + /** + * Whether the underlying fieldset should be functionally and visually disabled. + */ + fieldsetDisabled?: boolean + /** + * Whether to hide the widget label from ARIA. + */ + labelAriaHidden?: boolean + /** + * When true, bypass the required attribute if all options are made unavailable by constraints. + */ + bypassRequiredForConstraints?: boolean +} + +const FreeformInputWidget = ({ + configuration, + labelAriaHidden = true, + fieldsetDisabled +}: FreeformInputWidgetProps) => { + const [value, setValue] = useState() + + /** + * Handle form Clear all + */ + const documentRef = useRef( + typeof window !== 'undefined' ? document : null + ) + + useEventListener( + 'formAction', + ev => { + if (!('detail' in ev)) return + if (!('type' in ev.detail)) return + if (ev.detail.type !== 'clearAll') return + + setValue('') + }, + documentRef + ) + + const persistedSelection = useReadLocalStorage<{ + dataset: { id: string } + inputs: { [k: string]: string | number } + }>('formSelection') + + const { type, help, label, name, required, details } = configuration + + /** + * Cache persisted selection, so we don't need to pass it as an effect dependency. + */ + const persistedSelectionRef = useRef(persistedSelection) + + useEffect(() => { + const getInitialSelection = (): string => { + if ( + persistedSelectionRef.current && + 'inputs' in persistedSelectionRef.current + ) { + if ( + typeof persistedSelectionRef.current.inputs[name] === 'string' && + details.dtype === 'string' + ) { + return persistedSelectionRef.current.inputs[name] as string + } else if ( + typeof persistedSelectionRef.current.inputs[name] === 'number' && + (details.dtype === 'float' || details.dtype === 'int') + ) { + return persistedSelectionRef.current.inputs[name].toString() + } + } + + return (details?.default ?? '').toString() + } + + setValue(getInitialSelection()) + }, [name, details]) + + if (!configuration) return null + + if (type !== 'FreeformInputWidget') return null + + const inputRef = useRef(null) + const fieldSetRef = useRef(null) + + const { + dtype, + default: defaultValue, + comment + } = configuration.details as FreeformInputWidgetDetails + + const { inputType, otherProps } = useInputType(dtype) + + const initialValue = value ?? defaultValue ?? '' + + return ( + + + + {label} + + + + + {required && value?.length === 0 ? ( + This field is required. + ) : null} + +
+ + {label} +
+ ) => { + setValue(ev.target.value) + }} + aria-invalid={required && value?.length === 0} + {...otherProps} + /> + {comment ?? ''} +
+
+
+
+ ) +} + +/** + * Prevents some keys from being entered into an input of type integer, so no dot, comma, etc. + * Only allowed: + * + / - / e + * @param ev The keydown event. + */ +const ALLOWED_KEYS = [ + 'e', + 'E', + '+', + '-', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'Backspace', + 'Delete', + 'Tab', + 'Enter' +] + +/** + * Prevents some keys from being entered into an input of type float, so only integer like, comma and dot. + */ +const ALLOWED_FLOAT_KEYS = [',', '.'] + +/** + * This method is used to check whether a key is a digit. + * @param value The value to check. + * @returns Whether the value is a digit. + */ +const isDigitKey = (value: string) => { + return value >= '0' && value <= '9' +} + +/** + * @param value The value to check. + * @returns Whether the value is an integer. + */ +const isInteger = (value: string) => + isDigitKey(value) || ALLOWED_KEYS.includes(value) + +/** + * @param value The value to check. + * @returns Whether the value is a float. + */ +const isFloat = (value: string) => + isDigitKey(value) || + ALLOWED_KEYS.includes(value) || + ALLOWED_FLOAT_KEYS.includes(value) + +/** + * A wrapper around keyDownHandler that takes a function that returns a boolean. + * @param func The function to call. + * @returns A function that takes a keydown event and calls func with the key. If func returns true, the event is not prevented. + */ +const keyDownHandler = + (func: (value: string) => boolean) => + (ev: React.KeyboardEvent) => { + if (func(ev.key)) { + return + } + ev.preventDefault() + ev.stopPropagation() + } + +const useInputType = (dtype: string) => { + return useMemo(() => { + if (dtype === 'float' || dtype === 'int') { + const typeFloat = dtype === 'float' + + return { + inputType: 'number', + otherProps: { + step: typeFloat ? undefined : '1', + onKeyDown: typeFloat + ? keyDownHandler(isFloat) + : keyDownHandler(isInteger) + } + } + } + return { + inputType: 'text', + otherProps: {} + } + }, [dtype]) +} + +const Wrapper = styled.div` + display: flex; + flex-flow: column; + margin: auto; + + label { + margin-bottom: 0.5em; + } + + margin: auto; + + label { + margin-bottom: 0.5em; + } + + input { + all: unset; + color: #9599a6; + border: 2px solid #9599a6; + border-radius: 4px; + padding: 1em; + } + + input::-webkit-outer-spin-button, + input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + input[type='number'] { + -moz-appearance: textfield; + } + + input[aria-invalid='true'] { + border: 2px solid #f44336; + } +` + +export { FreeformInputWidget } +export { isDigitKey, isInteger, isFloat, keyDownHandler }