From 6fd439e4133a96838434c2d6989c9b33f45f8b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Thu, 12 Sep 2024 16:11:47 +0200 Subject: [PATCH] feat(Forms): add `validator` support to Iterate.Array (#3926) --- .../forms/Iterate/Array/Examples.tsx | 40 +++++ .../extensions/forms/Iterate/Array/demos.mdx | 14 ++ .../forms/FieldBlock/FieldBlock.tsx | 2 +- .../extensions/forms/Iterate/Array/Array.tsx | 114 ++++++++------ .../forms/Iterate/Array/ArrayDocs.ts | 4 + .../Iterate/Array/__tests__/Array.test.tsx | 147 +++++++++++++++++- .../extensions/forms/Iterate/Array/types.ts | 10 +- .../forms/Iterate/IterateItemContext.ts | 4 +- .../forms/Iterate/stories/Iterate.stories.tsx | 19 +++ .../extensions/forms/hooks/useFieldProps.ts | 6 + 10 files changed, 309 insertions(+), 51 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/Examples.tsx index bc72c20c259..aedf4f43dc4 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/Examples.tsx @@ -439,3 +439,43 @@ export const ToolbarVariantMiniumOneItemTwoItems = () => { ) } + +export const WithArrayValidator = () => { + return ( + + console.log('onSubmit')} + > + + { + if (!(arrayValue && arrayValue.length > 1)) { + return new Error('You need at least two items') + } + }} + > + + + + + + + + + + + + ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/demos.mdx index 17cc4deab54..def0f3a3777 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/demos.mdx @@ -68,3 +68,17 @@ It hides the toolbar in the `EditContainer` when there is only one item in the a ### Value composition + +### Array validator + +You can also add a validator to ensure that the array contains at least one item: + +```tsx +const validator = (arrayValue) => { + if (!(arrayValue?.length > 0)) { + return new Error('You need at least one item') + } +} +``` + + diff --git a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx index 5b32854b5d3..60457f7b5b4 100644 --- a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx @@ -562,7 +562,7 @@ function LabelDescription({ labelDescription, children }) { return
{children}
} -function getMessage(item: Partial): StateMessage { +export function getMessage(item: Partial): StateMessage { const { content } = item return ((content instanceof Error && content.message) || diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx index 78f522b7642..a761471285f 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx @@ -11,7 +11,7 @@ import classnames from 'classnames' import pointer from 'json-pointer' import { useFieldProps } from '../../hooks' import { makeUniqueId } from '../../../../shared/component-helper' -import { Flex } from '../../../../components' +import { Flex, FormStatus } from '../../../../components' import { pickSpacingProps } from '../../../../components/flex/utils' import useMountEffect from '../../../../shared/helpers/useMountEffect' import { @@ -28,6 +28,7 @@ import FieldBoundaryProvider from '../../DataContext/FieldBoundary/FieldBoundary import DataContext from '../../DataContext/Context' import useDataValue from '../../hooks/useDataValue' import { useSwitchContainerMode } from '../hooks' +import { getMessage } from '../../FieldBlock' import type { ContainerMode, ElementChild, Props, Value } from './types' import type { Identifier } from '../../types' @@ -84,16 +85,23 @@ function ArrayComponent(props: Props) { const { path, value: arrayValue, + error, defaultValue, withoutFlex, emptyValue, placeholder, containerMode, handleChange, + setChanged, onChange, children, } = useFieldProps(preparedProps) + useMountEffect(() => { + // To ensure the validator is called when a new item is added + setChanged(true) + }) + const idsRef = useRef>([]) const isNewRef = useRef>({}) const modesRef = useRef< @@ -251,54 +259,70 @@ function ArrayComponent(props: Props) { const WrapperElement = omitFlex ? Fragment : Flex.Stack return ( - - {arrayValue === emptyValue || props?.value?.length === 0 - ? placeholder - : arrayItems.map((itemProps) => { - const { id, value, index } = itemProps - const elementRef = (innerRefs.current[id] = - innerRefs.current[id] || createRef()) - - const renderChildren = (elementChild: ElementChild) => { - return typeof elementChild === 'function' - ? elementChild(value, index) - : elementChild - } - - const contextValue = { - ...itemProps, - elementRef, - } - - const content = Array.isArray(children) - ? children.map((child) => renderChildren(child)) - : renderChildren(children) - - if (omitFlex) { + <> + + {arrayValue === emptyValue || props?.value?.length === 0 + ? placeholder + : arrayItems.map((itemProps) => { + const { id, value, index } = itemProps + const elementRef = (innerRefs.current[id] = + innerRefs.current[id] || createRef()) + + const renderChildren = (elementChild: ElementChild) => { + return typeof elementChild === 'function' + ? elementChild(value, index) + : elementChild + } + + const contextValue = { + ...itemProps, + elementRef, + } + + const content = Array.isArray(children) + ? children.map((child) => renderChildren(child)) + : renderChildren(children) + + if (omitFlex) { + return ( + + + {content} + + + ) + } + return ( - - {content} - + + + {content} + + + ) - } - - return ( - - - {content} - - - ) - })} - + })} + + + + {getMessage({ content: error })} + + ) } diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayDocs.ts index d6035e1b3ac..0635c8b7aa1 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayDocs.ts @@ -1,4 +1,5 @@ import { PropertiesTableProps } from '../../../../shared/types' +import { dataValueProperties } from '../../hooks/DataValueDocs' export const ArrayProperties: PropertiesTableProps = { value: { @@ -41,6 +42,9 @@ export const ArrayProperties: PropertiesTableProps = { type: 'unknown', status: 'optional', }, + validator: dataValueProperties.validator, + validateInitially: dataValueProperties.validateInitially, + continuousValidation: dataValueProperties.continuousValidation, containerMode: { doc: 'Defines the container mode for all nested containers. Can be `view`, `edit` or `auto`. When using `auto`, it will automatically open if there is an error in the container. When a new item is added, the item before it will change to `view` mode, if it had no validation errors. Defaults to `auto`.', type: 'string', diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx index 298c9d46995..76e95dee2fc 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react' -import { fireEvent, render } from '@testing-library/react' +import { fireEvent, render, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as Iterate from '../..' import * as DataContext from '../../../DataContext' @@ -322,6 +322,151 @@ describe('Iterate.Array', () => { }) }) + describe('validator', () => { + it('should validate validator initially (validateInitially)', async () => { + const validator = jest.fn((arrayValue) => { + if (arrayValue.length === 2) { + return new Error('Error message') + } + }) + + render( + + + + + + + ) + + expect(validator).toHaveBeenCalledTimes(1) + expect(validator).toHaveBeenCalledWith( + ['foo', 'bar'], + expect.anything() + ) + + await waitFor(() => { + expect( + document.querySelector('.dnb-form-status') + ).toBeInTheDocument() + expect( + document.querySelector('.dnb-form-status') + ).toHaveTextContent('Error message') + }) + + fireEvent.click(document.querySelector('button')) + + expect(validator).toHaveBeenCalledTimes(2) + expect(validator).toHaveBeenCalledWith( + ['foo', 'bar', 'baz'], + expect.anything() + ) + + await waitFor(() => { + expect( + document.querySelector('.dnb-form-status') + ).not.toBeInTheDocument() + }) + }) + + it('should validate validator on form submit', async () => { + const validator = jest.fn((arrayValue) => { + if (arrayValue.length === 2) { + return new Error('Error message') + } + }) + + render( + + + + + + + ) + + expect(validator).toHaveBeenCalledTimes(0) + + const form = document.querySelector('form') + fireEvent.submit(form) + + expect(validator).toHaveBeenCalledTimes(1) + expect(validator).toHaveBeenCalledWith( + ['foo', 'bar'], + expect.anything() + ) + + await waitFor(() => { + expect( + document.querySelector('.dnb-form-status') + ).toBeInTheDocument() + expect( + document.querySelector('.dnb-form-status') + ).toHaveTextContent('Error message') + }) + + fireEvent.click(document.querySelector('button')) + + expect(validator).toHaveBeenCalledTimes(2) + expect(validator).toHaveBeenCalledWith( + ['foo', 'bar', 'baz'], + expect.anything() + ) + + await waitFor(() => { + expect( + document.querySelector('.dnb-form-status') + ).not.toBeInTheDocument() + }) + }) + + it('should validate during typing and show error when duplicate item is added', async () => { + const findFirstDuplication = (arr) => + arr.findIndex((e, i) => arr.indexOf(e) !== i) + + const validator = jest.fn((arrayValue) => { + const index = findFirstDuplication(arrayValue) + if (index > -1) { + const value = arrayValue[index] + return new Error(`You can not have duplicate items: ${value}`) + } + }) + + render( + + + + + + ) + + const input = document.querySelector('input') + await userEvent.type(input, 'foo') + + await waitFor(() => { + expect( + document.querySelector('.dnb-form-status') + ).toBeInTheDocument() + }) + + expect(document.querySelector('.dnb-form-status')).toHaveTextContent( + 'You can not have duplicate items: foo' + ) + }) + }) + describe('using single render prop', () => { describe('with primitive elements', () => { it('should call renderers with each element value', () => { diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/types.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/types.ts index 86e6c3048b9..a7472a2fdd5 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/types.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/types.ts @@ -1,4 +1,4 @@ -import { Path, UseFieldProps } from '../../types' +import { Path, UseFieldProps, Validator } from '../../types' import { Props as FlexContainerProps } from '../../../../components/flex/Container' export type ContainerMode = 'view' | 'edit' | 'auto' @@ -12,12 +12,18 @@ export type Props = Omit< > & Pick< UseFieldProps, - 'value' | 'defaultValue' | 'emptyValue' | 'onChange' + | 'value' + | 'defaultValue' + | 'emptyValue' + | 'onChange' + | 'validateInitially' + | 'continuousValidation' > & { children: ElementChild | Array path?: Path countPath?: Path countPathLimit?: number + validator?: Validator withoutFlex?: boolean placeholder?: React.ReactNode containerMode?: ContainerMode diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts index 136f5c83c37..70d2238e103 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts @@ -1,6 +1,6 @@ import React from 'react' import { Path } from '../types' -import { ContainerMode, ElementChild } from './Array/types' +import { ContainerMode } from './Array/types' export type ModeOptions = { omitFocusManagement?: boolean @@ -12,7 +12,7 @@ export interface IterateItemContextState { value?: unknown isNew?: boolean path?: Path - arrayValue?: Array + arrayValue?: Array containerMode?: ContainerMode previousContainerMode?: ContainerMode initialContainerMode?: ContainerMode diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx index 33be8c7295a..7ebb6a1d6d2 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx @@ -221,3 +221,22 @@ const Output = () => { ) } + +export const WithArrayValidator = () => { + return ( + + { + if (!(arrayValue?.length > 0)) { + return new Error('You need at least one item') + } + }} + > + + + + + + ) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts index a4c3ec105bb..cce85044d00 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts @@ -1267,6 +1267,10 @@ export default function useFieldProps( ] ) + const setChanged = (state: boolean) => { + changedRef.current = state + } + const handleChange = useCallback( async ( argFromInput: Value | unknown, @@ -1746,6 +1750,7 @@ export default function useFieldProps( handleBlur, handleChange, updateValue, + setChanged, forceUpdate, /** Internal */ @@ -1769,6 +1774,7 @@ export interface ReturnAdditional { value: Value | unknown, additionalArgs?: AdditionalEventArgs ) => void + setChanged: (state: boolean) => void updateValue: (value: Value) => void forceUpdate: () => void hasError?: boolean