From 7ba4d3b2903c9d354a21743b1d7b9cfdccde869b Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Tue, 12 Sep 2023 16:58:24 +0200 Subject: [PATCH 01/16] WIP: FreeformInputWidget --- cypress/component/FreeformInputWidget.cy.tsx | 65 +++++++ src/index.ts | 1 + src/widgets/FreeformInputWidget.tsx | 185 +++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 cypress/component/FreeformInputWidget.cy.tsx create mode 100644 src/widgets/FreeformInputWidget.tsx diff --git a/cypress/component/FreeformInputWidget.cy.tsx b/cypress/component/FreeformInputWidget.cy.tsx new file mode 100644 index 0000000..6f864b5 --- /dev/null +++ b/cypress/component/FreeformInputWidget.cy.tsx @@ -0,0 +1,65 @@ +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( + + ) + }) + +}) diff --git a/src/index.ts b/src/index.ts index 6d79572..9540f7c 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. diff --git a/src/widgets/FreeformInputWidget.tsx b/src/widgets/FreeformInputWidget.tsx new file mode 100644 index 0000000..39cd58e --- /dev/null +++ b/src/widgets/FreeformInputWidget.tsx @@ -0,0 +1,185 @@ +/* istanbul ignore file */ +/* See cypress/component/FreeformInputWidget.cy.tsx */ +import React, { useEffect, useMemo, useRef } from 'react' +import styled from 'styled-components' + +import { + Fieldset, + Legend, + Error, + ReservedSpace, + Widget, + WidgetHeader, + WidgetTitle +} from './Widget' +import { useBypassRequired } from '../utils' +import { WidgetTooltip } from '..' + +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 +} + +interface FreeformInputWidgetProps { + configuration: FreeformInputWidgetConfiguration + /** + * Permitted selections for the widget. + */ + constraints?: string[] + /** + * 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, + constraints, + fieldsetDisabled, + bypassRequiredForConstraints +}: FreeformInputWidgetProps) => { + if (!configuration) return null + + const { type, help, label, name, required } = configuration + + if (type !== 'FreeformInputWidget') return null + + const inputRef = useRef(null) + const fieldSetRef = useRef(null) + + const bypassed = useBypassRequired( + fieldSetRef, + bypassRequiredForConstraints, + constraints + ) + + const { + dtype, + default: defaultValue, + comment + } = configuration.details as FreeformInputWidgetDetails + + const { inputType, otherProps } = useInputType(dtype) + + return ( + + + + {label} + + + +
+ + {label} +
+ + {comment ?? ''} +
+
+
+
+ ) +} + +const useInputType = (dtype: string) => { + return useMemo(() => { + if (dtype === 'float' || dtype === 'int') { + return { + inputType: 'number', + otherProps: { + step: dtype === 'float' ? '' : '1' + } + } + } + return { + inputType: 'text', + otherProps: {} + } + }, [dtype]) +} + +const Wrapper = styled.div` + display: flex; + flex-flow: column; + margin: auto; + + label { + margin-bottom: 0.5em; + } + + input { + all: unset; + color: #9599a6; + border: 2px solid #9599a6; + border-radius: 4px; + } + + 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 } From fac15e09708b6c8280b5d9734b52f71643db32a8 Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Wed, 13 Sep 2023 09:02:17 +0200 Subject: [PATCH 02/16] WIP: Testing. --- cypress/component/FreeformInputWidget.cy.tsx | 36 +++++++++----------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/cypress/component/FreeformInputWidget.cy.tsx b/cypress/component/FreeformInputWidget.cy.tsx index 6f864b5..92e2a88 100644 --- a/cypress/component/FreeformInputWidget.cy.tsx +++ b/cypress/component/FreeformInputWidget.cy.tsx @@ -32,9 +32,7 @@ const Wrapper = ({ }) => { return ( -
- {children} -
+
{children}
) } @@ -45,21 +43,21 @@ describe('', () => { }) it('renders a FreeformInputWidget', () => { - - cy.mount( - - ) + cy.mount( + + + + ) }) - }) From dafe2d33a11648b1ab6ff1f92804cba9d2270b19 Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Mon, 18 Sep 2023 17:00:40 +0200 Subject: [PATCH 03/16] FreeformInputWidget first working version. --- cypress/component/FreeformInputWidget.cy.tsx | 308 +++++++++++++++++++ src/widgets/FreeformInputWidget.tsx | 130 +++++++- 2 files changed, 431 insertions(+), 7 deletions(-) diff --git a/cypress/component/FreeformInputWidget.cy.tsx b/cypress/component/FreeformInputWidget.cy.tsx index 92e2a88..cf140ad 100644 --- a/cypress/component/FreeformInputWidget.cy.tsx +++ b/cypress/component/FreeformInputWidget.cy.tsx @@ -60,4 +60,312 @@ describe('', () => { ) }) + + 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/src/widgets/FreeformInputWidget.tsx b/src/widgets/FreeformInputWidget.tsx index 39cd58e..c64fd52 100644 --- a/src/widgets/FreeformInputWidget.tsx +++ b/src/widgets/FreeformInputWidget.tsx @@ -1,19 +1,20 @@ /* istanbul ignore file */ /* See cypress/component/FreeformInputWidget.cy.tsx */ -import React, { useEffect, useMemo, useRef } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import styled from 'styled-components' import { Fieldset, Legend, - Error, ReservedSpace, Widget, WidgetHeader, - WidgetTitle + WidgetTitle, + Error } from './Widget' import { useBypassRequired } from '../utils' import { WidgetTooltip } from '..' +import { useReadLocalStorage } from 'usehooks-ts' interface FreeformInputWidgetDetailsCommon { comment?: string @@ -77,9 +78,46 @@ const FreeformInputWidget = ({ fieldsetDisabled, bypassRequiredForConstraints }: FreeformInputWidgetProps) => { - if (!configuration) return null + const [value, setValue] = useState() + + 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]) - const { type, help, label, name, required } = configuration + if (!configuration) return null if (type !== 'FreeformInputWidget') return null @@ -100,6 +138,8 @@ const FreeformInputWidget = ({ const { inputType, otherProps } = useInputType(dtype) + const initialValue = value ?? defaultValue ?? '' + return ( @@ -115,6 +155,11 @@ const FreeformInputWidget = ({ triggerAriaLabel={`Get help about ${label}`} /> + + {!bypassed && required && value?.length ? ( + The field is required. + ) : null} +
{label} @@ -123,7 +168,7 @@ const FreeformInputWidget = ({ ref={inputRef} type={inputType} name={name} - defaultValue={defaultValue} + defaultValue={initialValue} {...otherProps} /> {comment ?? ''} @@ -134,13 +179,84 @@ const FreeformInputWidget = ({ ) } +/** + * 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_INT_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_INT_KEYS.includes(value) + +/** + * @param value The value to check. + * @returns Whether the value is a float. + */ +const isFloat = (value: string) => + isDigitKey(value) || + ALLOWED_INT_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: dtype === 'float' ? '' : '1' + step: typeFloat ? undefined : '1', + onKeyDown: typeFloat + ? keyDownHandler(isFloat) + : keyDownHandler(isInteger) } } } From bb6413cb4fb21bed7aa91ebae6ba7ac671845d3b Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Tue, 19 Sep 2023 14:22:38 +0200 Subject: [PATCH 04/16] Unit testing float / int validation. --- __tests__/widgets/FreeformInputWidget.spec.ts | 83 +++++++++++++++++++ cypress/component/FreeformInputWidget.cy.tsx | 5 -- src/index.ts | 7 ++ src/widgets/FreeformInputWidget.tsx | 7 +- 4 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 __tests__/widgets/FreeformInputWidget.spec.ts diff --git a/__tests__/widgets/FreeformInputWidget.spec.ts b/__tests__/widgets/FreeformInputWidget.spec.ts new file mode 100644 index 0000000..fe976fc --- /dev/null +++ b/__tests__/widgets/FreeformInputWidget.spec.ts @@ -0,0 +1,83 @@ +/** + * 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/FreeformInputWidget.cy.tsx b/cypress/component/FreeformInputWidget.cy.tsx index cf140ad..c4fc804 100644 --- a/cypress/component/FreeformInputWidget.cy.tsx +++ b/cypress/component/FreeformInputWidget.cy.tsx @@ -62,7 +62,6 @@ describe('', () => { }) it('can write a string', () => { - cy.mount( ', () => { }) it('can write a float', () => { - cy.mount( ', () => { }) it('can write an integer', () => { - cy.mount( ', () => { }) it('can write a string', () => { - cy.mount( ', () => { cy.get('input').should('have.value', '') }) - }) diff --git a/src/index.ts b/src/index.ts index 9540f7c..eb237e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,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/widgets/FreeformInputWidget.tsx b/src/widgets/FreeformInputWidget.tsx index c64fd52..c3e789f 100644 --- a/src/widgets/FreeformInputWidget.tsx +++ b/src/widgets/FreeformInputWidget.tsx @@ -185,7 +185,7 @@ const FreeformInputWidget = ({ * + / - / e * @param ev The keydown event. */ -const ALLOWED_INT_KEYS = [ +const ALLOWED_KEYS = [ 'e', 'E', '+', @@ -219,7 +219,7 @@ const isDigitKey = (value: string) => { * @returns Whether the value is an integer. */ const isInteger = (value: string) => - isDigitKey(value) || ALLOWED_INT_KEYS.includes(value) + isDigitKey(value) || ALLOWED_KEYS.includes(value) /** * @param value The value to check. @@ -227,7 +227,7 @@ const isInteger = (value: string) => */ const isFloat = (value: string) => isDigitKey(value) || - ALLOWED_INT_KEYS.includes(value) || + ALLOWED_KEYS.includes(value) || ALLOWED_FLOAT_KEYS.includes(value) /** @@ -299,3 +299,4 @@ const Wrapper = styled.div` ` export { FreeformInputWidget } +export { isDigitKey, isInteger, isFloat, keyDownHandler } From 4a6c7e28b1bd53882550a144b58812829e574030 Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Tue, 19 Sep 2023 14:23:54 +0200 Subject: [PATCH 05/16] Formatter pass. --- __tests__/widgets/FreeformInputWidget.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/__tests__/widgets/FreeformInputWidget.spec.ts b/__tests__/widgets/FreeformInputWidget.spec.ts index fe976fc..cea5004 100644 --- a/__tests__/widgets/FreeformInputWidget.spec.ts +++ b/__tests__/widgets/FreeformInputWidget.spec.ts @@ -5,9 +5,7 @@ import { expect } from '@jest/globals' import { isDigitKey, isInteger, isFloat, keyDownHandler } from '../../src' -const asEvent = ( - key: string -): React.KeyboardEvent => { +const asEvent = (key: string): React.KeyboardEvent => { return { key, preventDefault: jest.fn(), @@ -73,7 +71,7 @@ describe('', () => { let ev = asEvent('A') expect(validator(ev)) expect(ev.preventDefault).toBeCalled() - + ev = asEvent('2') expect(validator(ev)) expect(ev.preventDefault).not.toBeCalled() From d8a820f1bbc9cbd9f93f1a20c3af247bbde8ee19 Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Fri, 22 Sep 2023 08:24:38 +0200 Subject: [PATCH 06/16] Drop constraints. --- src/widgets/FreeformInputWidget.tsx | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/widgets/FreeformInputWidget.tsx b/src/widgets/FreeformInputWidget.tsx index c3e789f..261185e 100644 --- a/src/widgets/FreeformInputWidget.tsx +++ b/src/widgets/FreeformInputWidget.tsx @@ -12,7 +12,6 @@ import { WidgetTitle, Error } from './Widget' -import { useBypassRequired } from '../utils' import { WidgetTooltip } from '..' import { useReadLocalStorage } from 'usehooks-ts' @@ -53,10 +52,6 @@ export interface FreeformInputWidgetConfiguration { interface FreeformInputWidgetProps { configuration: FreeformInputWidgetConfiguration - /** - * Permitted selections for the widget. - */ - constraints?: string[] /** * Whether the underlying fieldset should be functionally and visually disabled. */ @@ -74,7 +69,6 @@ interface FreeformInputWidgetProps { const FreeformInputWidget = ({ configuration, labelAriaHidden = true, - constraints, fieldsetDisabled, bypassRequiredForConstraints }: FreeformInputWidgetProps) => { @@ -124,12 +118,6 @@ const FreeformInputWidget = ({ const inputRef = useRef(null) const fieldSetRef = useRef(null) - const bypassed = useBypassRequired( - fieldSetRef, - bypassRequiredForConstraints, - constraints - ) - const { dtype, default: defaultValue, @@ -156,7 +144,7 @@ const FreeformInputWidget = ({ /> - {!bypassed && required && value?.length ? ( + {required && value?.length ? ( The field is required. ) : null} From edda31136998b295eaf4f29b6eba9d67ae22fdaf Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Fri, 22 Sep 2023 08:26:37 +0200 Subject: [PATCH 07/16] Unnecessary prop drop --- src/widgets/FreeformInputWidget.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/widgets/FreeformInputWidget.tsx b/src/widgets/FreeformInputWidget.tsx index 261185e..9dfd25a 100644 --- a/src/widgets/FreeformInputWidget.tsx +++ b/src/widgets/FreeformInputWidget.tsx @@ -69,8 +69,7 @@ interface FreeformInputWidgetProps { const FreeformInputWidget = ({ configuration, labelAriaHidden = true, - fieldsetDisabled, - bypassRequiredForConstraints + fieldsetDisabled }: FreeformInputWidgetProps) => { const [value, setValue] = useState() From 667e176e034e7babb0e181a3a60af87ac4a16252 Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Fri, 22 Sep 2023 09:26:37 +0200 Subject: [PATCH 08/16] Added tests for FreeformInputWidget --- __tests__/factories.ts | 13 ++ cypress/component/ExclusiveGroupWidget.cy.tsx | 201 +++++++++++++++++- src/types/Form.ts | 2 + src/utils/widgetFactory.tsx | 13 +- 4 files changed, 227 insertions(+), 2 deletions(-) diff --git a/__tests__/factories.ts b/__tests__/factories.ts index 94bad96..cd3b4f7 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/cypress/component/ExclusiveGroupWidget.cy.tsx b/cypress/component/ExclusiveGroupWidget.cy.tsx index c8349fc..8407427 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,203 @@ 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 +610,7 @@ describe('', () => { otherExclusive, getStringChoiceWidgetConfiguration(), getTextWidgetConfiguration(), + getFreeformInputWidgetConfiguration(), getStringListWidgetConfiguration(), getGeographicExtentWidgetConfiguration(), { 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 => ( From 931c14b7a702a5e4bd25d79ccba3f433b07fd918 Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Fri, 22 Sep 2023 09:27:27 +0200 Subject: [PATCH 09/16] Formatting fixes. --- __tests__/factories.ts | 4 ++-- cypress/component/ExclusiveGroupWidget.cy.tsx | 17 ++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/__tests__/factories.ts b/__tests__/factories.ts index cd3b4f7..c1c06d8 100644 --- a/__tests__/factories.ts +++ b/__tests__/factories.ts @@ -155,8 +155,8 @@ export const getFreeformInputWidgetConfiguration = () => { name: 'freeform_input', help: 'Enter a freeform input', details: { - dtype: 'string' as const, + dtype: 'string' as const }, - required: true, + required: true } } diff --git a/cypress/component/ExclusiveGroupWidget.cy.tsx b/cypress/component/ExclusiveGroupWidget.cy.tsx index 8407427..839384b 100644 --- a/cypress/component/ExclusiveGroupWidget.cy.tsx +++ b/cypress/component/ExclusiveGroupWidget.cy.tsx @@ -468,9 +468,8 @@ describe('', () => { }) it('with FreeformInputWidget and StringListWidget', () => { - const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') - + cy.viewport(1200, 900) const configuration = { type: 'ExclusiveGroupWidget' as const, @@ -492,13 +491,13 @@ describe('', () => { cy.mount(
- +
) From 4f170cdf0aafe3901ec63d86041b2e22f772c588 Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Mon, 25 Sep 2023 07:50:43 +0200 Subject: [PATCH 10/16] v7.0.0-0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2f78839..f698621 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-0", "description": "Common UI kit library", "repository": { "type": "git", From 0453242dcc78de9268cbc71aab4363b1695a9815 Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Mon, 25 Sep 2023 07:51:37 +0200 Subject: [PATCH 11/16] Precommit lint. --- .nycrc.json | 8 +++++++- CHANGELOG.md | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) 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 From f4ea5f374076434aa0536a9efd14df9cf957e2cf Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Mon, 25 Sep 2023 15:19:59 +0200 Subject: [PATCH 12/16] Fix error show. --- src/widgets/FreeformInputWidget.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/widgets/FreeformInputWidget.tsx b/src/widgets/FreeformInputWidget.tsx index 9dfd25a..3995d73 100644 --- a/src/widgets/FreeformInputWidget.tsx +++ b/src/widgets/FreeformInputWidget.tsx @@ -143,8 +143,8 @@ const FreeformInputWidget = ({ /> - {required && value?.length ? ( - The field is required. + {required && value?.length === 0 ? ( + This field is required. ) : null}
From 6a85a487fc3495d85dc40898e689145096bdf3af Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Mon, 25 Sep 2023 15:26:45 +0200 Subject: [PATCH 13/16] Fix value and added aria-invalid. --- src/widgets/FreeformInputWidget.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/widgets/FreeformInputWidget.tsx b/src/widgets/FreeformInputWidget.tsx index 3995d73..e40a8b3 100644 --- a/src/widgets/FreeformInputWidget.tsx +++ b/src/widgets/FreeformInputWidget.tsx @@ -156,6 +156,10 @@ const FreeformInputWidget = ({ type={inputType} name={name} defaultValue={initialValue} + onChange={(ev: React.ChangeEvent) => { + setValue(ev.target.value) + }} + aria-invalid={required && value?.length === 0} {...otherProps} /> {comment ?? ''} @@ -263,11 +267,18 @@ const Wrapper = styled.div` 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, From 0fbd246e079099c317c288e99d39c76041978b91 Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Mon, 2 Oct 2023 10:02:44 +0200 Subject: [PATCH 14/16] Handle clearAll and fix classNames. --- src/widgets/FreeformInputWidget.tsx | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/widgets/FreeformInputWidget.tsx b/src/widgets/FreeformInputWidget.tsx index e40a8b3..6952076 100644 --- a/src/widgets/FreeformInputWidget.tsx +++ b/src/widgets/FreeformInputWidget.tsx @@ -13,7 +13,7 @@ import { Error } from './Widget' import { WidgetTooltip } from '..' -import { useReadLocalStorage } from 'usehooks-ts' +import { useEventListener, useReadLocalStorage } from 'usehooks-ts' interface FreeformInputWidgetDetailsCommon { comment?: string @@ -50,7 +50,7 @@ export interface FreeformInputWidgetConfiguration { details: FreeformInputWidgetDetails } -interface FreeformInputWidgetProps { +export interface FreeformInputWidgetProps { configuration: FreeformInputWidgetConfiguration /** * Whether the underlying fieldset should be functionally and visually disabled. @@ -73,6 +73,25 @@ const FreeformInputWidget = ({ }: 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 } @@ -128,7 +147,7 @@ const FreeformInputWidget = ({ const initialValue = value ?? defaultValue ?? '' return ( - + {label} -
+
) => { setValue(ev.target.value) }} From a6a7a9287a8370abf286eeeb778ac972f62b62d0 Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Mon, 2 Oct 2023 11:20:48 +0200 Subject: [PATCH 15/16] v7.0.0-1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f698621..5e81932 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ecmwf-projects/cads-ui-library", - "version": "7.0.0-0", + "version": "7.0.0-1", "description": "Common UI kit library", "repository": { "type": "git", From d5cf11f1e8cf49ee8b0ed533ec18851c9ee38dfe Mon Sep 17 00:00:00 2001 From: pelusanchez Date: Tue, 3 Oct 2023 09:59:45 +0200 Subject: [PATCH 16/16] v7.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5e81932..90b3c67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ecmwf-projects/cads-ui-library", - "version": "7.0.0-1", + "version": "7.0.0", "description": "Common UI kit library", "repository": { "type": "git",