From a6e3bc31009dd3f3af944b2fd6657b036fe5cbf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= <tobias@tujo.no> Date: Mon, 25 Nov 2024 08:28:29 +0100 Subject: [PATCH 01/13] feat(Forms): add support for using a function references instead of a string based id (#4331) This feature allows developers to use a function or a React Context as the reference instead of a string-based ID. This can be useful for ensuring a safe local scope, especially when multiple form handlers are present. ```tsx const myReference= () => null const MyField = () => { const { data } = Form.useData(myReference) return data.foo } render( <> <Form.Handler id={myReference}> ... </Form.Handler> <MyField /> </> ) ``` --- .../extensions/forms/Form/getData/info.mdx | 2 +- .../extensions/forms/Form/setData/info.mdx | 2 +- .../extensions/forms/Form/useData/info.mdx | 4 +- .../forms/Form/useSnapshot/info.mdx | 4 +- .../forms/Form/useValidation/info.mdx | 2 +- .../forms/Wizard/Container/info.mdx | 2 +- .../forms/Wizard/location-hooks/info.mdx | 2 +- .../extensions/forms/Wizard/useStep/info.mdx | 2 +- .../extensions/forms/getting-started.mdx | 10 +- .../extensions/forms/DataContext/Context.ts | 4 +- .../forms/DataContext/Provider/Provider.tsx | 12 +- .../DataContext/Provider/ProviderDocs.ts | 2 +- .../Form/Element/__tests__/Element.test.tsx | 14 ++ .../data-context/__tests__/useData.test.tsx | 189 +++++++++++++++--- .../forms/Form/data-context/clearData.ts | 10 +- .../forms/Form/data-context/getData.tsx | 3 +- .../forms/Form/data-context/useData.tsx | 3 +- .../forms/Form/data-context/useValidation.tsx | 5 +- .../extensions/forms/hooks/useDataContext.tsx | 3 +- .../helpers/__tests__/useSharedState.test.ts | 117 ++++++++++- .../src/shared/helpers/useSharedState.tsx | 71 ++++--- 21 files changed, 386 insertions(+), 77 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx index 9abadb531ef..cd937ae186b 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx @@ -23,7 +23,7 @@ const { getValue, data, filterData, reduceToVisibleFields } = - `filterData` will filter the data based on your own logic. - `reduceToVisibleFields` will reduce the given data set to only contain the visible fields (mounted fields). -You link them together via the `id` (string) property. +You link them together via the `id` (string, function, object or React Context as the reference) property. TypeScript support: diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/setData/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/setData/info.mdx index eac652cd94e..5363b5580b6 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/setData/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/setData/info.mdx @@ -16,7 +16,7 @@ function Component() { } ``` -You link them together via the `id` (string) property. +You link them together via the `id` (string, function, object or React Context as the reference) property. Related helpers: diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx index 34c96df04a6..c46da0a4235 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx @@ -42,7 +42,7 @@ render( ## Usage -You can use the `Form.useData` hook with or without an `id` (string) property, which is optional and can be used to link the data to a specific [Form.Handler](/uilib/extensions/forms/Form/Handler/) component. +You can use the `Form.useData` hook with or without an `id` (string, function, object or React Context as the reference) property, which is optional and can be used to link the data to a specific [Form.Handler](/uilib/extensions/forms/Form/Handler/) component. ### Without an `id` property @@ -66,7 +66,7 @@ function Component() { ### With an `id` property -While in this example, "Component" is outside the `Form.Handler` context, but linked together via the `id` (string) property: +While in this example, "Component" is outside the `Form.Handler` context, but linked together via the `id` (string, function, object or React Context as the reference) property: ```jsx import { Form } from '@dnb/eufemia/extensions/forms' diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx index b4d6895f3c1..07ea4ecbf50 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx @@ -61,7 +61,7 @@ You can check out examples in the demo section. ## Usage of the `Form.useSnapshot` hook -You can use the `Form.useSnapshot` hook with or without an `id` (string) property, which is optional and can be used to link the data to a specific [Form.Handler](/uilib/extensions/forms/Form/Handler/) component. +You can use the `Form.useSnapshot` hook with or without an `id` (string, function, object or React Context as the reference) property, which is optional and can be used to link the data to a specific [Form.Handler](/uilib/extensions/forms/Form/Handler/) component. ### Without an `id` property @@ -85,7 +85,7 @@ function Component() { ### With an `id` property -While in this example, "Component" is outside the `Form.Handler` context, but linked together via the `id` (string) property: +While in this example, "Component" is outside the `Form.Handler` context, but linked together via the `id` (string, function, object or React Context as the reference) property: ```jsx import { Form } from '@dnb/eufemia/extensions/forms' diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/info.mdx index 3d74e108e06..963606e8572 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/info.mdx @@ -45,7 +45,7 @@ function Component() { } ``` -Or by linking the hook together with the form by using the `id` (string) property: +Or by linking the hook together with the form by using the `id` (string, function, object or React Context as the reference) property: ```jsx import { Form } from '@dnb/eufemia/extensions/forms' diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx index 89729d6ded8..1161682d231 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx @@ -117,7 +117,7 @@ const MyForm = () => { } ``` -When using the `useStep` hook outside of the `Wizard.Container` context, you need to provide an unique `id` (string): +When using the `useStep` hook outside of the `Wizard.Container` context, you need to provide an unique `id` (string, function, object or React Context as the reference): ```tsx import { Form, Wizard } from '@dnb/eufemia/extensions/forms' diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/location-hooks/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/location-hooks/info.mdx index 12512df0d29..40e692bffe9 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/location-hooks/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/location-hooks/info.mdx @@ -106,7 +106,7 @@ function MyForm() { ## Without a router -You connect the hook with the `Wizard.Container` component via an unique `id` (string). The `id` will be used in the URL query string: `url?unique-id-step=1`. +You connect the hook with the `Wizard.Container` component via an unique `id` (string, function, object or React Context as the reference). The `id` will be used in the URL query string: `url?unique-id-step=1`. ```jsx import { Form, Wizard } from '@dnb/eufemia/extensions/forms' diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/useStep/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/useStep/info.mdx index 0b743c3b7ac..25e58b9851b 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/useStep/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/useStep/info.mdx @@ -26,7 +26,7 @@ function MyForm() { } ``` -You can also connect the hook with the `Wizard.Container` via an `id` (string). This lets you render the hook outside of the context: +You can also connect the hook with the `Wizard.Container` via an `id` (string, function, object or React Context as the reference). This lets you render the hook outside of the context: ```jsx import { Form } from '@dnb/eufemia/extensions/forms' diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx index 8378af5c301..bcd12191b4f 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx @@ -128,9 +128,11 @@ Here is an example of how to use these methods: ```jsx import { Form } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference + function MyForm() { return ( - <Form.Handler id="unique-id"> + <Form.Handler id={myFormId}> <MyComponent /> </Form.Handler> ) @@ -145,15 +147,15 @@ function MyComponent() { data, filterData, reduceToVisibleFields, - } = Form.useData() // optionally provide an id (unique-id) + } = Form.useData() // optionally provide an id or reference } // You can also use the setData: -Form.setData('unique-id', { companyName: 'DNB' }) +Form.setData(myFormId, { companyName: 'DNB' }) // ... and the getData – method when ever you need to: const { getValue, data, filterData, reduceToVisibleFields } = - Form.getData('unique-id') + Form.getData(myFormId) ``` - `getValue` will return the value of the given path. diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts index d78adf557f9..62e684e83bc 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts @@ -7,7 +7,6 @@ import { Path, EventStateObject, EventReturnWithStateObject, - Identifier, FieldProps, ValueProps, OnChange, @@ -15,6 +14,7 @@ import { } from '../types' import { Props as ProviderProps } from './Provider' import { SnapshotName } from '../Form/Snapshot' +import { SharedStateId } from '../../../shared/helpers/useSharedState' export type MountState = { isPreMounted?: boolean @@ -85,7 +85,7 @@ export type FieldConnections = { } export interface ContextState { - id?: Identifier + id?: SharedStateId hasContext: boolean /** The dataset for the form / form wizard */ data: any diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx index 8d7660d0a9a..4817b0db407 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -33,7 +33,11 @@ import { debounce } from '../../../../shared/helpers' import FieldPropsProvider from '../../Field/Provider' import useUpdateEffect from '../../../../shared/helpers/useUpdateEffect' import { isAsync } from '../../../../shared/helpers/isAsync' -import { useSharedState } from '../../../../shared/helpers/useSharedState' +import { + SharedStateId, + createReferenceKey, + useSharedState, +} from '../../../../shared/helpers/useSharedState' import SharedContext, { ContextProps } from '../../../../shared/Context' import useTranslation from '../../hooks/useTranslation' import DataContext, { @@ -74,7 +78,7 @@ export interface Props<Data extends JsonObject> /** * Unique ID to communicate with the hook Form.useData */ - id?: string + id?: SharedStateId /** * Unique ID to connect with a GlobalStatus */ @@ -618,10 +622,10 @@ export default function Provider<Data extends JsonObject>( // - Shared state const sharedData = useSharedState<Data>(id) const sharedAttachments = useSharedState<SharedAttachments<Data>>( - id + '-attachments' + createReferenceKey(id, 'attachments') ) const sharedDataContext = useSharedState<ContextState>( - id + '-data-context' + createReferenceKey(id, 'data-context') ) const setSharedData = sharedData.set diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/ProviderDocs.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/ProviderDocs.ts index 564b61c28f7..dece5c43721 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/ProviderDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/ProviderDocs.ts @@ -13,7 +13,7 @@ export const ProviderProperties: PropertiesTableProps = { }, id: { doc: 'Unique id for connecting Form.Handler and helper tools such as Form.useData.', - type: 'string', + type: ['string', 'Function', 'Object', 'React.Context'], status: 'optional', }, schema: { diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Element/__tests__/Element.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Element/__tests__/Element.test.tsx index 9d87326d7c1..49c25c84137 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Element/__tests__/Element.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Element/__tests__/Element.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ import React from 'react' import { fireEvent, render } from '@testing-library/react' import { Form, DataContext, Field } from '../../..' @@ -151,4 +152,17 @@ describe('Form.Element', () => { expect(attributes).toEqual(['class', 'aria-label']) expect(formElement.getAttribute('aria-label')).toBe('Aria Label') }) + + it('should ensure that only a string can be set as the id', () => { + const myId = () => null + render( + // @ts-expect-error + <Form.Element id={myId}> + <Form.SubmitButton>Submit</Form.SubmitButton> + </Form.Element> + ) + + const formElement = document.querySelector('form') + expect(formElement).not.toHaveAttribute('id') + }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/__tests__/useData.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/__tests__/useData.test.tsx index 2987e3c3719..65bf6dfcf09 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/__tests__/useData.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/__tests__/useData.test.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { createContext } from 'react' import { renderHook, act, render, fireEvent } from '@testing-library/react' import { makeUniqueId } from '../../../../../shared/component-helper' import { Field, Form, Wizard } from '../../..' @@ -124,6 +124,89 @@ describe('Form.useData', () => { expect(result.current.data).toEqual({ key: 'changed value' }) }) + it('should get data with a string as the id', () => { + const { result } = renderHook(() => useData(identifier), { + wrapper: ({ children }) => ( + <> + <Provider id={identifier}> + <Field.String path="/foo" defaultValue="foo" /> + <Field.String path="/bar" defaultValue="bar" /> + </Provider> + + {children} + </> + ), + }) + + expect(result.current.data).toEqual({ + foo: 'foo', + bar: 'bar', + }) + }) + + it('should get data with a function reference as the id', () => { + const myId = () => null + const { result } = renderHook(() => useData(myId), { + wrapper: ({ children }) => ( + <> + <Provider id={myId}> + <Field.String path="/foo" defaultValue="foo" /> + <Field.String path="/bar" defaultValue="bar" /> + </Provider> + + {children} + </> + ), + }) + + expect(result.current.data).toEqual({ + foo: 'foo', + bar: 'bar', + }) + }) + + it('should get data with an object reference as the id', () => { + const myId = {} + const { result } = renderHook(() => useData(myId), { + wrapper: ({ children }) => ( + <> + <Provider id={myId}> + <Field.String path="/foo" defaultValue="foo" /> + <Field.String path="/bar" defaultValue="bar" /> + </Provider> + + {children} + </> + ), + }) + + expect(result.current.data).toEqual({ + foo: 'foo', + bar: 'bar', + }) + }) + + it('should get data with a React Context as the id', () => { + const myId = createContext(null) + const { result } = renderHook(() => useData(myId), { + wrapper: ({ children }) => ( + <> + <Provider id={myId}> + <Field.String path="/foo" defaultValue="foo" /> + <Field.String path="/bar" defaultValue="bar" /> + </Provider> + + {children} + </> + ), + }) + + expect(result.current.data).toEqual({ + foo: 'foo', + bar: 'bar', + }) + }) + describe('remove', () => { it('should remove the data', () => { const { result } = renderHook(() => useData(), { @@ -177,6 +260,35 @@ describe('Form.useData', () => { }) expect(result.current.data).not.toHaveProperty('foo') }) + + it('should remove data with handler id', () => { + const { result } = renderHook(() => useData(identifier), { + wrapper: ({ children }) => ( + <> + <Provider id={identifier}> + <Field.String path="/foo" defaultValue="foo" /> + <Field.String path="/bar" defaultValue="bar" /> + </Provider> + + {children} + </> + ), + }) + + expect(result.current.data).toEqual({ + foo: 'foo', + bar: 'bar', + }) + + act(() => { + result.current.remove('/foo') + }) + + expect(result.current.data).toEqual({ + bar: 'bar', + }) + expect(result.current.data).not.toHaveProperty('foo') + }) }) it('"update" should only re-render when value has changed', () => { @@ -225,40 +337,71 @@ describe('Form.useData', () => { expect(result.current.data).toEqual({ key: 'changed value' }) }) - it('should sync two hooks by using "update"', () => { - const props = { key: 'value' } + describe('update', () => { + it('should sync two hooks by using "update"', () => { + const props = { key: 'value' } - const { result: A } = renderHook(() => useData(identifier)) - const { result: B } = renderHook(() => useData(identifier, props)) + const { result: A } = renderHook(() => useData(identifier)) + const { result: B } = renderHook(() => useData(identifier, props)) - expect(A.current.data).toEqual({ key: 'value' }) - expect(B.current.data).toEqual({ key: 'value' }) + expect(A.current.data).toEqual({ key: 'value' }) + expect(B.current.data).toEqual({ key: 'value' }) - act(() => { - B.current.update('/key', (value) => { - return 'changed ' + value + act(() => { + B.current.update('/key', (value) => { + return 'changed ' + value + }) }) + + expect(A.current.data).toEqual({ key: 'changed value' }) + expect(B.current.data).toEqual({ key: 'changed value' }) }) - expect(A.current.data).toEqual({ key: 'changed value' }) - expect(B.current.data).toEqual({ key: 'changed value' }) - }) + it('should support update without a function', () => { + const props = { key: 'value' } - it('should support update without a function', () => { - const props = { key: 'value' } + const { result: A } = renderHook(() => useData(identifier)) + const { result: B } = renderHook(() => useData(identifier, props)) - const { result: A } = renderHook(() => useData(identifier)) - const { result: B } = renderHook(() => useData(identifier, props)) + expect(A.current.data).toEqual({ key: 'value' }) + expect(B.current.data).toEqual({ key: 'value' }) - expect(A.current.data).toEqual({ key: 'value' }) - expect(B.current.data).toEqual({ key: 'value' }) + act(() => { + B.current.update('/key', 'new value') + }) - act(() => { - B.current.update('/key', 'new value') + expect(A.current.data).toEqual({ key: 'new value' }) + expect(B.current.data).toEqual({ key: 'new value' }) }) - expect(A.current.data).toEqual({ key: 'new value' }) - expect(B.current.data).toEqual({ key: 'new value' }) + it('should update data with handler id', () => { + const { result } = renderHook(() => useData(identifier), { + wrapper: ({ children }) => ( + <> + <Provider id={identifier}> + <Field.String path="/foo" defaultValue="foo" /> + <Field.String path="/bar" defaultValue="bar" /> + </Provider> + + {children} + </> + ), + }) + + expect(result.current.data).toEqual({ + foo: 'foo', + bar: 'bar', + }) + + act(() => { + result.current.update('/foo', 'updated') + }) + + expect(result.current.data).toEqual({ + foo: 'updated', + bar: 'bar', + }) + }) }) it('should rerender when shared state calls "set"', () => { diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/clearData.ts b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/clearData.ts index 9e79c2047b8..4fd934c85e5 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/clearData.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/clearData.ts @@ -1,9 +1,13 @@ -import { createSharedState } from '../../../../shared/helpers/useSharedState' +import { + SharedStateId, + createReferenceKey, + createSharedState, +} from '../../../../shared/helpers/useSharedState' import { SharedAttachments } from '../../DataContext/Provider' -export default function clearData(id: string) { +export default function clearData(id: SharedStateId) { const sharedAttachments = createSharedState<SharedAttachments<unknown>>( - id + '-attachments' + createReferenceKey(id, 'attachments') ) sharedAttachments.data.clearData?.() } diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/getData.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/getData.tsx index 3265f595264..f1bca9147b1 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/getData.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/getData.tsx @@ -1,6 +1,7 @@ import pointer from '../../utils/json-pointer' import { SharedStateId, + createReferenceKey, createSharedState, } from '../../../../shared/helpers/useSharedState' import { SharedAttachments } from '../../DataContext/Provider' @@ -23,7 +24,7 @@ export default function getData<Data>( ): SetDataReturn<Data> { const sharedState = createSharedState(id) const sharedAttachments = createSharedState<SharedAttachments<Data>>( - id + '-attachments' + createReferenceKey(id, 'attachments') ) const data = sharedState.get() as Data diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx index ba8ed93217c..8e386c358b5 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx @@ -8,6 +8,7 @@ import { import pointer, { JsonObject } from '../../utils/json-pointer' import { SharedStateId, + createReferenceKey, useSharedState, } from '../../../../shared/helpers/useSharedState' import useMountEffect from '../../../../shared/helpers/useMountEffect' @@ -89,7 +90,7 @@ export default function useData<Data = JsonObject>( ) sharedAttachmentsRef.current = useSharedState<SharedAttachments<Data>>( - id + '-attachments', + createReferenceKey(id, 'attachments'), { rerenderUseDataHook: forceUpdate } ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useValidation.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useValidation.tsx index 6b75302953a..e6b00106e04 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useValidation.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useValidation.tsx @@ -1,6 +1,7 @@ import { useCallback, useContext, useMemo } from 'react' import { SharedStateId, + createReferenceKey, useSharedState, } from '../../../../shared/helpers/useSharedState' import DataContext, { ContextState } from '../../DataContext/Context' @@ -19,7 +20,7 @@ export default function useValidation( ): UseDataReturn { const { data } = useSharedState< UseDataReturn & SharedAttachments<unknown> - >(id + '-attachments') + >(createReferenceKey(id, 'attachments')) const fallback = useCallback(() => false, []) @@ -62,7 +63,7 @@ type UseConnectionsSharedState = { function useConnections(id: SharedStateId = undefined) { const { get } = useSharedState<UseConnectionsSharedState>( - id + '-attachments' + createReferenceKey(id, 'attachments') ) const dataContext = useContext(DataContext) diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useDataContext.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataContext.tsx index a8116150377..9c2f020983a 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useDataContext.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataContext.tsx @@ -1,6 +1,7 @@ import { useCallback, useContext } from 'react' import { SharedStateId, + createReferenceKey, useSharedState, } from '../../../shared/helpers/useSharedState' import DataContext, { ContextState } from '../DataContext/Context' @@ -10,7 +11,7 @@ export default function useDataContext(id: SharedStateId = undefined): { getContext: () => ContextState } { const sharedDataContext = useSharedState<ContextState>( - id + '-data-context' + createReferenceKey(id, 'data-context') ) const dataContext = useContext(DataContext) diff --git a/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts b/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts index 104ee5ffc67..5fe8b8e566a 100644 --- a/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts +++ b/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts @@ -1,9 +1,15 @@ import { renderHook, act } from '@testing-library/react' import { makeUniqueId } from '../../component-helper' -import { useSharedState, createSharedState } from '../useSharedState' +import { + useSharedState, + createSharedState, + SharedStateId, + createReferenceKey, +} from '../useSharedState' +import { createContext } from 'react' describe('useSharedState', () => { - let identifier: string + let identifier: SharedStateId beforeEach(() => { identifier = makeUniqueId() @@ -47,6 +53,50 @@ describe('useSharedState', () => { expect(result.current.data).toEqual({ test: 'changed' }) }) + it('should update the shared state with a function reference as id', () => { + const identifier = () => null + const { result } = renderHook(() => + useSharedState(identifier, { test: 'initial' }) + ) + act(() => { + result.current.update({ test: 'updated' }) + }) + expect(result.current.data).toEqual({ test: 'updated' }) + }) + + it('should update the shared state with an async function reference as id', () => { + const identifier = async () => null + const { result } = renderHook(() => + useSharedState(identifier, { test: 'initial' }) + ) + act(() => { + result.current.update({ test: 'updated' }) + }) + expect(result.current.data).toEqual({ test: 'updated' }) + }) + + it('should update the shared state with an object reference as id', () => { + const identifier = {} + const { result } = renderHook(() => + useSharedState(identifier, { test: 'initial' }) + ) + act(() => { + result.current.update({ test: 'updated' }) + }) + expect(result.current.data).toEqual({ test: 'updated' }) + }) + + it('should update the shared state with a React context reference as id', () => { + const identifier = createContext(null) + const { result } = renderHook(() => + useSharedState(identifier, { test: 'initial' }) + ) + act(() => { + result.current.update({ test: 'updated' }) + }) + expect(result.current.data).toEqual({ test: 'updated' }) + }) + it('should unsubscribe from the shared state when the component unmounts', () => { const { result, unmount } = renderHook(() => useSharedState(identifier, { test: 'initial' }) @@ -171,3 +221,66 @@ describe('useSharedState', () => { expect(resultB.current.data).toEqual({ foo: 'baz' }) }) }) + +describe('createReferenceKey', () => { + it('should return the same object for the same references', () => { + const ref1 = {} + const ref2 = () => null + + const key1 = createReferenceKey(ref1, ref2) + const key2 = createReferenceKey(ref1, ref2) + + expect(key1).toBe(key2) + }) + + it('should return the same object for the same string references', () => { + const ref1 = {} + const ref2 = 'unique' + + const key1 = createReferenceKey(ref1, ref2) + const key2 = createReferenceKey(ref1, ref2) + + expect(key1).toBe(key2) + }) + + it('should return different objects for different references', () => { + const ref1 = {} + const ref2 = {} + const ref3 = {} + + const key1 = createReferenceKey(ref1, ref2) + const key2 = createReferenceKey(ref1, ref3) + + expect(key1).not.toBe(key2) + }) + + it('should return different objects for different first references', () => { + const ref1 = {} + const ref2 = {} + const ref3 = {} + + const key1 = createReferenceKey(ref1, ref2) + const key2 = createReferenceKey(ref3, ref2) + + expect(key1).not.toBe(key2) + }) + + it('should cache the combined reference', () => { + const ref1 = {} + const ref2 = () => null + + const key1 = createReferenceKey(ref1, ref2) + const key2 = createReferenceKey(ref1, ref2) + + expect(key1).toBe(key2) + }) + + it('should create a new reference if it does not exist', () => { + const ref1 = {} + const ref2 = {} + + const key1 = createReferenceKey(ref1, ref2) + + expect(key1).toBeDefined() + }) +}) diff --git a/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx b/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx index 4fb9fe76bdf..6d837bceb3f 100644 --- a/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx +++ b/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx @@ -12,20 +12,22 @@ import useMountEffect from './useMountEffect' const useLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect -export type SharedStateId = string +export type SharedStateId = + | string + | (() => void) + | Promise<() => void> + | React.Context<any> + | Record<string, unknown> /** * Custom hook that provides shared state functionality. - * - * @template Data - The type of data stored in the shared state. - * @param {SharedStateId} id - The identifier for the shared state. - * @param {Data} initialData - The initial data for the shared state. - * @param {Function} onChange - Optional callback function to be called when the shared state is set from another instance/component. - * @returns {Object} - An object containing the shared state data, update function, extend function, and set function. */ export function useSharedState<Data>( - id: SharedStateId, + /** The identifier for the shared state. */ + id: SharedStateId | undefined, + /** The initial data for the shared state. */ initialData: Data = undefined, + /** Optional callback function to be called when the shared state is set from another instance/component. */ onChange = null ) { const [, forceUpdate] = useReducer(() => ({}), {}) @@ -53,7 +55,7 @@ export function useSharedState<Data>( }, [id, initialData]) const sharedAttachment = useMemo(() => { if (id) { - return createSharedState(id + '-oc', { onChange }) + return createSharedState(createReferenceKey(id, 'oc'), { onChange }) } }, [id, onChange]) @@ -141,26 +143,27 @@ interface SharedStateInstance<Data> extends SharedStateReturn<Data> { hadInitialData: boolean } -const sharedStates: Record<SharedStateId, SharedStateInstance<any>> = {} +const sharedStates: Map< + SharedStateId, + SharedStateInstance<any> +> = new Map() /** * Creates a shared state instance with the specified ID and initial data. - * @template Data The type of data stored in the shared state. - * @param id The ID of the shared state. - * @param initialData The initial data for the shared state. - * @returns The created shared state instance. */ export function createSharedState<Data>( + /** The identifier for the shared state. */ id: SharedStateId, + /** The initial data for the shared state. */ initialData?: Data ): SharedStateInstance<Data> { - if (!sharedStates[id]) { + if (!sharedStates.get(id)) { let subscribers: Subscriber[] = [] - const get = () => sharedStates[id].data + const get = () => sharedStates.get(id).data const set = (newData: Partial<Data>) => { - sharedStates[id].data = { ...newData } + sharedStates.get(id).data = { ...newData } } const update = (newData: Partial<Data>) => { @@ -169,7 +172,10 @@ export function createSharedState<Data>( } const extend = (newData: Data) => { - sharedStates[id].data = { ...sharedStates[id].data, ...newData } + sharedStates.get(id).data = { + ...sharedStates.get(id).data, + ...newData, + } sync() } @@ -187,7 +193,7 @@ export function createSharedState<Data>( subscribers.forEach((subscriber) => subscriber()) } - sharedStates[id] = { + sharedStates.set(id, { data: undefined, get, set, @@ -196,17 +202,36 @@ export function createSharedState<Data>( subscribe, unsubscribe, hadInitialData: Boolean(initialData), - } as SharedStateInstance<Data> + } as SharedStateInstance<Data>) if (initialData) { extend(initialData) } } else if ( - sharedStates[id].data === undefined && + sharedStates.get(id).data === undefined && initialData !== undefined ) { - sharedStates[id].data = { ...initialData } + sharedStates.get(id).data = { ...initialData } + } + + return sharedStates.get(id) +} + +/** + * Creates a reference key for the shared state. + * You can pass any JavaScript instance as the reference. + */ +export function createReferenceKey(ref1, ref2) { + if (!cache.has(ref1)) { + cache.set(ref1, new Map()) + } + + const innerMap = cache.get(ref1) + + if (!innerMap.has(ref2)) { + innerMap.set(ref2, {}) } - return sharedStates[id] + return innerMap.get(ref2) } +const cache = new Map() From 9c18b51e273cda2a60662e0d39c23cb0a175bb00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= <tobias@tujo.no> Date: Mon, 25 Nov 2024 12:36:18 +0100 Subject: [PATCH 02/13] chore(Forms): refactor and document missed parts of #4331 (#4335) --- .../extensions/forms/Form/Handler/info.mdx | 8 ++++-- .../parts/async-state-return-example.mdx | 6 +++-- .../extensions/forms/Form/clearData/info.mdx | 6 +++-- .../extensions/forms/Form/getData/info.mdx | 18 ++++++++----- .../extensions/forms/Form/setData/info.mdx | 4 ++- .../extensions/forms/Form/useData/info.mdx | 25 ++++++++++++------- .../forms/Form/useSnapshot/info.mdx | 6 +++-- .../forms/Form/useValidation/info.mdx | 24 ++++++++++++------ .../extensions/forms/Iterate/Count/info.mdx | 6 +++-- .../forms/Wizard/Container/info.mdx | 6 +++-- .../extensions/forms/Wizard/useStep/info.mdx | 6 +++-- .../extensions/forms/Iterate/Count/Count.tsx | 7 +++--- .../Wizard/Container/WizardContainer.tsx | 3 ++- .../extensions/forms/Wizard/hooks/useStep.tsx | 11 +++++--- 14 files changed, 90 insertions(+), 46 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx index 17715911b4b..e687dec6cfc 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx @@ -23,11 +23,15 @@ render( ) ``` +## Data handling + The form data can be handled outside of the form. This is useful if you want to use the form data in other components: ```jsx import { Form } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference + function MyForm() { const { getValue, @@ -37,9 +41,9 @@ function MyForm() { data, filterData, reduceToVisibleFields, - } = Form.useData('unique') + } = Form.useData(myFormId) - return <Form.Handler id="unique">...</Form.Handler> + return <Form.Handler id={myFormId}>...</Form.Handler> } ``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/parts/async-state-return-example.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/parts/async-state-return-example.mdx index faf06e5a976..52064fe781a 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/parts/async-state-return-example.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/parts/async-state-return-example.mdx @@ -3,6 +3,8 @@ In all async operations, you can simply return an error object to display it in ```tsx import { Form } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference + // Async function const onSubmit = async (data) => { try { @@ -12,7 +14,7 @@ const onSubmit = async (data) => { }) const data = await response.json() - Form.setData('unique-id', data) // Whatever you want to do with the data + Form.setData(myFormId, data) // Whatever you want to do with the data } catch (error) { return error // Will display the error message in the form } @@ -32,7 +34,7 @@ const onSubmit = async (data) => { function Component() { return ( - <Form.Handler id="unique-id" onSubmit={onSubmit}> + <Form.Handler id={myFormId} onSubmit={onSubmit}> ... </Form.Handler> ) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/clearData/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/clearData/info.mdx index 4c64d580f71..568a020e460 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/clearData/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/clearData/info.mdx @@ -9,10 +9,12 @@ The `Form.clearData` lets you clear the data of a form. ```jsx import { Form } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference + function Component() { - return <Form.Handler id="unique-id">...</Form.Handler> + return <Form.Handler id={myFormId}>...</Form.Handler> } // You can call it later and even outside of the form -Form.clearData('unique-id') +Form.clearData(myFormId) ``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx index cd937ae186b..1f63ccc92c3 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx @@ -9,13 +9,15 @@ With the `Form.getData` method, you can manage your form data outside of the for ```jsx import { Form } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference + function Component() { - return <Form.Handler id="unique-id">...</Form.Handler> + return <Form.Handler id={myFormId}>...</Form.Handler> } // Later, when there is data available const { getValue, data, filterData, reduceToVisibleFields } = - Form.getData('unique-id') + Form.getData(myFormId) ``` - `getValue` will return the value of the given path. @@ -44,9 +46,11 @@ You can use the `reduceToVisibleFields` function to get only the data of visible ```tsx import { Form } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference + const MyForm = () => { return ( - <Form.Handler id="unique-id"> + <Form.Handler id={myFormId}> <Form.Visibility pathTrue="/isVisible"> <Field.String path="/foo" /> </Form.Visibility> @@ -55,7 +59,7 @@ const MyForm = () => { } // Later, when there is data available -const { data, reduceToVisibleFields } = Form.getData('unique-id') +const { data, reduceToVisibleFields } = Form.getData(myFormId) const visibleData = reduceToVisibleFields(data) ``` @@ -77,9 +81,11 @@ The callback function should return a `boolean` or `undefined`. Return `false` t It returns the filtered form data. ```tsx +const myFormId = 'unique-id' // or a function, object or React Context reference + const MyForm = () => { return ( - <Form.Handler id="unique-id"> + <Form.Handler id={myFormId}> <Field.String path="/foo" disabled /> </Form.Handler> ) @@ -92,6 +98,6 @@ const filterDataHandler = ({ path, value, data, props, internal }) => { } // Later, when there is data available -const { filterData } = Form.getData('unique-id') +const { filterData } = Form.getData(myFormId) const filteredData = filterData(filterDataHandler) ``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/setData/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/setData/info.mdx index 5363b5580b6..e0563f8a15f 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/setData/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/setData/info.mdx @@ -9,10 +9,12 @@ With the `Form.setData` method, you can manage your form data outside of the for ```jsx import { Form } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference + Form.setData('unique', { foo: 'bar' }) function Component() { - return <Form.Handler id="unique">...</Form.Handler> + return <Form.Handler id={myFormId}>...</Form.Handler> } ``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx index c46da0a4235..98e561d6ebb 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx @@ -71,17 +71,19 @@ While in this example, "Component" is outside the `Form.Handler` context, but li ```jsx import { Form } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference + function MyForm() { return ( <> - <Form.Handler id="unique">...</Form.Handler> + <Form.Handler id={myFormId}>...</Form.Handler> <Component /> </> ) } function Component() { - const { data } = Form.useData('unique') + const { data } = Form.useData(myFormId) } ``` @@ -136,15 +138,17 @@ With the `set` method, you can extend the data set. Existing data paths will be ```jsx import { Form, Field } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference + function MyForm() { - const { data, set } = Form.useData('unique') + const { data, set } = Form.useData(myFormId) useEffect(() => { set({ foo: 'bar' }) }, []) return ( - <Form.Handler id="unique"> + <Form.Handler id={myFormId}> <Field.String path="/foo" /> </Form.Handler> ) @@ -211,12 +215,14 @@ const filterDataHandler = ({ path, value, data, props, internal }) => { } } +const myFormId = 'unique-id' // or a function, object or React Context reference + const MyForm = () => { - const { filterData } = Form.useData('my-form') + const { filterData } = Form.useData(myFormId) const filteredData = filterData(filterDataHandler) return ( - <Form.Handler id="my-form"> + <Form.Handler id={myFormId}> <Field.String path="/foo" data-exclude-field /> </Form.Handler> ) @@ -238,22 +244,23 @@ You decide where and when you want to provide the initial `data` to the form. It ```jsx import { Form, Field } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference const initialData = { foo: 'bar' } function MyForm() { return ( - <Form.Handler id="unique" data={initialData}> + <Form.Handler id={myFormId} data={initialData}> <Field.String path="/foo" /> </Form.Handler> ) } function ComponentA() { - Form.useData('unique', { foo: 'bar' }) + Form.useData(myFormId, { foo: 'bar' }) } function ComponentB() { - const { set } = Form.useData('unique') + const { set } = Form.useData(myFormId) useEffect(() => { set({ foo: 'bar' }) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx index 07ea4ecbf50..135684988bc 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx @@ -90,17 +90,19 @@ While in this example, "Component" is outside the `Form.Handler` context, but li ```jsx import { Form } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference + function MyForm() { return ( <> - <Form.Handler id="unique">...</Form.Handler> + <Form.Handler id={myFormId}>...</Form.Handler> <Component /> </> ) } function Component() { - const { createSnapshot, revertSnapshot } = Form.useSnapshot('unique') + const { createSnapshot, revertSnapshot } = Form.useSnapshot(myFormId) } ``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/info.mdx index 963606e8572..b8a03449ef4 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/info.mdx @@ -50,17 +50,19 @@ Or by linking the hook together with the form by using the `id` (string, functio ```jsx import { Form } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference + function MyForm() { return ( <> - <Form.Handler id="unique">...</Form.Handler> + <Form.Handler id={myFormId}>...</Form.Handler> <Component /> </> ) } function Component() { - const { hasErrors, hasFieldError } = Form.useValidation('unique') + const { hasErrors, hasFieldError } = Form.useValidation(myFormId) } ``` @@ -69,10 +71,12 @@ Or by using it in the form component itself: ```jsx import { Form } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference + function MyForm() { - const { hasErrors } = Form.useValidation('unique') + const { hasErrors } = Form.useValidation(myFormId) - return <Form.Handler id="unique">...</Form.Handler> + return <Form.Handler id={myFormId}>...</Form.Handler> } ``` @@ -83,14 +87,16 @@ You can also report a form error that gets displayed on the bottom of the form b ```jsx import { Form } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference + function MyForm() { - const { setFormError } = Form.useValidation('unique') + const { setFormError } = Form.useValidation(myFormId) useEffect(() => { setFormError('This is a global form error') }, []) - return <Form.Handler id="unique">...</Form.Handler> + return <Form.Handler id={myFormId}>...</Form.Handler> } ``` @@ -101,12 +107,14 @@ You can also use the `setFieldStatus` method to report field status. This will u ```jsx import { Form, Field } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference + function Component() { - const { setFieldStatus } = Form.useValidation('unique') + const { setFieldStatus } = Form.useValidation(myFormId) return ( <Form.Handler - id="unique" + id={myFormId} onSubmit={async () => { // Report a field status setFieldStatus('/path/to/field', { diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Count/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Count/info.mdx index 5d8d3007223..9fdb0ec5d5a 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Count/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Count/info.mdx @@ -77,11 +77,13 @@ render( And call it as a function as well (id is required): ```tsx +const myFormId = 'unique-id' // or a function, object or React Context reference + function MyForm() { - const count = Iterate.count({ id: 'myForm', path: '/myList' }) + const count = Iterate.count({ id: myFormId, path: '/myList' }) return ( - <Form.Handler id="myForm" data={{ myList: ['foo', 'bar'] }}> + <Form.Handler id={myFormId} data={{ myList: ['foo', 'bar'] }}> <MyComponent /> </Form.Handler> ) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx index 1161682d231..31c7a7ef92c 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx @@ -122,12 +122,14 @@ When using the `useStep` hook outside of the `Wizard.Container` context, you nee ```tsx import { Form, Wizard } from '@dnb/eufemia/extensions/forms' +const myContainerId = 'unique-id' // or a function, object or React Context reference + const MyForm = () => { - const { setActiveIndex, activeIndex } = Wizard.useStep('unique-id') + const { setActiveIndex, activeIndex } = Wizard.useStep(myContainerId) return ( <Form.Handler> - <Wizard.Container id="unique-id"> + <Wizard.Container id={myContainerId}> <Wizard.Step> <Button onClick={() => setActiveIndex(0)}>Step 1</Button> </Wizard.Step> diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/useStep/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/useStep/info.mdx index 25e58b9851b..99e1f925014 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/useStep/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/useStep/info.mdx @@ -31,15 +31,17 @@ You can also connect the hook with the `Wizard.Container` via an `id` (string, f ```jsx import { Form } from '@dnb/eufemia/extensions/forms' +const myContainerId = 'unique-id' // or a function, object or React Context reference + function Sidecar() { - const { activeIndex, setActiveIndex } = Wizard.useStep('unique-id') + const { activeIndex, setActiveIndex } = Wizard.useStep(myContainerId) } function MyForm() { return ( <Form.Handler> <Sidecar /> - <Wizard.Container id="unique-id">...</Wizard.Container> + <Wizard.Container id={myContainerId}>...</Wizard.Container> </Form.Handler> ) } diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Count/Count.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Count/Count.tsx index 60d1ebf0007..0528e25541d 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Count/Count.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Count/Count.tsx @@ -1,7 +1,8 @@ import { useCallback } from 'react' import pointer from '../../utils/json-pointer' -import { Identifier, Path } from '../../types' +import { Path } from '../../types' import { useData, getData } from '../../Form' +import { SharedStateId } from '../../../../shared/helpers/useSharedState' export type Props = { /** @@ -12,7 +13,7 @@ export type Props = { /** * A Form.Handler or DataContext `id` for when called outside of the context. */ - id?: Identifier + id?: SharedStateId /** * A filter function to filter the data before counting. @@ -45,7 +46,7 @@ export function count(props: Props) { return countData(data, props) } -export function useCount(id: Identifier = undefined) { +export function useCount(id: SharedStateId = undefined) { const { data } = useData(id) const count = useCallback( diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx index 3f3fc8d4571..4b13a5a2f32 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx @@ -30,6 +30,7 @@ import DataContext, { import Handler from '../../Form/Handler/Handler' import { SharedStateReturn, + createReferenceKey, useSharedState, } from '../../../../shared/helpers/useSharedState' import useHandleLayoutEffect from './useHandleLayoutEffect' @@ -146,7 +147,7 @@ function WizardContainer(props: Props) { > >() sharedStateRef.current = useSharedState<WizardContextState>( - hasContext && id ? id + '-wizard' : undefined + hasContext && id ? createReferenceKey(id, 'wizard') : undefined ) // Store the current state of showAllErrors diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/hooks/useStep.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/hooks/useStep.tsx index e0e94811670..b3753b75a05 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/hooks/useStep.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/hooks/useStep.tsx @@ -3,15 +3,18 @@ import WizardContext, { OnStepChange, WizardContextState, } from '../Context/WizardContext' -import { Identifier } from '../../types' -import { useSharedState } from '../../../../shared/helpers/useSharedState' +import { + SharedStateId, + createReferenceKey, + useSharedState, +} from '../../../../shared/helpers/useSharedState' // SSR warning fix: https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85 const useLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect export default function useStep( - id: Identifier = null, + id: SharedStateId = null, { onStepChange }: { onStepChange?: OnStepChange } = {} ) { const setFormError = useCallback(() => null, []) @@ -26,7 +29,7 @@ export default function useStep( const sharedDataRef = useRef<ReturnType<typeof useSharedState<WizardContextState>>>(null) sharedDataRef.current = useSharedState<WizardContextState>( - id ? id + '-wizard' : undefined + id ? createReferenceKey(id, 'wizard') : undefined ) useLayoutEffect(() => { From b2b9eef82084185fdc693dba9320f95383aba7e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= <tobias@tujo.no> Date: Mon, 25 Nov 2024 12:37:34 +0100 Subject: [PATCH 03/13] feat(Forms): add `variant="filled"` and `toolbarVariant="custom"` to Iterate.EditContainer and Iterate.ViewContainer (#4329) --- .../forms/Iterate/Array/Examples.tsx | 134 ++++++++- .../extensions/forms/Iterate/Array/demos.mdx | 7 + .../forms/Iterate/EditContainer/info.mdx | 5 +- .../EditContainer/EditContainerDocs.ts | 2 +- .../__tests__/EditAndViewContainer.test.tsx | 26 +- .../ViewContainer/ViewContainerDocs.ts | 2 +- .../Section/containers/SectionContainer.tsx | 3 +- .../Form/Section/style/dnb-form-section.scss | 19 +- .../themes/dnb-section-theme-sbanken.scss | 7 + .../style/themes/dnb-section-theme-ui.scss | 8 + .../forms/Form/Section/style/themes/ui.js | 6 + .../forms/Iterate/Array/ArrayItemArea.tsx | 13 +- .../Iterate/Array/ArrayItemAreaContext.ts | 3 + .../Array/__tests__/Array.screenshot.test.ts | 30 +- ...rray-have-to-match-edit-container.snap.png | Bin 10809 -> 13396 bytes ...ve-to-match-filled-edit-container.snap.png | Bin 0 -> 24127 bytes ...ve-to-match-filled-view-container.snap.png | Bin 0 -> 11951 bytes .../Iterate/EditContainer/EditContainer.tsx | 11 +- .../EditContainer/EditContainerDocs.ts | 4 +- .../__tests__/EditAndViewContainer.test.tsx | 275 +++++++++++++++--- .../PushContainer/PushContainerDocs.ts | 2 +- .../forms/Iterate/Toolbar/Toolbar.tsx | 11 +- .../Iterate/ViewContainer/RemoveButton.tsx | 8 +- .../Iterate/ViewContainer/ViewContainer.tsx | 10 +- .../ViewContainer/ViewContainerDocs.ts | 4 +- .../extensions/forms/Iterate/hooks/useItem.ts | 15 +- .../theme-sbanken/sbanken-theme-forms.scss | 1 + .../style/themes/theme-ui/ui-theme-forms.scss | 1 + 28 files changed, 527 insertions(+), 80 deletions(-) create mode 100644 packages/dnb-eufemia/src/extensions/forms/Form/Section/style/themes/dnb-section-theme-sbanken.scss create mode 100644 packages/dnb-eufemia/src/extensions/forms/Form/Section/style/themes/dnb-section-theme-ui.scss create mode 100644 packages/dnb-eufemia/src/extensions/forms/Form/Section/style/themes/ui.js create mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-filled-edit-container.snap.png create mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-filled-view-container.snap.png 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 1c947014598..0af16769b22 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 @@ -1,5 +1,5 @@ import ComponentBox from '../../../../../../shared/tags/ComponentBox' -import { Flex, Table, Td, Th, Tr } from '@dnb/eufemia/src' +import { Avatar, Flex, Table, Td, Th, Tr } from '@dnb/eufemia/src' import { Iterate, Field, @@ -565,3 +565,135 @@ export const WithArrayValidator = () => { </ComponentBox> ) } + +export const FilledViewAndEditContainer = () => { + return ( + <ComponentBox + data-visual-test="filled-view-and-edit-container" + hideCode + > + {() => { + const MyEditItemForm = () => { + return ( + <Flex.Stack> + <Field.Name.First itemPath="/firstName" required /> + <Field.Name.Last itemPath="/lastName" required /> + </Flex.Stack> + ) + } + + const EditItemToolbar = () => { + return ( + <Iterate.Toolbar> + <Flex.Horizontal + justify="space-between" + style={{ width: '100%' }} + > + <Flex.Horizontal gap="large"> + <Iterate.EditContainer.DoneButton /> + <Iterate.EditContainer.CancelButton /> + </Flex.Horizontal> + <Iterate.ViewContainer.RemoveButton left={false} /> + </Flex.Horizontal> + </Iterate.Toolbar> + ) + } + + const MyEditItem = (props) => { + return ( + <Iterate.EditContainer + variant="filled" + toolbarVariant="custom" + toolbar={<EditItemToolbar />} + {...props} + > + <ValueWithAvatar /> + <MyEditItemForm /> + </Iterate.EditContainer> + ) + } + + const CreateNewEntry = () => { + return ( + <Iterate.PushContainer + path="/accounts" + title="New account holder" + variant="filled" + openButton={ + <Iterate.PushContainer.OpenButton text="Add another account" /> + } + showOpenButtonWhen={(list) => list.length > 0} + > + <MyEditItemForm /> + </Iterate.PushContainer> + ) + } + + const ValueWithAvatar = () => { + const { value } = Iterate.useItem() + const firstName = String(value['firstName'] || '') + return ( + <Flex.Horizontal align="center"> + <Avatar.Group label={firstName}> + <Avatar>{firstName.substring(0, 1).toUpperCase()}</Avatar> + </Avatar.Group> + <Value.String itemPath="/firstName" /> + </Flex.Horizontal> + ) + } + + const MyViewItem = () => { + return ( + <Iterate.ViewContainer + variant="filled" + toolbarVariant="custom" + toolbar={<></>} + > + <Flex.Horizontal align="center" justify="space-between"> + <ValueWithAvatar /> + + <Iterate.Toolbar> + <Iterate.ViewContainer.EditButton /> + </Iterate.Toolbar> + </Flex.Horizontal> + </Iterate.ViewContainer> + ) + } + + return ( + <Form.Handler + data={{ + accounts: [ + { + firstName: + 'Tony with long name that maybe will wrap over to a new line', + lastName: 'Last', + }, + { + firstName: 'Maria', + lastName: 'Last', + }, + ], + }} + onSubmit={(data) => console.log('onSubmit', data)} + onSubmitRequest={() => console.log('onSubmitRequest')} + > + <Flex.Vertical> + <Form.MainHeading>Accounts</Form.MainHeading> + + <Form.Card> + <Iterate.Array path="/accounts" limit={2}> + <MyViewItem /> + <MyEditItem /> + </Iterate.Array> + <CreateNewEntry /> + </Form.Card> + + <Form.SubmitButton variant="send" /> + </Flex.Vertical> + </Form.Handler> + ) + }} + </ComponentBox> + ) +} 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 7b8e20894be..42d6de4bd4c 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 @@ -49,6 +49,13 @@ With an optional `title` and [Iterate.Toolbar](/uilib/extensions/forms/Iterate/T <Examples.ViewAndEditContainer /> +### Customize the view and edit containers + +- Using `variant="filled"` will render the [ViewContainer](/uilib/extensions/forms/Iterate/ViewContainer) and [EditContainer](/uilib/extensions/forms/Iterate/EditContainer) with a background color. +- Using `toolbarVariant="custom"` will render the [Toolbar](/uilib/extensions/forms/Iterate/Toolbar/) without any spacing so you can customize it to your needs. + +<Examples.FilledViewAndEditContainer /> + ### Initially open <Examples.InitiallyOpen /> diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/EditContainer/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/EditContainer/info.mdx index 9dc74e1820f..d761fac1342 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/EditContainer/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/EditContainer/info.mdx @@ -55,8 +55,9 @@ You can get the internal item object by using the `Iterate.useItem` hook. import { Iterate, Field, Value } from '@dnb/eufemia/extensions/forms' const MyItemForm = () => { - const item = Iterate.useItem() - console.log('index:', item.index) + // TypeScript type inference + const item = Iterate.useItem<{ foo: string }>() + console.log('My item:', item.index, item.value.foo) return <Field.String itemPath="/" /> } diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/EditContainerDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/EditContainerDocs.ts index bb5c00b69d6..b561bae98e9 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/EditContainerDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/EditContainerDocs.ts @@ -7,7 +7,7 @@ export const EditContainerProperties: PropertiesTableProps = { status: 'optional', }, variant: { - doc: 'Defines the variant of the container. Can be `outline` or `basic`. Defaults to `outline`.', + doc: 'Defines the variant of the container. Can be `outline`, `filled` or `basic`. Defaults to `outline`.', type: 'string', status: 'optional', }, diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/__tests__/EditAndViewContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/__tests__/EditAndViewContainer.test.tsx index ff239574eb0..16c689f6f39 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/__tests__/EditAndViewContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/__tests__/EditAndViewContainer.test.tsx @@ -559,7 +559,7 @@ describe('EditContainer and ViewContainer', () => { ) }) - it('should set variant to "basic" when variant is set to "basic"', async () => { + it('should set correct class for variant "basic"', () => { render( <Form.Section> <Form.Section.ViewContainer variant="basic"> @@ -579,6 +579,30 @@ describe('EditContainer and ViewContainer', () => { expect(editBlock).toHaveClass('dnb-forms-section-block--variant-basic') }) + it('should set correct class for variant "filled"', () => { + render( + <Form.Section> + <Form.Section.ViewContainer variant="filled"> + View Content + </Form.Section.ViewContainer> + + <Form.Section.EditContainer variant="filled"> + Edit Content + </Form.Section.EditContainer> + </Form.Section> + ) + + const [viewBlock, editBlock] = Array.from( + document.querySelectorAll('.dnb-forms-section-block') + ) + expect(viewBlock).toHaveClass( + 'dnb-forms-section-block--variant-filled' + ) + expect(editBlock).toHaveClass( + 'dnb-forms-section-block--variant-filled' + ) + }) + it('should validate on done button click', async () => { render( <Form.Section> diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Section/ViewContainer/ViewContainerDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Form/Section/ViewContainer/ViewContainerDocs.ts index 94d7f6b53ba..9aed0813273 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Section/ViewContainer/ViewContainerDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Section/ViewContainer/ViewContainerDocs.ts @@ -7,7 +7,7 @@ export const ViewContainerProperties: PropertiesTableProps = { status: 'optional', }, variant: { - doc: 'Defines the variant of the container. Can be `outline` or `basic`. Defaults to `outline`.', + doc: 'Defines the variant of the container. Can be `outline`, `filled` or `basic`. Defaults to `outline`.', type: 'string', status: 'optional', }, diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Section/containers/SectionContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Section/containers/SectionContainer.tsx index b2c52927db5..5c05ab3fabf 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Section/containers/SectionContainer.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Section/containers/SectionContainer.tsx @@ -19,7 +19,7 @@ export type SectionContainerProps = { * Defines the variant of the ViewContainer or EditContainer. Can be `outline`. * Defaults to `outline`. */ - variant?: 'outline' | 'basic' + variant?: 'outline' | 'basic' | 'filled' } export type Props = { @@ -126,6 +126,7 @@ function SectionContainer(props: Props & FlexContainerProps) { <Card stack innerSpace={variant === 'basic' ? false : 'small'} + filled={variant === 'filled'} className="dnb-forms-section-block__inner" {...restProps} aria-label={ariaLabel} diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/dnb-form-section.scss b/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/dnb-form-section.scss index 399486c581b..4e14c27fa90 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/dnb-form-section.scss +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/dnb-form-section.scss @@ -11,13 +11,6 @@ flex-direction: column; } - &--variant-basic { - --border-color: transparent; - .dnb-card { - --card-outline-color: transparent; - } - } - &__inner { flex: 1; outline: none; // for JavaSCript focus @@ -43,6 +36,18 @@ } } + &--variant-basic { + --border-color: transparent; + .dnb-card { + --card-outline-color: transparent; + } + } + + &--variant-filled &__inner { + --space: var(--spacing-small); + background-color: var(--color-lavender); + } + &--no-animation &__inner { transform: translateY(0); } diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/themes/dnb-section-theme-sbanken.scss b/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/themes/dnb-section-theme-sbanken.scss new file mode 100644 index 00000000000..cb6e6f2f8ce --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/themes/dnb-section-theme-sbanken.scss @@ -0,0 +1,7 @@ +.dnb-forms-section { + &-block { + &--variant-filled &__inner { + --space: var(--spacing-small); + } + } +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/themes/dnb-section-theme-ui.scss b/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/themes/dnb-section-theme-ui.scss new file mode 100644 index 00000000000..567160289b3 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/themes/dnb-section-theme-ui.scss @@ -0,0 +1,8 @@ +.dnb-forms-section { + &-block { + &--variant-filled &__inner { + --space: var(--spacing-small); + background-color: var(--color-lavender); + } + } +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/themes/ui.js b/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/themes/ui.js new file mode 100644 index 00000000000..7f7b31db976 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/themes/ui.js @@ -0,0 +1,6 @@ +/** + * Imports the default theme + * + */ + +import './dnb-section-theme-ui.scss' diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemArea.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemArea.tsx index 35c9453fca1..7b6bdd0eba0 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemArea.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemArea.tsx @@ -15,10 +15,11 @@ const useLayoutEffect = export type ArrayItemAreaProps = { /** - * Defines the variant of the ViewContainer, EditContainer or PushContainer. Can be `outline` or `basic`. + * Defines the variant of the ViewContainer, EditContainer or PushContainer. Can be `outline`, `filled` or `basic`. * Defaults to `outline`. */ - variant?: 'outline' | 'basic' + variant?: 'outline' | 'basic' | 'filled' + toolbarVariant?: 'minimumOneItem' | 'custom' } export type Props = { @@ -40,6 +41,7 @@ function ArrayItemArea(props: Props & FlexContainerProps) { children, openDelay = 100, variant = 'outline', + toolbarVariant, ...restProps } = props @@ -168,7 +170,9 @@ function ArrayItemArea(props: Props & FlexContainerProps) { }, [handleRemove, index, setOpenState]) return ( - <ArrayItemAreaContext.Provider value={{ handleRemoveItem }}> + <ArrayItemAreaContext.Provider + value={{ handleRemoveItem, variant, toolbarVariant }} + > <HeightAnimation className={classnames( 'dnb-forms-section-block', @@ -184,7 +188,8 @@ function ArrayItemArea(props: Props & FlexContainerProps) { > <Card stack - innerSpace="small" + filled={variant === 'filled'} + innerSpace={variant === 'basic' ? false : 'small'} className="dnb-forms-section-block__inner" {...restProps} aria-label={ariaLabel} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemAreaContext.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemAreaContext.ts index cd03dc0ef6a..e92a9bed4fd 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemAreaContext.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemAreaContext.ts @@ -1,7 +1,10 @@ import { createContext } from 'react' +import { ArrayItemAreaProps } from './ArrayItemArea' type ArrayItemAreaContext = { handleRemoveItem?: () => void + variant?: ArrayItemAreaProps['variant'] + toolbarVariant?: ArrayItemAreaProps['toolbarVariant'] } const ArrayItemAreaContext = createContext<ArrayItemAreaContext>(null) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.screenshot.test.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.screenshot.test.ts index a4583a99dcc..90370890ba3 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.screenshot.test.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.screenshot.test.ts @@ -25,6 +25,30 @@ describe('Iterate.Array', () => { expect(screenshot).toMatchImageSnapshot() }) + it('have to match filled view container', async () => { + const screenshot = await makeScreenshot({ + url, + selector: + '[data-visual-test="filled-view-and-edit-container"] .dnb-forms-section-view-block', + }) + + expect(screenshot).toMatchImageSnapshot() + }) + + it('have to match filled edit container', async () => { + const screenshot = await makeScreenshot({ + url, + selector: '[data-visual-test="filled-view-and-edit-container"]', + screenshotSelector: + '[data-visual-test="filled-view-and-edit-container"] .dnb-forms-section-edit-block', + simulate: 'click', + simulateSelector: + '[data-visual-test="filled-view-and-edit-container"] button', + recalculateHeightAfterSimulate: true, + }) + expect(screenshot).toMatchImageSnapshot() + }) + it('have to match view container', async () => { const screenshot = await makeScreenshot({ url, @@ -38,11 +62,13 @@ describe('Iterate.Array', () => { const screenshot = await makeScreenshot({ url, selector: '[data-visual-test="view-and-edit-container"]', - screenshotSelector: '.dnb-forms-section-edit-block', + screenshotSelector: + '[data-visual-test="view-and-edit-container"] .dnb-forms-section-edit-block', waitAfterSimulate: 100, simulate: 'click', simulateSelector: - '[data-visual-test="view-and-edit-container"] .dnb-forms-iterate-open-button', + '[data-visual-test="view-and-edit-container"] button', + recalculateHeightAfterSimulate: true, }) expect(screenshot).toMatchImageSnapshot() }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-edit-container.snap.png b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-edit-container.snap.png index 673fd378ebcd74e2abf35773e4917186835ca921..9262ce30446d0d65b9a523cc10dc50931c2f409c 100644 GIT binary patch literal 13396 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 b8Cf=Qo>q|iUMhH7h(br>x_XtWUDW>pH@cU7 literal 10809 zcmeHNc{r49-<DKbM9G%58m1z$gcwU324i@NlC2_?(Af7$ND*o#Da(+Q?E9LfY}3dt z>)4H~W0&2$*YrNArti=9JKp0vzULpueO&JQT7K8>I?wZWUh_y>OO<KY!CiE8bWCuy z^MBFNF(AS7661F8m-p}|0v(+I9sK;6OD@LbZub{@H;ZMadrv}QeEN2Nf$a`A;&Z+a zaaA<$kPaE)#<8+WT~h0Dl5a}6X`>dSEsT(2+An$P7%SVQ+>-kb)!y7UC-qmJCmppg zSF$3%2nwh<SznWEp-^vO#K9q()#|ylrnf#*Jh^JDo8xCy8Niltx4+D?E!AixUN=dv z|FH5sx-Ik!j4T3wKU@m`5Pr&Pz=v7!;myy_E5AnA@<rUqgkL&gQc~nJGf>(1>YV?( z)YMcd);U4iZ#^`xEsV8fT>E(6vbV(ZQf-7xn)I^jcx#Hzz8~Fhj<a_0+E8`qQxVyO ztJ#)1)(8X>=daWF{+@RT;{iMDyQ?|3byLW4JxsJOe86wRv_N$U-Whj)F5t%qilR>{ zgvwZLxBs?0@ZW1&>C0HtcF;B}yA1IDF-s|}57pSJ0@r}wgT(TVWlKNY??9*2&L6}5 zdjj57I~kD&hrb@9nQF{g245)KUzW`fnwTAg3czkfM#R(1-+R|S7ThJZR|qW4z?PBs zrsGgHINE(XeqPz-uS0&)2-<;B^kx6c3uuV#CxDcR1?d#Toj|*iE%aQB2Y7L?m66vo z6It~(cSltDT+QwjFSIu1+4i=3E_M&Tjj#zwOiW}_l=NC#dI58_n;&UB5-d0NPU!VZ zNeitmuT?UtMjT(^y@RjajC}hcO-PO+y<^L<*UKMN8aa38oQd*UU%7xlAPmv#3!~Lo zFd9V8Y2t;Ved7g=N{paJQ!hDxFxZj2hgVKYC4`UHWVN#`7ERee#8R3x#47J?**XyA z+ykdfwj^D~W+&~V4XDSqfW64C_0jyhIYc%6dcfq=?qf)Wi#_h+xg(G>a7RbSF1PWN z#vH3Q*?wQvvGXCa0}ro&9DmFuDOBh%+9c8s&+LTk)G+p19xD9i2OV1dD}w9yY_wFT zF3EAn++rz3K}&I&G_Oj~!$D3aOx=Do4bey0O%@EREKYVG+4+|-dfibNIVSwLmz5@a z6O;q}ppq7Kk8RPb#L*6DUYDH)>|47jYOYoWKhcr(IyYB3x7Ta6-3*HuNY1KL-}(aC znL9U@lvdTAVQM<|B_ZQRWx!I`uwqzGX}#jA#|=1#m~mv!`=RFsRUf>!nwI(Q9PPD; zL_KJu!4MXK^fTV<yOHKKFZK;bIU74ncAc&Y;tt!+skGWrKakUA97qy|$xjv8AH+et ztKiV6?4D`JPOX*sQP$n;A`cZ7yCgevt+|`yks%0~ZYi&o=IAP8ce1W+0hTPs3f0P< zjWa$b^ZU`;LjOqFyq|D&Bd8-6%ODJImvEWxlbxwxZ59$jgg@gu8sRHex?F2JmXbIf zXMFgNug4ilLNNH=w+bLrU!U{eD}JNmA#+qbhT$+lUx;9VZS$%e#wLouW;SR8&3)ua z#5Ad37#A2QR^sAg&wbj?jK`tj40n;LOQv|5zdJ2RbooN3iWiM}oV!q?)fm!;j@A?d zbg~=4^*FgRhZX0NM*D#f>n9lOG{~j_e#(;S9)3lb+qVgQnT!Qb>)~e?wcw?7_T-$l zsu?`LiStyqb1$zykDb~?cC*$(*%jBn#GZ1~!iyt=1T%6w)4^vrT}<-9<uZ;E7Nhr0 zK^Kt<r>Y)vv7URd=aG?9hoDV<|AThxyzT)%4&#U5Tvg5veQqY7cAv;lg;Cb#X5y9B zk1-)_ie{=7*cXFjden&OsbR)$$G+A^aJ-)^v|4c`k<{;VbNrs&0D?W~U;0A{iCB_k zITAK=9^zjnB&?HK@HJejFQ0_bIJ^u)?GJ$TtJxu<<WAYVC+E7%RC1VhWZrB{xEL?H z)aPT`_rbgIEh1t;@AOVhx5dfzN4Cm#xPU+!Y!GOe=!c6}B<ZCDkPNZbxgA1OGHJWC zKlM&diyr?<q1NnY(#E#k>~W>`e{fW|MhyHiPO42HM}s!OKdCFbU_y($XgA7`kr8R9 zBjiI1^O^fuaAtyOCNy9ozyZ+N@EJjXwx47`7y5+pFq-9j^1C+;;kYTGM%#}tpd-E6 z6<S8NJ0t)jTzI($L)*_k(+A-3PxQa*K7s#O@<_p5B>tvwL)W3u#$0Qn3UaMvbuv(< z*F#eD{Y0ipUdE)4@a5FU+&1}#0ETh-^Lx2qvlISc*-VCT2#ib4!SXSmf}D_6B<HD9 zr-Imv#uAfm0G61UE@zf0407Z*EOfZVWB2J0s>gNs0??chafl#VS_l;sfF&fmMVz2P zq4cNV@C7m0%hR-L>(ifL$0h4$33+YIMdKywVXSE-E4S}t)k>q*X7G3b^xW2++3=P& z8{+ap8k$$8$Hi0=;M8p^tJkU_e2qz$(?*)!eD+VAoBb??2V};2@vSBt-IHRFH(#Rg zt^LTlEz#-t_)Tep-;Z$v4|QNFx>p7Ba&s6t=ujbC{8m$3K4oLIIo<Tby)BOYgsgf+ zZUft5#|a=~P^71-Rcp(0gFqXpQA!qC4NSv~oZwSYr|r!<TLC<eF3pCOMm`W>R$RJP z1T+e2m7Is)D?b5LXRwS)#4o}hge|6w;i?FXs<|NOZ@p-W+`w+JqLW5`7#^tzLqatc z&*v_p1)Hhhgt^@9_Ou_a3j%YImvbCD@LDBE9gXu9)p#xf-}+)~Y;4{FV6J*}w_}?T zRp0H$U26Y)r;uj2)70Vg<(I(Z1dKt#xC{o#+~~?jjkZSSn2hRk19701X=m0wj#t`1 zb%Cx%i{WZ^75h}<ysHV&=(Sg?n6!<xN#|0HDT5M^f;6%e-rzNj0@gl54Jt%fa}wks zr;?Y)os3HM(bg!}xv!OAk8c1G(^%rU>M%rTMk?_l6&AkW)u$WIF;^~@EEa>->QLdq zaTAJL;Xqts;_c!g^}4h0I^CIsfn9D9h|u40Rmz7dxd_q`5CJYJOYI3T;^?K+;%}V} z#M(lj`mCY)Pn!zthnW#FJGC&;N*haq8Ey+>vOsYRM)a<k&rf!*vg-4268G3X7W$nM zl>K&QtGR%^TodfRVZXl67XM1sPB2L|7~~TWmyP%`EUD+S==<c<16ETA`cQGxsyHR3 zg)bMItxu2RTrq1N;Mio4bBS;J1E=H4dd!<?xT02|Ar4Ft6K74#<v(bisQ}eSVR%Sx zAGYi`PuW->q-x^QB1zM#$5)D6P+jg*#iN%UFxK+6#S3~XS){oNRbIzSZYA(c-Qjxl zhP@jjb@dUs(Y}!Nm4(21kFS!qh=k!s(ddn3PN0t>?W&KVD%mc7i3-AGhw>;JT)f!4 z$2XjoH>>qy;N{qbGg0<+r%|JC!rl_Cy%v&8-n_ztq&kS_vDb`rY}H3?EDy(<^l?ew zxdH^`j%pzT_Y%tb6h8Y_^Nrf@Q=?O2`F;0CT2qMrg=$`CqIKRIYoR1fOg%S0P+*Pm z$mqf>ZI#EIPx+))yQjQl*5fk%&=Kk4^oaeqJDUCk>8M(1q6m-?p^CY8Ix^p-qy#yU zJ3W(Mni**A$0biWkM~3-Yrz!_x<IA8u=nQ2``d0sgQMT`;#DCnp9`>@;KQAS($s{E zBsfqP@wd5zX^^R503a4SwCxa0`x7Xje_Z0t<loQNJ>|gNjL55zhW@neI3O=v6Q5`7 zrzsguhyq+d+40i0y8`(_O1N63PNRde^FcLQNpMu7Y5xx{fXXQrjj>BB3X2t#2nG4C zqWVn(*am6{W(Cq0%p)p?oqJAuxpm&k=>8fpeEn0^FOyL|6EiYRT7RQq+DlohmrZkD z%#X9EZ_o8~Pp5_?ozEL$xj~47qwvp;iF<yy*KXI0IQ@$be6p2VhuN!!1h41zDvByi zr1NODv4E5Lr1*nlh~mMl9#0vSC|C1uKH~JhVEGu^Q8`$L({$g`TwV#A0o(J}@2LVj z#>?AmTlL1d2NjV?X{i)*=U<rWNr7@>KfERMvI@E3)k|ar9ryrjE2a14uX&*S25ji@ zb*7VOdIkyL5SW#vUi>+35ZEQDfZB6lX-Bny9S|8E{}V*A<G@x4<}6&eN9T>+#fU80 z^)o4Ej{up7OnLl_&RYWX@k~b?;r9dCVkWlvLT{SfV1+2h8~~4RuCST@G#}EvyfKVO zoY%@IAS3x3R8js$BMg(;LWRw-vf&FM0H0*%8_sFCC2v=~r#uQqR?m}GQQ1tGzSs1E z`*7cDd~k~KSfnBswRn-M>ZNQf&O}XlKqm{wQ<o+zlnnNVM#wwc0FadD^IGc92W4n1 zGFnkkT+|0>IW#yLYb`YoH&=Z8a*SCy79s_|A8_pATdH~R!qra;;JZto_l_HY%mab! zXGBV*J>0_bDG%uC-UUgwAHnplrp7Y8$7H1`Y#_aSZ{X`-x#LXs$*@}jkwiw8D{1$p zD+14KE<!+wUXTk1F>p4o4QHDwn$wy9-%#bx2r<ugeyUd~)F{gwv4=|n(yb^7w1aHl zy{(6L%B1EG1iVX3RG%uCZ_*f~T5f=Y6jDLu^g`TJ#bK&Pfm#yHZ*1ARJ<h;~>N%+L zdpb}Bf}HcDq3saWL1?#1EB1vhs26zCc{2g?sfhpAebvVF6U5NVpIv}(G5zvR2i2l` zxw1}vDl+vOz(a#Ct*up{wBuatvPVWbb>15Nm;!$&;WW{nzcdhNI@0he!E5$C0Si?H zriS6&kJ~bqzeeDZUb9##2T)7g3^M}8!JxsuUV#HhJbNBj($g7#rL|W*)jN@6f_7WC z&@-`sK++LT{G65g`4~t!w&E+c4z^6TMRP-=J?y%ih}NTrvaHe~YzmJVMM(h7Gt!Y| zaS%r(nwdTy=Bh_f+>4&)k@|K`aA;+){3K_VMSZHlo7vDb4?#Y+kylbzIvko2GFJ}v z!ygqDJUAmT3=$%eqhb1ILZ}Nk1BN>_%u%{@0XA17Nv$6XZwLy*-R4K0da1~EV6eVW zxWMej>Qp?@3nRR|_}svTE6mUi{+Q=<=46N2ZtdBZB(o%K&uDda+@&uRMa}2RSs>=& zL3-ss*B5R`9mnq4xbIHZSjX|!@yZckC3U$DN8NJ4c&x~tmbppJv22WWSgngW<1?^B zY3WRNy+0E-dzgurYfa>7$wDV`;Tf}jIJVU1AViz0QUgTZ!#7<A(xvbHvrLRMz8<WV z=}k7!$y9Xjhimn!l?!Pf0<Z5nmVIjz!k_sm6COYbu7XxupOD7ot<p#_?UHq}M{Ixv zH!skh(-y!c+>|y9u?UBRO~;A!Ah814xuBEG6Df~2q43!&Y9~~021p3eV4z&)1LX-$ z5hT`+K<x$tjV#IMIo~Kd9BJPqFjTzQ6S(h)O2wCii_Kya6S+hi1Rri~CItN$c!ZVQ zwnh7Tm#So%%5Rk}Rf@e$s~A?IEYFLuQEh^Q#{mPqd$#)1urL$T&g_Su#WEZ&_h$|% z-B?INee1Bm^E!7u4M;I8kWk1o1Cnv3S~%@IaQ&!<rq~&{D_T>x#zLWFVji-zDyzh5 zpyCm#{ks266T}e!7BXOcUC*kOH*<sWJRBUNmg{wqr>PEuWlLfU-Ugu;<uK7Mo|u$0 zZdEk}-qn@>-(imVdH)3Ncb5e?`r*Y{cR{K%*a-m~)&IC>ljdV$0b;Tld10Z-e^y!P zGoZI1nK=gVWdJ)1?f~K)c>1yM6QHkK;<tIh7WAP(FFo6T{%?8T-~94I@tU%Os7Fv0 zK=m#33%hIdvyXn`mw<dV^mJU8dGGTB!~y2lP;JW+r^W7!DJNoOUNB5zj=)L%|E$0? z{~#W3Wj|c?PO`qH{BF?G&Cir8cLVRXsQB)69;rK5BAZhT44pn5YD{?hc0gwf2m<hN zZ0n-=kKo*ns~{uT#rE=W8f07T_{A;v=ApX35c@TXAKjb!_KcaDi4JjR0)|JENafLn zS$YH8T>bfW*Wsn*(Rh4ru3WCimieC)9`lr{yQDI0LpF2qNAT5+0msb>N+|yf%=hRC zXaF_zd}T$5Mppgv{=WnD|1UmN5x3ZjR?<r|jvC^bM{M%;-w#*PlmWp}3&RVeAX^W# zy!Zc!41cGaAdtq#X6U*2emSGmbbFElq=NyvcoUxgd4Chb{=4yM5a}nZqgO6=iQiPO zF)7TpSs8X1XCG$Y$p3+{n-g{2Z#fGtAT)EA>YQ(slJi1>t%+~h=fX||j@p*;HmBwv z{zjRPm+2hF0UsQ1UVC-Q9uGY5$iiMP@$qiw!IlF1!9aI%P9xc!Hn#+(;q({$p|5ud zYhrYTtkNJb*H<jhCZc_z<j!@ZOC~N_P>Ir3ycoRU&v(_<Q54<h&H0UlMLD#k&LoVD zF?XY96;~_#6x=F40!Ee0vcUIJ)_YCy!Mx+v%}kps`1`^8!FRZQrLsQ;BCOGBWIYRy zN&^N?SHzvcju+Er8;)%k{L!iA%a*GIy;t!jXa%pOAu&~5UEOnEC;4cA^qz7tb?R_t zbcBqU(+LLdyu~rysRAK*gI&)H@tKv<b%Tk_q2~*GPZnG!S7Vv{piH+vA^w<B&;d)# zG-NbutXkM~Is;#|7%VT!TejP_Sl79PP)OP6;iqt3R+?4&vX{5B@0BzJwp=<`RXJti zcI+)Pr*_+9cxNZ4#PzeVwQ6B;a;?nZJzl4WVr+Hi3xF^m(~CEB>-C)ZSRIP0E?KQY zKj!BbW7lgaa<4sw>V3?GS~m@;H0@ndIoJc6>9opls|{ajvQXkaS2`P7cmmFZ^W~;F z>E4FBq%>SGzJfc}MpQ?)WDs(`^%AaU)Ux3Wr^iiH?b0V=`PT%<S-C8chA#CAqxD+C zJV)IpaPC#*Vx>H$;KnMDSy7fr88e<OKlyNY_s8n8q0fVX(aV}VxvP^;J-9~VfuR`I zi$+hh6zH^P?4hIEIZyp9fc->A!bOjqySdd%XBq8>?d#v{_!2zYEg!Z`#*$`Zt_?DT zi@-XDI;{q$&rY8ltC2Jv0JnDgWO{j6cO^+<tjMLzQ%!<(yudnT*F&wPOkcn~Ft)mk zbLhC8^v<RwaWcdD_U(ZLuk|L%hNa6av8?(jRy8Pq2xMX1R8R5b%ZQEf(pcN;IfS?} zdj;q2Q%uN7&zZ{ntdo;HJNT|R_kJq#T;H|mS$vn1T(2dcGC8c;Yo{Ss%IvjN-cCB= z-zM##IC+GIe`k+0nDrC-kEp)Q!nNm3Z3h>eQ&vZtJEuwEgCQZgDF$r{;C?2eLQugy zRR&fNo8I!30}I7Vh8eM2x4N1W0@xZ&m!|Uz8(Qy`Gb78f{u{GlORCqCv{t3971?pH zrg$VI`kb*l(@R<L?HiNMGi_xrPrR3&u6h2TuGy0&EtN-il8(bV8oV?3ja=<6WzN1y zKO46oC#>5#H0AhV3u;*3;!QuH8NF&ybjoGgKQYT`h){FJCw5$#_+@<(k5pg_W6PZ@ zS|QuwC;dWCiqh;*Owl&5L-5=|h<{><aeHvqD{J|2o2gqzt<UX!yl&}&;bD+>>&$k^ zVhKooe|6}!=Pu;ItJ@ZT*ErtQ2P`wE&ANTAwE+R|oiP_Cil~RdH==krR~yr2rF@IU zL7kyJ!Bx6;v&(H|6!r1UjuywP8w+FeNiZn6*m*^=AAat5AI-VeE}*a7kAp-;&nFB# z--X1~?}~P9GnyHxc{$o<U)PSNxY!afoI34Jk~!0kQH1qXyp5l=Oc}P?bGbHhQyPAM zkUAejD8uT2^4it3s$$&F=aTcXug@ilw5{=kTel<1J<S+pbEv^`q~%2@6iyW0ous)v zh?`?@*N(E1*~#}h7iF*Iu4>6wPN#XD`qtw_%m!u5xuibD-=nGhk{&^|a1nc#8bfYS zsPIhgOt~!CQ8rgbw6#5X?baPGMYpd6RU%w33Nm<g@ghEm{Mnv%$A=iD^j$DdnA~Rx z`RJ<0Ok+{m^@@#@hI0{yOB9HKgL!R$b81N;f!HcQ!|@<uh=Vw@N|)+)Iy2hWt?KkC zvtpyq1^&3#j7eaqJ`5|Ec?69;cU_}ybd}6;%DPi3G|1@>%F$u(e<ZhAr2Wve;Tp;= zO@^7BH0CTr^;w{G*>6hi{5>+tF$KWMs})Q#qHPxe?KgE)urvlA!#@N3$l#d&Vu)gH zgWo>o08`i|_1ph&xt#JLQ8y{}c4O>?9B}Pb5asJlt1iJl#yp8Q-|~K{#DJJ#icepO zD|qooPb8g~bOR{6$Q2e{VZG#ch`*lg<&`@Rq%Pm^jT)NcLFX;Rrj|P{VwiujGtZWP z)H?%?!7v;JX4NBLZ(jhnIpNvof_?|6@6}^ExWTvruHi&XN?_8x9&<}y;uRN^`r#5w zq%`mgvNxrgHfzHnb0@`2Y0Z7c11Gd~bweks;p}1A)<kIxMN2gLxM;ZER+_mfp9Z#P zbwT0RpSIz@W!8ZcK$M4T|MUJo@%etN|L^3(nvBohy|W*fdyK(PLh0Zaw9coVy>|a! DXYQ&e diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-filled-edit-container.snap.png b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-filled-edit-container.snap.png new file mode 100644 index 0000000000000000000000000000000000000000..d778947cab9d5a6bf752ab51df6322bd8535d3c7 GIT binary patch literal 24127 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 literal 0 HcmV?d00001 diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-filled-view-container.snap.png b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-filled-view-container.snap.png new file mode 100644 index 0000000000000000000000000000000000000000..41c66d6bb7d065a84a8601e5a70d1a3c815cf6ca GIT binary patch literal 11951 zcmeHtg;QHy_byrpRv<{R777H4Yboxoh2jL4;;um&+@ZKbDN-yDoZ#-z;#!J36f4jc zxas%y{eJ(zo!`uzxifo`%$#%f*;#Atz1Di3CrU$29v_Di2L%NMUr|9u69okgg#7&u z`w{X}oTM-Z1%(zxQAYBOkMVIXmOqK?ufb<RGIY^kA~<d}b7VWK0+9l?YP3Rf`5QWk zJ*l_Rm+_`3l6K5W+A@TMuSqn{uC(u?7hXKqR=E}L6#UUf?LFMk^>x)*?3^fRbushw z=;Zg07reea4cNGVM3lc{;hn@&93WLJTDAll!#~3mTO;GU94#vGKcfa86$iMChe;*% z?+p@WJ&<=83jd6*ml(_{4!k4|*#Dh03~>7I!?dW&gausiag7?fzq3oAk$~E;{yzK; z!$JC8Pv1-F?(qMv2>J4-|My;woyqkD9KGEa1x!jyv%def#`fQJyjP@k{@Ru#9u)XO zucm;6!|0V|E?yLuV8G9MYGE%G91`ZDV3{ViiDV{Ad`sE?KK6qFg<d94Bs1y#dk)zc zGSbLYQ{~S+TQg-^Zs%Ky+Nzih>?91`1lW^I{}=)Koj&o!{{Djk+`K=Sj7bszWG)WD zdR$JyBuA=sV`dqn_^&OcWKq(R`J&?D@XLD&LZj$Q7^Gztzn5ee_;n}$_ofM9=-8M? zRMZP|b9FR!NkDRM#N)$$TueX5Jo$eeMEDwmhtZ@%ghkC9?L=FnXR8Z}elYN&$8`SP z*@etszeZA{#hWhR0kNsx0t_-pSV)>#aO%H^B>dO0c^Evr(<+1x1z|l58gBn{;Tzi@ z7ryvkbK(DL{7B4TCyV%=D|~J9m3gqax3rGF6;P6sE4i?_5xmch>dXnLHS7I#zF(T} z)2_MRBfAwwZY+Yu4mf6<nx3}0*m=Ay{?G#EXxU0~U^Ri8w;Zi?nk+TgpX$<o^>>et zH#4?fsCjR)d-kmhfFYy<HTm+?Ch*51fqv(`sO^`+Vk3W7R|uby;re~f-NmFhkJr)L zmeY=3+v^d}xR1Bo&9m3drTrNQ&w4O6=uO11i6gMj8YFV`g8W695;wp4?m8|upP{=S z3@0;LU7vt8Yqvbzwo`Sp^85Sx6dvx;(aV0e<<$lJ@={dsS1i|t7(A>^*KdDJXdH>9 zm}Y!YmXN~Y*<Kfb{^&9}SJ+$0?trIrX{FJTbvVECUF<XQFVz=5E)y71qB?mnpPTP= z`(Hk`BI91Y{7qSw`^Sk*qOvmOu~2cyk8I7tzJ^^mqdw>Hea<?~`Q@~Bk?W?-_U*1= zcF1kcf_3u(;WL0tCD$2)f=<wRX65~**5bpU<n7@W;MQ&};gMEm(!3J<Ik)CUt#XlF z7>?%E$+?<;O-@m%u#A#Z`f2Bbxb4@rvJQo%Vcto;vE$KgySu@7n&kcIQl;$??IM-T zhYb5F!zQ_KJjFD{<dPA`rd2t!qzZaMqwNn*S=Fm<@2=Ob#Hy*tu3N!jQNP^f%!pS4 zChLwRX`hDy*cfilxA{X8sKq(%cE^(W^8-QhSN1*Wn)M1qWttz_@cbJ;^clNA1sO#} zd$Chi*xoa!h`8chSq^e`ue4H0i|UYoS_E`p{8||~R1<AVC&ZD=W@z53_rD(yj&dBv zpy~O_EB(agkKq!&9ZFWdWom7=q?Jszwk_mANx59mVXyHl?ep8+{9#OTBj)XqR4t$n zc=HEbsnx4$Hr7y@Vk^CA&6w`tIta3zWe<DZd2?~zh5i_4L%&2dn?)4zBdONvbZBa| zh#{iYYADf6KQM4>D1WLWKsYKUX4JfYf8k74(DR^3B~N6$&$plw+-jhyscE@2^jfn( z0=1hvj*>s^VAejP^K|XuBej^n9!KjD&s~6|qJlyq!t*d@dqh#~>2s}8wfD}Qclmt- z#+HGPc{>FJa+u8AHu?aW!rt-zKNfAyHxqspdF)RuT}Kq@RAJb3evW@CN?dHxi8^B0 z?yy)ZOF$#^w(=Ec$HHq^D=g(=VJ+~N>drVsYxA8L|6TjV#5rifv^%(Ij2jt@qKAO1 z();SpdxM?Hd>bR5hu_H{fi?d+ifSIKkqo~o#Lvrp)(V{}zLT0@OkCUFUA<E!s=|x# zO)bYYquZZ{qLicVWqh{hId94@-=w`XTUmpgg4+k2T7I987Wv=eSR0vJScrCjrMPvP zon5Nevx0t~wIalo6x#hJhqTnSU(V<7*%l(W1cs1nVCj$UA3^T?w>Q_v%WJHY5)@lA zX==Gb3TGSrlWv)T=fhqU{0IE|Q^gfumBlG_)s#jYfcEF>5!v-7SYz(k0y80x#Zrn_ zgYJFxPJ3H^ww)xd1wBZgG;b1Eh}Lh2tq4cC(g-+JeoYs>S`Z$uc0KhxUeX_|X!AHs z+Y_-G%Xs}W*1uv<JrS8mtbHeblY*dPYpvd=e0NlQ%=+Lmi}5>Q(=OVU-8{(Fs`q@R zcXysBq$sjOdNjRrv^n+r%14RIjg@(m{1@&!V`)`}0;8Orzso1*a-Zt^wJF~AMuG^r zdoYs8*%)2cyOcDYhp<a4Knp~n6*gv?h48$tV)Ri(CyYTqASnB#V+s`gmvR`6_vmJV zINe~vvuQ=U)Nk7Yb~8^(UzKUIsxg&roL{d6DaTQ|3f=CeTaBg-Pl`Vj0q;EmxQ$@R z(vG~YEggGYxBRwK3mExx)ob6_jK;G>Z9s5ti-F9w<K^oRKF6iL-!x}5*e<dZd~XZ< zZ@0<M=L~Vjl^Y~O{3e9X3YXv(5z!U1Aib+-R#=LmQ_Czi7B&!LWP>t6^hfB5-y!=? zb3^;!;4f;}RO<cUjBmv&bvA{+zxO_tQ=p5tnuq1q`rq=Dfm>nG@dPE5*o|cDS;KJx zUq5k9JRx<TjYH9cn}<brJjoPvPeWD;3z4buG{Vf2T1z_vLQt|~$K}R-+-qgt%;%rT zIjyfZ?C&yCmKz<b?%K7>b*ijk5oba^-`{oAuW`A`>44{r4W}Z<qDfiH_Lk^abvrDK zH};+l07cR{O<30aFI3aGQBQS^DLA~>0un^d`jSU>AXcq%FvNk~-L1m3`Ze*9UU6|T z00e2KIGC;A48tK!nyt{KAi6pq4cUG#|CHY%ChG8NzBF@~@)%uR9q7%4=Wjb&Z>!}| z=s&&Eq-*3F29$R@r@g(|OtAV=$SAibozcba`}1;5&hi^N>T}FZRLL+KAm-i4QbV#> zeh?*HctXVWS3{eV2UK+99}Dt*%QG;ravlU$hHy8`@=IekWceuHX}vL)#sN0#6H{76 z0Nm!mq$-tZzub4Kxg-OiB5N*=syVNWVI4my>Aju>-n?#kxX0RRJ?%&p3rM<@E@<qi zW_i-x=3!g@)Vo0?gKN>BZ+!)s#<G5qcVdmka3B_HRvM2#vay8(cwhYf&VY>1A_-{Q z&Zyt@^{=A)#C23d#WsBUN+#SSKZQk2#yDo=@fn+#(9C2OoNgo~mE*gIML@k(uSPBC zCc{qQc%^)SmdRs<u*D4pbiCV{G%4bY86BVe&KSaJdKi|TBcj)dXd2q+SH2o;EfCy^ z6R^%0x(mZ4&n6FrWOA9cbuegMZMTi&pxdJwb9{S8?mU`SmBXo04>a$&3yVUpb${&t zz5CJrEUTi4W<0GzB<tm`a4M%u%9R5Sl@gWAqB{dij5qsJ1TpA1f#KCcdLgEQwIGB& z?8OHY2lw2;@vPLs!YxmWv+kIOSzUST;N&x_2roU%<QE;6Tv66cDsJWC^z^;hJeC@I z7VCr-;JC=(h9SE{_NJ8$YMc6x+7)Cw6^q)<k}37J7i~B_;R9J{>jtH2!9=_neU;{` zHZ@~iJsV{h!a9VXhK##ILpKOtG{m96Gj1s@i1E;kgi9M~=_?hJ<wok`;0zy3gR8;9 z#<H%eykt7DfWD)>FPpJpSeW|Uia(9t?J1~&odO9)<?Rm4Gx>3$wTwAFXRfHDL+9J4 zuVlknf6Y}HQo=(L@KcQ2{W83cR&HB}-kaJ4$XuSR&SOt$U?)rDImblUy9h7g#W030 zNk0}nI-*4TYBQNvOjb|fHmxcksK0S)>f24>EvWV`4EvNVrg$(rSv=MUjfhU=e0TD& zK4oQ%k$bDK4}lB&!ii`YMvvWQ5HMF00RY>Ez6Vo^`c-BLjas$Poz+-8?~ftil)(nJ zVT0@5&_1=B`D6|Hf?Qd4`lF|Tkva0u0>)<Mn_Z<hrMl*GO3h{OEqWp{DRz4)QR$lP z=XZC)E~eGRhwCVlLxi3}j$Av#0HC+m=|q*!Tn^a`QV!tYlO7Bho1TO*#L**d^K90r z-A_9zL0yCUI@rC|d)%lC;8IaQU`ezdhRaHffgT!V-yNEtNSObTYq%_MUdSwLsmVEC zF+02~I0n~tW3FhW-q!uD*6~CHGkGnEF-8THT#+FoH-Jnal_pgIaeLkKtd%6N9-LIX zr@w3Syx-%|2(8dBAB9GVTVU$;er<<b-7i#=MYRkZ@Od5_?quELBuhML_Tp%5^f+00 zjm*dg)b<q*yw+`W^Cm$d?mSnP-65n^Hj^hj{x=nFr|s7`7;-9Om{aYL1U853qgy4@ zFxSWTjRe8l)?W(4cF<7@8+59&b~05JGi_I#2^9!O2*>7{Bcw7g&ixI0Ec+sLX9L_% z^{)vrmLKk~#d%MBb_-UKd5b%^UufQM!bm;b(|zXCd$lwoarC;7r-0CQF6iN-9*knQ z(|bDiHJ?_KZ*%(DM+$4I%A?q}jqyxo<^lb^DThyiDB|909qr@fIxTM7aa_;@auY2W zBL^(}mgRJ{H39DS?U0bu(7$EF3NRXTrFk{utjD>rX}>qWRrixre^8Gn9G}XpQh@LU zE*Try&E{E;jBpGY`#}@eSqjHWwaK&FjhIy-REQeA7b(c+y>6MNqTO7qF7E5nIs__i zZx}fle`V$HeQ`RX;%&vgh}Y@Y)0v;sD|&RaDjI9a>N<SMArR<7$f<~MjH1_7uV`4h zz}OY?w08^z4=1_IrzBplnK&D%(+GKZG?K#E&dwTg#xq{I1{*Z*RM;PZOB*mdEA=mI zEx^5^rmMnS`4ASD+^&H2vKy-32Vy#eGc}Z5N$<qE@iFV}`pHcy7YztL>{fFDLQ!Fx z%7))MD1|&C8y&+DH%?P}2~I3;;>%jiYi%p|GyRB3U55D`^w#u@OsjeVLg3@+ocZ#N zdl5|63>$+8kNqN!k7oxk(GT1j?N|3c%h-tPuaHT->u>^9vNk3*R$Jy!McT=xCN@Sr z2XYQ0QzLI%+x?xq@a70c!bu#l#7cLf{6_*M_Ggyu=RY`hCh5~97I1;1VHYDkZz$^g z`vob=)Li+CA?=}wtsck?+L!*VSlq0K!DgvyRuMxKSw$`0UTwv2b+@=kk7Zqj!)3bS z>a0esui%s3h$s=szAOepelEuZ>f30zQ$g3tD#Fu{p7y&YypsPO;TB8^daFE|Hm;J> z6G=p0=Yd*?DTO9>td@(oVNl_6ZTl`IO#g(8Q*E4QSdaUqV2hg@F-WhoB<P`IXOt2R z`|K&Q?28z<odHB~K_Pf&!ayc9Zn(&OifJf-4H(H8ycbgO$;WjN4N@~k`a<(22M1`f z>~=8oV$-gtdA|A~_P#wn`ou{M)l9WSQL%kCK%Ws;sqYMr>SdoTk;HFebL}?$k8N@2 zY>*oi2@&8%Y#_3fxj;2-*{caxx<28gn9pgDQ)CVWENz}%9IIx;(g+V=q|om?A~W`H zt2AuVS^X3;Nept~H0b_J2J}w5U#Sy$>4qKJzy+^l#P%9jC6E8*LjQH^<7@g@Wu_Dz zpn4C62`wSDj~U`o<%aCi2Wd$1@Yw)WoeD636qNCs%hhA=v*KiK+3u{SfMkrrshxlB zWX2gH%lz4EXwv#vUKcJ_pp_m~xlX`RUu}U8F=&VYn!oA>&?moa?8*`+1I~n{Z<K4F zB6n3i+oIAC@jCzw?kt_e7xEZ@Pp|fl9^uK_Ft$m$3-|irOFc6*a+;1tn+X-y+MvTK z-+@#hP7EN}mdHZ0Ow-DO-JRWKkTzpU>pN>au61Ri6`$`pLqi_Bz@j!Ywuj?dM;zc) zBO5498LvBUE?{#XR6oE<7!y_P*zLJ4wSe%90W9PSx`z{;V2jAYLFSfq`ua9HhTQQr zBYJnTT`X*7bvBdZ#1_FeXcS7w{%P*8Zm!jvGoVC0n~fGbrTLY-9PJ#$_wvhNgDP&V zi1-I$(7o7NUMN5e#S|{h#VUqU8N7=rS#?4N#3t{X(35O`^)=V3k+azS_h=Ml_H{f0 zqFjz!zZ&p(h(;Ie8E;4^nvQF47Cd9he6D6kY}Dp^C`i*F^jMVE!ejfj<-SBtXra&O z2;3MggYCUOR6?rl#*}g}#L=q%hDD{493>d-HF+iDoKoG_pHK$^nbTg8)jFFbUPe-r z?Tkw}$;-l%Dn4DLf9Iv-Q2nQ{dHR%}m9;=|fd1R-nDHn={vXT>4_8`}hYO+0`^efu z1NE#0PC%Dir=A~Ja@T<aY<o)-gF266WSL?4UO(8O5P3S=_liEzC72R>5C8m8p0n5S z?&sA(jER{!4XxjIS-{B++G9)20lnu0m`>07?!GCDgGwT!<MI7g89@`+6g+uKTozVQ zl#vxTH$HXcr^sZr-31j_DX>r)p-wIfL8~BvVO~>h@QCPp1rvep>-(d*gIS4<j4Win z<EdrwAzSC65y?3!%~(1=B68Fws-H2zcvLG9F1xgwxVxs*M32KAIErrl+^g4Ho(ed{ z3VN^iFuLs*r)LShCkh>-ZI=DU(J<j1G-2u)6;t7aW<}HsUEsam?VI-b^~)bpmthK3 zu*Bb3!rtnS=oyHkQq}12d~CRDj=5R^Lrm$JwZrlHA^ZzM_cJJ411yaKWw=$9)+5K$ z4y={*9c8MJ7Bz6PU@D`X>b(Rns%R<`-=~hb%*uhBgW>&a(b<6TAN)E^fz9bPq#*3J zP%5lqk3({Q9G@yLbwS@|6`lN&UnsC!k93FM=IZgAI2FTrWxz4lq0a!>pDtd98MHol zoF&d3x7EIasqBV2<6EkAmGT=2TyP_q43|~#v#&x4)h{y!)(5&rsW9h=G4|>9NKk#} zvOviAj31PVV#~}Ax?l9yrwgRlFhhII=YONYyKn9$FZnBRAadamHff&#?KhZq#oR=C zOLY&%)w*m#wHbEOrd_7=cTOWI&%?Qw!1z{1wA~v6aSUv<rvi>kmP<JA!})nXbIL1i z^U)llA(6{^tZV$K7}wK+E<0$oD{K4gvJV!ojthBD=;J2x75nBq`h}PJ;2_5w+x<w@ zU`7JC8ew{#xiQk)d)dfs3THQb$=i)MfoZY!lR|fDB2AC8JMV?e^~}vx6+Y!+ZbH$q z#sVT^v!o*kG8ZT<{mk4NdHh6Nn}UakAkecWoLAj(n@+_SmpR=Vji%lH^aL)|etv2q z;N|??<!mlVgp1M$Rc*kp!JS2D7+?}Kr}%R_J^xC~XtQ37?rq@x@1`*!kWXV9{L(8Z zFD73)Wkw;Bu63q1ibrv}pkmE0JB8*Vi*JfQF5I5YKn*h(b&f1D@wd&Kf9*un^ig+- zT??M<3qdyha@*w+h8cjY=Wath(%orPHnTryrj$bZ{a+|f_KjX>vsy#;zK4xkjf{8) zl&O{2QaUw2PH@VEnM!dt4~b2(VLOvK6K9tY*3Z*!&KR2VT)Pqy->e(~nG%OmLX0vW ztlXLox+l3@^<beAhu0o?_B9_<jVV>`YR10xJ4A`jgmI2(G|@aKZfkFGZo8t(fV-Vy z^*te5b!&R^?UkNNaC>zf6_&{o+BIFJ3+#kn-P3&RJKOV7=2EmT`!DdtOD}?NMbm)q zJV!Q;js*cbl#98zy1dbz)h%KPCxrvg(pqMX)vplI#Mnuyc2^k{Qox{y&ynnJ^mOv{ z-9fEh`aN5NdF_mIy$Hz<2f(o5PKJv;eDd!?$2aB7Z-&-;ubT8;^xQYQa&Y)T`|{2x zOd$J=NmJwSY{%lF?unzU-of}oG)E?XJ2j_fcYlOSBG$TWV#dOFWfYfu`r);;4#7E( zS<p|DpgXIMMk;{;ma=#~0LHDkr-46(K}<ndfczq=beB@G_hSQ4-e=;P!wzX!y9YUA z8l<SoI!iLA0B>5BXnF0yG~R_-#ZAUz4%N(_8@{1@fcF%%eE%zMO_bBK->Nn+gTCrV zNm?$N(yw-PU1sn~X{o+Dit_AoJ8cPuWYo>`P=3@iA{Z{+V*A}LB0z5s(2cnH8LEs8 zXRS&5cGv{D?yF0q6Q4icypobeIgx0NNxTbA1;ecg`~BW7_?7pc^z>yjOPYM0DY*8c z%)hEZfCJ!Z->6MOhyB5i5BNRzVQ-gI{A|<aBPb`6;u#6~^4RssbK4ZEVs#da7Y($; z?iIeyhlZfpp$5E34}4NNm_j2uwLfyJR);v)lb_{VZQR0Foi(ny_HqYDHBBa`s_-j& za_D_JZDL1wX+|xWpT8}m+G32WU<usm6}^^QYs%`?%Xmemjq7ooq=5~zS54M<)b;bF z1C%Drlb+BXg&b!N;Q|g6uA!uVV9O{HzxT-|avw^Q=z2OA>gw4lBNjGvLidA6iVd$; zhEQ7ObyDjlz5owe;d_=GaGUB$V~_iBM<g*uM#pr$EEzlzY#2ZU(z>FVw!czp(Y^*E zZl^|z1{{mG0~$sqv<~rtEo=^gW0)a<531#MDs!_3QVvMmK=aM2A|XZRQ(>01GKh$A z7{9rS%Sr&{=9`^!R+WMiUOy9Yqw<uMs?+Kg7MStOF>Bt;mEXbJ@P+xT7cbR=bd+@M zjDHyMzHqZX47uoDLs&21BnM;C#E_@HO$x}W5NQ5D2>JwhIHK4gE>$nhpLE@7_=jBy zrcI<O#2AN%4&w`*?YaX3+uvBco7-z7WF`cydVJ`&ro<N3A&;t8%CRPS-niW)c2`_r zO(Mo)RY*O{h&y*!64}oW!o~bTQpHzh3N?_REN@1(4)cfddlPC8PJDh6yd_R^VO)o{ z8s{^-`j(&2H@3b4HjAna-XQYziA1ndB=eQb3bD~Ww>2^_0v_OKf0&wArx<R^Da|;^ z_oVqrJ+}{F{2R1YMrx@(?F_bA6=gl1N*=EmC)N+yPA8hKu5g4qj7m(#PxQX%^0SJ} z{CTQ@!ig?!R4dnh$w_9QAGo4&#)^P)&uQOTY+A!<K2990j0>j6GPgCe7<^Q<TNhfz z1rIjKS2|#V1g@M4^lIp-D47^a&EfkB7TIUstP<t@LlP2lL03o^VLc43mDHo==b0RS zzMG8k4fYzKLru)6gN@rZ3-a5mJrhms`5wix>sW^7s(_>qI?2jd_*`j(x-o!C&a@lJ zfL{2?%&*hT1rqgb7EhvpFSo_-^urJOxX3{9{j*PgQ<fV-ul_KD5|>0ZJdTNvju5Xh zN%sz%RicC?O)ONtjSQ=0^pSBe%qKhnIi9qu8~Y;U(ne_)8rnF<cAdCg$w8wXKQ*)V zPQITu*|Cy(Jg~H;{h9omZqQU17>kaCEO|#=J4JGmzofqp<}I)+`-ejlBy0xP>5r5f zTI6w{y-n4KBAy{<F>?!n@%dlhz$q=<lzw;|bNG6`e_g=<GkB5Q<T(C1<2OvQHj=w6 zNM10rgsK)n*6dq@O&wwqqBqEph&d<4t}-!*X74}x56>AI-t{=*akjP*c%A`J%<M`E zn&w~NlhZx+Vke{;$~oY6q2n|lPP)7qjNukk&DMyL#o6OWG|0l+{aJxJ-vT5>Kc z)_nLo#i^7ngRJgp3CxkOFdU_Wbd!<#<7G>)yGNQ@x*tW?7#-L_Fy*PWIYc|P@V=hy z=Xv7F{w=9h#;e0J;P|#w14DbY<F4yJ^kz4(MrLT`QQVe1_=ETK_1fq%0!zQ<glaxb zpA4v8bzHFVT)zqbyLLuFym#lF&b-L2MB`nvI?mgY3F&!oX{JcSmhbpj_oEO&0B{D{ zplZGUk}Ci3B`Witv(0M3tf&{GPY-mKu(1bY72g`zX&R>34EssB;Mh~Q0x+GHa;OaF zSFH&G;OI=Pc0MqH>30Ir=uwHW(a~V6Erl%^3viz$uGJT-p09a@y|yz-@TD_IM)JkK z?R+3;4qagvMAdy#2<$b;&^yVYM@$X{fDykO`2i_(m5j@B?a$#^24T5j@GgTQvZIux zkMiDSTBUi5gt7gSD3An^I}cJY>|Wp3c%DR}Tdl>!)`j9_;pT>Co0dY?Jw~{b9;eH$ zNMS{(RN_soVa*+c^qw8h-VE(`+U!<pSQ(G?ZL~Yj^V|(PrE5QcSsh6Lmqv{Nn->id z8P2`$G~?2iooUGn9gcYB!~@x{x1Cn|W@kzFAA<OM{(t&oyI>d=q9jUup|T<V<n;&& z3W41pEr7C%T9KjhXL%gpq;~|9*~Vl*pT$3?l?V_UVixSQJ(@26ur&*Yy3ZH8d!8wK zNH(AjgeB+YeF&1;?YVu8o=L`OTqK+m2paTUG(B2=U9804g6!zWhsHCw!7m*aXn`P= zky>?J&`_hJ;iyG|D2RQ6&AAQ9at7atBT)_Zbd$i$6iyRKO8y>#7I+CqpVZ~vd**h? z_4_|0lwp%oY8Jn}&?NF(F&Q+1EUd={JdylJRsg$G>+#F|<)+RHCxb?ZG9=@p|KsR( z<cQuq{bcR4#`H<eHliu`c(+tF+YZSQafBHa$^gnrV;-5E;yGiUQ7<%=_+3%*D5u-r zA5>J2rCrQc%%?oUr_ADatV{wwxE}vFURE?RG9ts(7>wt%yJPZ9?zNpj%)!9yFJ8Rh zaob{U(G2M|ieWP2aa<y~RUnt?f%P)q*HcgpvCoco*YvV0-JW|ZI>v2u?w17VLbsG- z|B&B>PzfYFh?lZGwRm?YRwiJnH?*lrt7LpSB{`DDLg7YEAfuShkvq0!Qf0tT`n3N| zXd7p~e=QhEKrtnV--jM8HE0QW9?+f`)>i++K+Y+;w57~yKg_+QTwtxnOGm=q@_9ZR zino%}qjLvI1ITW%;oiUK53z7mv{Wd|+hTlI?jnd}s!~!|k>pWpaa2J0`(?<Fqc2fj zS%B{{x#>o3QM?Pl9;@}9TNb;Gu^pQQY{$h~E6rL<MT(aWp?y9JWjyTc<CmKYa0?c> z1LY4=VU|}1+O^X3eI|jvH6n$|%n%kN&pW+p@=#&U1+IShwBk0(UQ(SgB<y{{f<&Xn zY&$MH_3L(H6{vWva|E%Dot9?cco#Qkz-@6G9c^kP4`V8C-51+TuHIuA$`KgqYS`iy zC%7{IGN|J94=}=Gh8r=`cV>DTqmAI`{vaKp3W34Tq!hk<PqKW54B-iOXe1FR!_-U= zYLAtcj2P}kHz@xK3~Y8LyE-vrW5o6dE5=Bc|AZtx9k)Jl2;kW(I2_5A7|bL@;m<|( z$D~spN+g5g#wWT6>2&_^+3b{^Wu#{DzsvTWJP#NY`lC}Bkc{?4#;GUn@y>WQWsE{H zXi_k2cvjbh9ocE=G_HQ%8M)p2m{6o!ErWmK|N0<r@s^mhI|-`Tm<B6cfPo<io))#8 zdII%5SFfp`7dH}#g*;+}h+wgaecn!iT%Xv7lHjm0H`otWXs=uM_RZg`XK)!~z9~0| z(NUBH>zYDgp^;S!-PAhSNQxx$=i!_xS1XtIVVaLlwm&IIuz`ZK$zf4NE$0;-l7^dx zEWpzrVHov>Uu_HF4?(VG=cPO=;7|V)0X3q3Y(;xa$h5)N--&ozref0i()18e>zr3@ zq>q^O^pCT#><ZW<#Q(sOk+QjIKje;B^_VUhuYJ>M=p7RpLnr9_N#y36G>`LFqf8;s z+WR1=D?9a!BkXX#+M-s9@}&bqw!mSydO%X__WC1m_hiE#G})-A%mvwsdE)@*V~L&b z&Oj<4^*m<fG`2MFulnqmD~-b^b5#{Y23jR5lVrf9PVZCW+x^lzyVMN(zWJH5YKX`n zv29t|XkBxZ-_?QHPxOlL>btd$fL4jm-R5Y@>PXtH_)Xd2_+1?mSU<4N`r!CeF)yI; zLv(NYtm+S+t{#jYp4OMoME%~ZD$T|Tm&?U9v&}>8ossN#t$F_l8?0Po6;c`TXVV@j z&=!V9VJ<`qc6XYI?h8QjDi`6sHc&K4O<4;@W2dRldWsrSG+O2jv&7HPu?UjnR~xg# z-<BZ}R#LX{V*R=uyQK)ivWg(*56RZRO*tetR2z84nY3|Wm&MO2YkdGxzir=Y=*15K zthjAAw}y$V7JLmIR_644joY7pu#@RKXS+F=z<Wk)Ki76xkJ9`!^i}N4O#ea0;s>W_ z;sNcm1`az6`_GsAHuDkgbSjBwwQpbBr5#=!&JX6ans%G#|9WWJIg!SDX?Iy^V055> zq^w*;>C`ZvV|sSS{V3;HZS&ZVX5$u}>knwi*qX5(mBmtyYEsF|<keF7P4FK@mkc^I zk_7bRRkH`-<9$k!7Q0^r`gi*Ti`B~O55*<!_PXZHcG0#XGDvO3NJz1=GO{Oiix;hA z_-O*G(hfEqGicZ-(yn+3l9+AiVn6S}bu12gfPyA4ik;WKQUc-XAv`wr1_u|ED}G_q zcc@LMpzl$@(zk62w$<0p%%Q{0t`_`h8;+X<@Ri4NnD7$-20rM`H5#j8$fK(nA&u$g zRN7A(lmbr7vTq0<(X03$i^H&D0Bk1Mc{GCOq!zOhQ2S4whmCR=$7`KyeLg-cNC0l` zhL=W|(zxk}9+3Es6XUr~hF-2WHp$?#ARKeWO7Z&~@}WPV!u&K3|A1vJK&=4>q+&VF zre8bLXIbE|ijduu_p|etPeNFQzx{T|<@*haHGXRPS~6C#SSeL?AkH3|{ZT5cwGN~6 zr#ZLeiky^G+rX##H{M!WHmOtsfd#>3)s4kX16FTb@2R0}j!X4PBdHY`1#hl-g6>7e z{WFWwBDW`eC@~fqo!8`?+Rg@$x~=Lv`D|0iEbdKDUxjqDiLo&kYf5|yo`pWh(dw_< zH?y<Pz`u@`(lwAJQdHOSbm_wc5|hlArk3<EMxw#c6&;cPM08J*L?15D{*5VUFiV#J z8vNvaP=B<=w$9+>ckj@|P@Z^(QepJ6#>mm;+a1Fhza&$0_POdH^-Y1VY2+qHpUgkP z>ToQJO}lwjVr8RA-rru3Tfz0%zT6Rm)N)KWp3^=tH;>NXvuM`&#vwHqgG6$Tp0H;1 zRL#IGn5s_EEaD`ExuTqx>c8|2(pKz3R&C5rRs<>CH8tcCo^Fz8!S3YeM!KMPsw@uv z{%uDqEz^DF)UUorMA0ZJW?JxguEcP{sB<=zA6%(xQxkL1?bRpq6er#p=z)`xvG(uf zAD(>BD~a93uOiHeULeFc0)c+;mCfYUy1BV+>!C4l!?P(K%C#%XBwC025Gwg%s$kzU zP*+CSe-d;UQl$lKeavjGYJCWMueZE;xjzk}A#-}vEV43sxv#~aZLOBJ{TZA9O|{w{ z@mBF;$6+JVGwgH2x@A=%bVfZy!yrjBXqvL&7PL5yAI}!ZM$xe(F{4Q)H{LUyif&I8 zbhjQwc-pIGal}h6Q$t&Z59Dk=y7J+pk%3-um);wuN`LEazz|UTQYbr~V8&jiT~UG* z2i)D(I)tc|BH7(D=8HHk$t>prBswcwIh-R{H=}eOMwA=bUZw{xjAGhgsr3r-TNERv zg=!ENZ~+*{36YqNCx*=iuE;{LA{qBf*vSXe0~rwvHqeRwt53krULm%7mBLqpN(pi_ zLmE)hA;a&mPRGZd52E0Qq4)FyQ^4|<(nmD&?;P#9MS^~fZT=||X;%Om8KJ+hYw!N5 z7YQYiV$J7oxfV(PiU6^_ky^fWv0w-Bzv@jHLR6e&Y)Tcc|5zG%u=eDlAglBQ+GX{> z>Ovx!cRdWjkCMUvl&`c<7bXca;uHFzAIblSN+mFe3b>*Qx-E78N(BG6mf>O?HryS8 zg^c;<>l2B`fAyEh{s9#!nN&fF38bUR*eP8cee09PBqqiDZ)&zibrq<H-s1m{xREvq zdux=57zhNqqz4m&nACC-FE8=heZIb<>H_{#cq9MS^AHJZYeaO?bEJCo{+%#M3fJ(^ zP$Du_A|bwTo#b~B@0gPQ8si4|4*k&?BODHw^aL@R1+(T0c`B*piIg3DD!7jk%Po)# zVfxo_dc;F-9*>BF8X2DX3reOGgaKdyKQC<s1AjLYF-7VP_ZB46hKH8^JGBp?3r4Fo zQk{q(V)VUu^lXWU*bL48Ew#DY)YQ~iKCQW3uLa+N(!Y97bF`Y+Z|dE_n8hyZJ#_R$ zJZ8kTWbo&IFVjLlF(UogAUzrXcRBCwkV4=EXO)G2Z;3QO2Fq*V%cEESiiZ<W!lLd5 nLfN1HmAd}_DgIxy#lvGXU@&93r5PSlh>oHtt0n`JG7bJeO<q8I literal 0 HcmV?d00001 diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainer.tsx index 72ab1170946..9af697c1542 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainer.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainer.tsx @@ -31,10 +31,11 @@ export type Props = { * An alternative toolbar to be shown in the EditContainer. */ toolbar?: React.ReactNode + /** * The variant of the toolbar. */ - toolbarVariant?: 'minimumOneItem' + toolbarVariant?: ArrayItemAreaProps['toolbarVariant'] } export type AllProps = Props & FlexContainerProps & ArrayItemAreaProps @@ -59,13 +60,15 @@ export default function EditContainer(props: AllProps) { toolbar={ hasToolbar ? null - : toolbarElement ?? ( + : toolbarElement ?? + (toolbarVariant !== 'custom' && ( <Toolbar> <DoneButton /> <CancelButton /> </Toolbar> - ) + )) } + toolbarVariant={toolbarVariant} {...rest} > {children} @@ -85,6 +88,7 @@ export function EditContainerWithoutToolbar( title, titleWhenNew, toolbar, + toolbarVariant, ...restProps } = props || {} @@ -103,6 +107,7 @@ export function EditContainerWithoutToolbar( mode="edit" className={classnames('dnb-forms-section-edit-block', className)} ariaLabel={convertJsxToString(itemTitle)} + toolbarVariant={toolbarVariant} {...restProps} > {itemTitle && <Lead size="basis">{itemTitle}</Lead>} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainerDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainerDocs.ts index 5e7981dc3f1..a2f05d5e84f 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainerDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainerDocs.ts @@ -12,7 +12,7 @@ export const EditContainerProperties: PropertiesTableProps = { status: 'optional', }, variant: { - doc: 'Defines the variant of the container. Can be `outline` or `basic`. Defaults to `outline`.', + doc: 'Defines the variant of the container. Can be `outline`, `filled` or `basic`. Defaults to `outline`.', type: 'string', status: 'optional', }, @@ -22,7 +22,7 @@ export const EditContainerProperties: PropertiesTableProps = { status: 'optional', }, toolbarVariant: { - doc: 'Use variants to render the toolbar differently. Currently there is only the `minimumOneItem` variant. See the info section for more info.', + doc: 'Use variants to render the toolbar differently. Currently there are the `minimumOneItem` and `custom` variants. See the info section for more info.', type: 'string', status: 'optional', }, diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditAndViewContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditAndViewContainer.test.tsx index 78d1a364a09..a0b29c5d996 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditAndViewContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditAndViewContainer.test.tsx @@ -1,8 +1,9 @@ import React from 'react' import { render, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Flex } from '../../../../../components' import IterateItemContext from '../../IterateItemContext' import { Field, Form, Iterate, Value } from '../../..' -import userEvent from '@testing-library/user-event' import nbNO from '../../../constants/locales/nb-NO' const tr = { @@ -738,59 +739,211 @@ describe('EditContainer and ViewContainer', () => { } }) - it('should render toolbarVariant="minimumOneItem" with correct buttons', () => { - const { rerender } = render( - <Iterate.Array value={['foo']}> - <Iterate.ViewContainer toolbarVariant="minimumOneItem"> - View Content - </Iterate.ViewContainer> - <Iterate.EditContainer toolbarVariant="minimumOneItem"> - Edit Content - </Iterate.EditContainer> - </Iterate.Array> - ) + describe('toolbarVariant', () => { + it('should render toolbarVariant="minimumOneItem" with correct buttons', () => { + const { rerender } = render( + <Iterate.Array value={['foo']}> + <Iterate.ViewContainer toolbarVariant="minimumOneItem"> + View Content + </Iterate.ViewContainer> + <Iterate.EditContainer toolbarVariant="minimumOneItem"> + Edit Content + </Iterate.EditContainer> + </Iterate.Array> + ) - { - const elements = document.querySelectorAll( - '.dnb-forms-iterate__element' + { + const elements = document.querySelectorAll( + '.dnb-forms-iterate__element' + ) + expect(elements).toHaveLength(1) + + const [firstElement] = Array.from(elements) + const [viewBlock, editBlock] = Array.from( + firstElement.querySelectorAll('.dnb-forms-section-block') + ) + expect(editBlock.querySelectorAll('button')).toHaveLength(0) + expect(viewBlock.querySelectorAll('button')).toHaveLength(1) + expect(viewBlock.querySelectorAll('button')[0]).toHaveTextContent( + tr.viewContainer.editButton + ) + } + + rerender( + <Iterate.Array value={['foo', 'bar']}> + <Iterate.ViewContainer toolbarVariant="minimumOneItem"> + View Content + </Iterate.ViewContainer> + <Iterate.EditContainer toolbarVariant="minimumOneItem"> + Edit Content + </Iterate.EditContainer> + </Iterate.Array> + ) + + { + const elements = document.querySelectorAll( + '.dnb-forms-iterate__element' + ) + expect(elements).toHaveLength(2) + + const [firstElement] = Array.from(elements) + const [viewBlock, editBlock] = Array.from( + firstElement.querySelectorAll('.dnb-forms-section-block') + ) + expect(editBlock.querySelectorAll('button')).toHaveLength(2) + expect(viewBlock.querySelectorAll('button')).toHaveLength(2) + } + }) + + it('should render toolbarVariant="custom" without a toolbar', () => { + render( + <Iterate.Array value={['foo']}> + <Iterate.ViewContainer toolbarVariant="custom"> + View Content + </Iterate.ViewContainer> + <Iterate.EditContainer toolbarVariant="custom"> + Edit Content + </Iterate.EditContainer> + </Iterate.Array> ) - expect(elements).toHaveLength(1) - const [firstElement] = Array.from(elements) const [viewBlock, editBlock] = Array.from( - firstElement.querySelectorAll('.dnb-forms-section-block') + document + .querySelector('.dnb-forms-iterate__element') + .querySelectorAll('.dnb-forms-section-block') ) expect(editBlock.querySelectorAll('button')).toHaveLength(0) + expect(viewBlock.querySelectorAll('button')).toHaveLength(0) + }) + + it('should render toolbarVariant="custom" should default toolbar', () => { + render( + <Iterate.Array value={['foo']}> + <Iterate.ViewContainer toolbarVariant="custom"> + View Content + </Iterate.ViewContainer> + <Iterate.EditContainer toolbarVariant="custom"> + Edit Content + </Iterate.EditContainer> + </Iterate.Array> + ) + + const [viewBlock, editBlock] = Array.from( + document + .querySelector('.dnb-forms-iterate__element') + .querySelectorAll('.dnb-forms-section-block') + ) + expect(editBlock.querySelectorAll('button')).toHaveLength(0) + expect(viewBlock.querySelectorAll('button')).toHaveLength(0) + }) + + it('should render toolbarVariant="custom" with correct spacing', () => { + render( + <Iterate.Array value={['foo']}> + <Iterate.ViewContainer toolbarVariant="custom"> + View Content + <Flex.Horizontal> + <Iterate.Toolbar> + <Iterate.ViewContainer.EditButton /> + </Iterate.Toolbar> + </Flex.Horizontal> + </Iterate.ViewContainer> + <Iterate.EditContainer toolbarVariant="custom"> + Edit Content + <Flex.Horizontal> + <Iterate.Toolbar> + <Iterate.EditContainer.DoneButton /> + </Iterate.Toolbar> + </Flex.Horizontal> + </Iterate.EditContainer> + </Iterate.Array> + ) + + const [viewBlock, editBlock] = Array.from( + document + .querySelector('.dnb-forms-iterate__element') + .querySelectorAll('.dnb-forms-section-block') + ) + + expect(editBlock.querySelectorAll('button')).toHaveLength(1) expect(viewBlock.querySelectorAll('button')).toHaveLength(1) - expect(viewBlock.querySelectorAll('button')[0]).toHaveTextContent( - tr.viewContainer.editButton + + const viewToolbars = viewBlock.querySelectorAll( + '.dnb-forms-iterate-toolbar' + ) + const editToolbars = editBlock.querySelectorAll( + '.dnb-forms-iterate-toolbar' ) - } - rerender( - <Iterate.Array value={['foo', 'bar']}> - <Iterate.ViewContainer toolbarVariant="minimumOneItem"> - View Content - </Iterate.ViewContainer> - <Iterate.EditContainer toolbarVariant="minimumOneItem"> - Edit Content - </Iterate.EditContainer> - </Iterate.Array> - ) + expect(viewToolbars).toHaveLength(1) + expect(editToolbars).toHaveLength(1) - { - const elements = document.querySelectorAll( - '.dnb-forms-iterate__element' + const viewToolbar = viewToolbars[0] + expect(viewToolbar).toHaveClass('dnb-space__top--zero') + expect(viewToolbar).toHaveClass('dnb-space__right--small') + expect(viewToolbar).toHaveClass('dnb-space__left--zero') + + const editToolbar = editToolbars[0] + expect(editToolbar).toHaveClass('dnb-space__top--zero') + expect(editToolbar).toHaveClass('dnb-space__right--small') + expect(editToolbar).toHaveClass('dnb-space__left--zero') + + const viewSpace = viewToolbars[0].querySelector('.dnb-space') + expect(viewSpace).toHaveClass('dnb-space__top--zero') + expect(viewSpace).toHaveClass('dnb-flex-container--row-gap-small') + + const editSpace = editToolbars[0].querySelector('.dnb-space') + expect(editSpace).toHaveClass('dnb-space__top--zero') + expect(editSpace).toHaveClass('dnb-flex-container--row-gap-small') + }) + + it('should render toolbarVariant="custom" without a hr', () => { + render( + <Iterate.Array value={['foo']}> + <Iterate.ViewContainer toolbarVariant="custom"> + View Content + <Flex.Horizontal> + <Iterate.Toolbar> + <Iterate.ViewContainer.EditButton /> + </Iterate.Toolbar> + </Flex.Horizontal> + </Iterate.ViewContainer> + <Iterate.EditContainer toolbarVariant="custom"> + Edit Content + <Flex.Horizontal> + <Iterate.Toolbar> + <Iterate.EditContainer.DoneButton /> + </Iterate.Toolbar> + </Flex.Horizontal> + </Iterate.EditContainer> + </Iterate.Array> ) - expect(elements).toHaveLength(2) - const [firstElement] = Array.from(elements) const [viewBlock, editBlock] = Array.from( - firstElement.querySelectorAll('.dnb-forms-section-block') + document + .querySelector('.dnb-forms-iterate__element') + .querySelectorAll('.dnb-forms-section-block') ) - expect(editBlock.querySelectorAll('button')).toHaveLength(2) - expect(viewBlock.querySelectorAll('button')).toHaveLength(2) - } + + expect(editBlock.querySelectorAll('button')).toHaveLength(1) + expect(viewBlock.querySelectorAll('button')).toHaveLength(1) + + const viewToolbars = viewBlock.querySelectorAll( + '.dnb-forms-iterate-toolbar' + ) + const editToolbars = editBlock.querySelectorAll( + '.dnb-forms-iterate-toolbar' + ) + + expect(viewToolbars).toHaveLength(1) + expect(editToolbars).toHaveLength(1) + + const viewToolbar = viewToolbars[0] + const editToolbar = editToolbars[0] + + expect(viewToolbar.querySelectorAll('hr')).toHaveLength(0) + expect(editToolbar.querySelectorAll('hr')).toHaveLength(0) + }) }) it('should validate on submit', () => { @@ -989,4 +1142,46 @@ describe('EditContainer and ViewContainer', () => { expect(containerMode[0]).toBe('edit') expect(containerMode[1]).toBe('edit') }) + + it('should set correct class for variant "basic"', () => { + render( + <Iterate.Array path="/" defaultValue={[null]}> + <Iterate.ViewContainer variant="basic"> + View Content + </Iterate.ViewContainer> + <Iterate.EditContainer variant="basic"> + <Field.String itemPath="/" required /> + </Iterate.EditContainer> + </Iterate.Array> + ) + + const [viewBlock, editBlock] = Array.from( + document.querySelectorAll('.dnb-forms-section-block') + ) + expect(viewBlock).toHaveClass('dnb-forms-section-block--variant-basic') + expect(editBlock).toHaveClass('dnb-forms-section-block--variant-basic') + }) + + it('should set correct class for variant "filled"', () => { + render( + <Iterate.Array path="/" defaultValue={[null]}> + <Iterate.ViewContainer variant="filled"> + View Content + </Iterate.ViewContainer> + <Iterate.EditContainer variant="filled"> + <Field.String itemPath="/" required /> + </Iterate.EditContainer> + </Iterate.Array> + ) + + const [viewBlock, editBlock] = Array.from( + document.querySelectorAll('.dnb-forms-section-block') + ) + expect(viewBlock).toHaveClass( + 'dnb-forms-section-block--variant-filled' + ) + expect(editBlock).toHaveClass( + 'dnb-forms-section-block--variant-filled' + ) + }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainerDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainerDocs.ts index fbdffe54f74..25b51bd566d 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainerDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainerDocs.ts @@ -43,7 +43,7 @@ export const PushContainerProperties: PropertiesTableProps = { status: 'optional', }, variant: { - doc: 'Defines the variant of the container. Can be `outline` or `basic`. Defaults to `outline`.', + doc: 'Defines the variant of the container. Can be `outline`, `filled` or `basic`. Defaults to `outline`.', type: 'string', status: 'optional', }, diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Toolbar/Toolbar.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Toolbar/Toolbar.tsx index c41ab227535..8beb409f98f 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Toolbar/Toolbar.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Toolbar/Toolbar.tsx @@ -6,6 +6,7 @@ import { SpaceAllProps } from '../../../../components/Space' import IterateItemContext from '../IterateItemContext' import ToolbarContext from './ToolbarContext' import FieldBoundaryContext from '../../DataContext/FieldBoundary/FieldBoundaryContext' +import ArrayItemAreaContext from '../Array/ArrayItemAreaContext' import { useTranslation } from '../../hooks' export type ToolbarParams = { @@ -27,6 +28,7 @@ export default function Toolbar({ value, arrayValue: items, } = useContext(IterateItemContext) || {} + const { toolbarVariant } = useContext(ArrayItemAreaContext) || {} const { errorInContainer } = useTranslation().IterateEditContainer const { hasError, hasVisibleError } = useContext(FieldBoundaryContext) || {} @@ -48,14 +50,17 @@ export default function Toolbar({ return ( <Space - top="medium" + top={toolbarVariant === 'custom' ? false : 'medium'} className={classnames('dnb-forms-iterate-toolbar', className)} {...rest} > - <Hr space={0} /> + {toolbarVariant !== 'custom' && <Hr space={0} />} <ToolbarContext.Provider value={{ setShowError }}> - <Flex.Horizontal top="x-small" gap="large"> + <Flex.Horizontal + top={toolbarVariant === 'custom' ? false : 'x-small'} + gap="large" + > {children} </Flex.Horizontal> </ToolbarContext.Provider> diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/RemoveButton.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/RemoveButton.tsx index 1f26e3467f2..6dfc5a0fad4 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/RemoveButton.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/RemoveButton.tsx @@ -1,9 +1,11 @@ import React from 'react' -import RemoveButton from '../RemoveButton' +import RemoveButton, { Props as RemoveButtonProps } from '../RemoveButton' import useTranslation from '../../hooks/useTranslation' -export default function ViewContainerRemoveButton() { +export default function ViewContainerRemoveButton( + props: RemoveButtonProps +) { const { removeButton } = useTranslation().IterateViewContainer - return <RemoveButton text={removeButton} /> + return <RemoveButton text={removeButton} {...props} /> } diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainer.tsx index cc49e27fa4c..92fdba61dc3 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainer.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainer.tsx @@ -16,14 +16,16 @@ export type Props = { * The title of the ViewContainer. */ title?: React.ReactNode + /** * An alternative toolbar to be shown in the ViewContainer. */ toolbar?: React.ReactNode + /** * The variant of the toolbar. */ - toolbarVariant?: 'minimumOneItem' + toolbarVariant?: ArrayItemAreaProps['toolbarVariant'] } export type AllProps = Props & FlexContainerProps & ArrayItemAreaProps @@ -62,6 +64,7 @@ function ViewContainer(props: AllProps) { mode="view" ariaLabel={convertJsxToString(itemTitle)} className={classnames('dnb-forms-section-view-block', className)} + toolbarVariant={toolbarVariant} {...restProps} > <Flex.Stack> @@ -69,12 +72,13 @@ function ViewContainer(props: AllProps) { {children} {hasToolbar ? null - : toolbarElement ?? ( + : toolbarElement ?? + (toolbarVariant !== 'custom' && ( <Toolbar> <EditButton /> <RemoveButton /> </Toolbar> - )} + ))} </Flex.Stack> </ArrayItemArea> ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainerDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainerDocs.ts index dc92473f35b..b776d570a0d 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainerDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainerDocs.ts @@ -7,7 +7,7 @@ export const ViewContainerProperties: PropertiesTableProps = { status: 'optional', }, variant: { - doc: 'Defines the variant of the container. Can be `outline` or `basic`. Defaults to `outline`.', + doc: 'Defines the variant of the container. Can be `outline`, `filled` or `basic`. Defaults to `outline`.', type: 'string', status: 'optional', }, @@ -17,7 +17,7 @@ export const ViewContainerProperties: PropertiesTableProps = { status: 'optional', }, toolbarVariant: { - doc: 'Use variants to render the toolbar differently. Currently there is only the `minimumOneItem` variant. See the info section for more info.', + doc: 'Use variants to render the toolbar differently. Currently there are the `minimumOneItem` and `custom` variants. See the info section for more info.', type: 'string', status: 'optional', }, diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/useItem.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/useItem.ts index a202eac933d..befdceea108 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/useItem.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/useItem.ts @@ -1,7 +1,16 @@ import { useContext } from 'react' -import IterateItemContext from '../IterateItemContext' +import IterateItemContext, { + IterateItemContextState, +} from '../IterateItemContext' -export default function useItem() { +export type UseItemReturn<Value = unknown> = Omit< + IterateItemContextState, + 'value' +> & { + value: Value +} + +export default function useItem<Value = unknown>() { const item = useContext(IterateItemContext) - return item + return item as UseItemReturn<Value> } diff --git a/packages/dnb-eufemia/src/style/themes/theme-sbanken/sbanken-theme-forms.scss b/packages/dnb-eufemia/src/style/themes/theme-sbanken/sbanken-theme-forms.scss index e41a420d08e..326e15d8a38 100644 --- a/packages/dnb-eufemia/src/style/themes/theme-sbanken/sbanken-theme-forms.scss +++ b/packages/dnb-eufemia/src/style/themes/theme-sbanken/sbanken-theme-forms.scss @@ -17,3 +17,4 @@ $THEME_FALLBACK: 'ui'; @import '../../../extensions/forms/Field/Number/style/themes/dnb-number-theme-sbanken.scss'; @import '../../../extensions/forms/FieldBlock/style/themes/dnb-field-block-theme-sbanken.scss'; @import '../../../extensions/forms/Wizard/style/themes/dnb-wizard-layout-theme-sbanken.scss'; +@import '../../../extensions/forms/Form/Section/style/themes/dnb-section-theme-sbanken.scss'; diff --git a/packages/dnb-eufemia/src/style/themes/theme-ui/ui-theme-forms.scss b/packages/dnb-eufemia/src/style/themes/theme-ui/ui-theme-forms.scss index f88b254164d..7215604584f 100644 --- a/packages/dnb-eufemia/src/style/themes/theme-ui/ui-theme-forms.scss +++ b/packages/dnb-eufemia/src/style/themes/theme-ui/ui-theme-forms.scss @@ -17,3 +17,4 @@ $THEME_FALLBACK: 'ui'; @import '../../../extensions/forms/Field/Number/style/themes/dnb-number-theme-ui.scss'; @import '../../../extensions/forms/FieldBlock/style/themes/dnb-field-block-theme-ui.scss'; @import '../../../extensions/forms/Wizard/style/themes/dnb-wizard-layout-theme-ui.scss'; +@import '../../../extensions/forms/Form/Section/style/themes/dnb-section-theme-ui.scss'; From 76bddf0adb7e673c608526de2e6f70195a98a8fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= <tobias@tujo.no> Date: Mon, 25 Nov 2024 14:24:58 +0100 Subject: [PATCH 04/13] feat(Forms): add `showConfirmDialog` to Iterate.RemoveButton (#4330) --- .../forms/Iterate/Array/Examples.tsx | 9 ++- .../forms/Iterate/RemoveButton/info.mdx | 8 +++ .../components/dialog/parts/DialogAction.tsx | 35 +++++++---- .../forms/Iterate/Array/ArrayItemArea.tsx | 2 +- .../Iterate/RemoveButton/RemoveButton.tsx | 63 +++++++++++++------ .../Iterate/RemoveButton/RemoveButtonDocs.ts | 5 ++ .../__tests__/RemoveButton.test.tsx | 33 ++++++++++ .../forms/constants/locales/en-GB.ts | 1 + .../forms/constants/locales/nb-NO.ts | 1 + 9 files changed, 120 insertions(+), 37 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 0af16769b22..a238bd87094 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 @@ -205,7 +205,7 @@ export const ArrayFromFormHandler = () => { </Field.Composition> <Iterate.Toolbar> - <Iterate.RemoveButton /> + <Iterate.RemoveButton showConfirmDialog /> </Iterate.Toolbar> </Iterate.AnimatedContainer> </Iterate.Array> @@ -549,7 +549,7 @@ export const WithArrayValidator = () => { width="medium" size="medium" /> - <Iterate.RemoveButton /> + <Iterate.RemoveButton showConfirmDialog /> </Flex.Horizontal> </Iterate.Array> @@ -593,7 +593,10 @@ export const FilledViewAndEditContainer = () => { <Iterate.EditContainer.DoneButton /> <Iterate.EditContainer.CancelButton /> </Flex.Horizontal> - <Iterate.ViewContainer.RemoveButton left={false} /> + <Iterate.ViewContainer.RemoveButton + showConfirmDialog + left={false} + /> </Flex.Horizontal> </Iterate.Toolbar> ) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/RemoveButton/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/RemoveButton/info.mdx index 457cae6b65b..b38daeab302 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/RemoveButton/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/RemoveButton/info.mdx @@ -20,3 +20,11 @@ render( </>, ) ``` + +## Confirm removal + +You can use the `showConfirmDialog` property to open a confirmation dialog before removing the item. + +```tsx +<Iterate.RemoveButton showConfirmDialog /> +``` diff --git a/packages/dnb-eufemia/src/components/dialog/parts/DialogAction.tsx b/packages/dnb-eufemia/src/components/dialog/parts/DialogAction.tsx index 004f9573647..e8e3805c40d 100644 --- a/packages/dnb-eufemia/src/components/dialog/parts/DialogAction.tsx +++ b/packages/dnb-eufemia/src/components/dialog/parts/DialogAction.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react' +import React, { useCallback, useContext } from 'react' import classNames from 'classnames' import Button from '../../button/Button' import Space from '../../space/Space' @@ -71,6 +71,25 @@ const DialogAction = ({ const { close } = useContext(ModalContext) let childrenWithCloseFunc: Array<React.ReactChild> + const onConfirmHandler = useCallback( + (event) => { + dispatchCustomElementEvent({ onConfirm }, 'onConfirm', { + event, + close, + }) + }, + [close, onConfirm] + ) + const onDeclineHandler = useCallback( + (event) => { + dispatchCustomElementEvent({ onDecline }, 'onDecline', { + event, + close, + }) + }, + [close, onDecline] + ) + if (children) { childrenWithCloseFunc = React.Children.map(children, (child) => { if (child.type === Button) { @@ -105,12 +124,7 @@ const DialogAction = ({ <Button text={declineText || translation?.Dialog?.declineText} variant="secondary" - onClick={(event) => { - dispatchCustomElementEvent({ onDecline }, 'onDecline', { - event, - close, - }) - }} + onClick={onDeclineHandler} size={ButtonContext?.size || 'large'} /> )} @@ -118,12 +132,7 @@ const DialogAction = ({ <Button text={confirmText || translation?.Dialog?.confirmText} variant="primary" - onClick={(event) => { - dispatchCustomElementEvent({ onConfirm }, 'onConfirm', { - event, - close, - }) - }} + onClick={onConfirmHandler} size={ButtonContext?.size || 'large'} /> )} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemArea.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemArea.tsx index 7b6bdd0eba0..7e71a21b1d3 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemArea.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemArea.tsx @@ -165,8 +165,8 @@ function ArrayItemArea(props: Props & FlexContainerProps) { // } isRemoving.current = true - handleRemove?.({ keepItems: true }) setOpenState(false) + handleRemove?.({ keepItems: true }) }, [handleRemove, index, setOpenState]) return ( diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/RemoveButton/RemoveButton.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/RemoveButton/RemoveButton.tsx index 2cda00e35ce..821a816d7a8 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/RemoveButton/RemoveButton.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/RemoveButton/RemoveButton.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useContext } from 'react' import classnames from 'classnames' -import { Button } from '../../../../components' +import { Button, Dialog } from '../../../../components' import { ButtonProps } from '../../../../components/Button' import IterateItemContext from '../IterateItemContext' import { useTranslation } from '../../hooks' @@ -12,7 +12,9 @@ import { import { trash } from '../../../../icons' export type Props = ButtonProps & - DataValueReadWriteComponentProps<unknown[]> + DataValueReadWriteComponentProps<unknown[]> & { + showConfirmDialog?: boolean + } function RemoveButton(props: Props) { const iterateItemContext = useContext(IterateItemContext) @@ -22,7 +24,8 @@ function RemoveButton(props: Props) { throw new Error('RemoveButton must be inside an Iterate.Array') } - const { text, children, className, ...restProps } = props + const { text, children, className, showConfirmDialog, ...restProps } = + props const buttonProps = omitDataValueReadWriteProps(restProps) const translation = useTranslation().RemoveButton const textContent = text || children || translation.text @@ -30,28 +33,48 @@ function RemoveButton(props: Props) { const elementBlockContext = useContext(ArrayItemAreaContext) const { handleRemoveItem } = elementBlockContext || {} - const handleClick = useCallback(() => { - if (handleRemoveItem) { - handleRemoveItem?.() - } else { - handleRemove?.() - } - }, [handleRemove, handleRemoveItem]) + const handleClick = useCallback( + ({ close }) => { + close?.() + + if (handleRemoveItem) { + handleRemoveItem?.() + } else { + handleRemove?.() + } + }, + [handleRemove, handleRemoveItem] + ) + + const triggerAttributes: ButtonProps = { + className: classnames( + 'dnb-forms-iterate-remove-element-button', + className + ), + text: textContent, + variant: textContent ? 'tertiary' : 'secondary', + icon: trash, + icon_position: 'left', + ...buttonProps, + } + + if (showConfirmDialog) { + return ( + <Dialog + variant="confirmation" + title={translation.confirmRemoveText} + triggerAttributes={triggerAttributes} + onConfirm={handleClick} + /> + ) + } return ( <Button - className={classnames( - 'dnb-forms-iterate-remove-element-button', - className - )} - variant={textContent ? 'tertiary' : 'secondary'} - icon={trash} - icon_position="left" + {...triggerAttributes} on_click={handleClick} {...buttonProps} - > - {textContent} - </Button> + /> ) } diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/RemoveButton/RemoveButtonDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/RemoveButton/RemoveButtonDocs.ts index 82188663e5a..dad74872682 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/RemoveButton/RemoveButtonDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/RemoveButton/RemoveButtonDocs.ts @@ -1,6 +1,11 @@ import { PropertiesTableProps } from '../../../../shared/types' export const RemoveButtonProperties: PropertiesTableProps = { + showConfirmDialog: { + doc: 'Use `true` to show a confirmation dialog before removing the item.', + type: 'boolean', + status: 'optional', + }, '[Button](/uilib/components/button/properties)': { doc: 'All button properties.', type: 'Various', diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/RemoveButton/__tests__/RemoveButton.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/RemoveButton/__tests__/RemoveButton.test.tsx index 09d5a950c45..2ae0d0ec52b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/RemoveButton/__tests__/RemoveButton.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/RemoveButton/__tests__/RemoveButton.test.tsx @@ -1,5 +1,6 @@ import React from 'react' import { render, fireEvent, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import IterateItemContext from '../../IterateItemContext' import RemoveButton from '../RemoveButton' import nbNO from '../../../constants/locales/nb-NO' @@ -16,6 +17,10 @@ describe('RemoveButton', () => { </IterateItemContext.Provider> ) + afterEach(() => { + handleRemove.mockReset() + }) + it('should call handleRemove when clicked inside an Iterate element', () => { render(<RemoveButton>Remove Button</RemoveButton>, { wrapper }) @@ -120,4 +125,32 @@ describe('RemoveButton', () => { expect(screen.getByText(remove)).toBeInTheDocument() }) + + describe('showConfirmDialog', () => { + it('should show Dialog before removing', async () => { + render(<RemoveButton showConfirmDialog />, { wrapper }) + + await userEvent.click(document.querySelector('button')) + + expect(handleRemove).toHaveBeenCalledTimes(0) + + await userEvent.click( + document.querySelector( + '.dnb-dialog__inner button.dnb-button--primary' + ) + ) + + expect(handleRemove).toHaveBeenCalledTimes(1) + }) + + it('should show Dialog with translation before removing', async () => { + render(<RemoveButton showConfirmDialog />, { wrapper }) + + await userEvent.click(document.querySelector('button')) + + expect( + document.querySelector('.dnb-dialog__inner') + ).toHaveTextContent(nb.confirmRemoveText) + }) + }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts b/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts index a44eb1df661..d2df5c1b137 100644 --- a/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts +++ b/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts @@ -33,6 +33,7 @@ export default { }, RemoveButton: { text: 'Remove', + confirmRemoveText: 'Are you sure you want to delete this?', }, SectionViewContainer: { editButton: 'Edit', diff --git a/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts b/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts index b976462b78b..1110f5caee2 100644 --- a/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts +++ b/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts @@ -32,6 +32,7 @@ export default { }, RemoveButton: { text: 'Fjern', + confirmRemoveText: 'Er du sikker på at du vil slette dette?', }, SectionViewContainer: { editButton: 'Endre', From 0b02b6e84687c8e44f44a935c009e1c8e5c42519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= <tobias@tujo.no> Date: Mon, 25 Nov 2024 14:25:17 +0100 Subject: [PATCH 05/13] feat(Forms): introduce `decoupleForm` prop to Form.Handler (#4332) --- .../uilib/extensions/forms/Form/Handler.mdx | 2 +- .../extensions/forms/Form/Handler/info.mdx | 97 ++++++-- .../docs/uilib/extensions/forms/Form/info.mdx | 34 ++- .../uilib/extensions/forms/all-features.mdx | 2 +- .../extensions/forms/getting-started.mdx | 20 +- .../extensions/forms/DataContext/Context.ts | 5 +- .../forms/DataContext/Provider/Provider.tsx | 228 +++++++++--------- .../extensions/forms/Form/Element/Element.tsx | 67 ++++- .../Form/Element/__tests__/Element.test.tsx | 6 +- .../extensions/forms/Form/Handler/Handler.tsx | 183 ++++++-------- .../Form/Handler/__tests__/Handler.test.tsx | 80 ++++++ .../forms/Form/SubmitButton/SubmitButton.tsx | 6 +- 12 files changed, 451 insertions(+), 279 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler.mdx index 5e322095c31..f19bea3c156 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler.mdx @@ -1,6 +1,6 @@ --- title: 'Handler' -description: '`Form.Handler` provides both the DataContext.Provider and a HTML form element.' +description: 'The `Form.Handler` is the root component of your form. It provides a HTML form element and handles the form data.' showTabs: true tabs: - title: Info diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx index e687dec6cfc..af4d9bedcb7 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx @@ -7,32 +7,57 @@ import AsyncChangeExample from './parts/async-change-example.mdx' ## Description -The `Form.Handler` component provides a HTML form element. +The `Form.Handler` is the root component of your form. It provides a HTML form element and handles the form data. ```jsx import { Form } from '@dnb/eufemia/extensions/forms' -render( - <Form.Handler - data={existingData} - onChange={...} - onSubmit={...} - > - Your Form - </Form.Handler>, -) +const existingData = { firstName: 'Nora' } + +function MyForm() { + return ( + <Form.Handler + defaultData={existingData} + onSubmit={...} + > + Your Form + </Form.Handler> + ) +} ``` -## Data handling +## Decoupling the form element -The form data can be handled outside of the form. This is useful if you want to use the form data in other components: +For more flexibility, you can decouple the form element from the form context by using the `decoupleFormElement` property. It is recommended to use the `Form.Element` to wrap your rest of your form: ```jsx import { Form } from '@dnb/eufemia/extensions/forms' -const myFormId = 'unique-id' // or a function, object or React Context reference +function MyApp() { + return ( + <Form.Handler decoupleFormElement> + <AppRelatedThings> + <Form.Element> + <Form.MainHeading>Heading</Form.MainHeading> + <Form.Card> + <Field.Email /> + </Form.Card> + <Form.SubmitButton /> + </Form.Element> + </AppRelatedThings> + </Form.Handler> + ) +} +``` -function MyForm() { +## Data handling + +You can access, mutate and filter data inside of the form context by using the `Form.useData` hook: + +```jsx +import { Form } from '@dnb/eufemia/extensions/forms' + +function MyComponent() { const { getValue, update, @@ -41,9 +66,18 @@ function MyForm() { data, filterData, reduceToVisibleFields, - } = Form.useData(myFormId) + } = Form.useData() + + return <>...</> +} - return <Form.Handler id={myFormId}>...</Form.Handler> +function MyApp() { + return ( + <> + <Form.Handler>...</Form.Handler> + <MyComponent /> + </> + ) } ``` @@ -55,6 +89,31 @@ function MyForm() { - `filterData` will filter the data based on your own logic. - `reduceToVisibleFields` will reduce the given data set to only contain the visible fields (mounted fields). +### Using a form ID + +The form data can be handled outside of the form. This is useful if you want to use the form data in other components: + +```jsx +import { Form } from '@dnb/eufemia/extensions/forms' + +const myFormId = 'unique-id' // or a function, object or React Context reference + +function MyComponent() { + const { data } = Form.useData(myFormId) + + return <>...</> +} + +function MyApp() { + return ( + <> + <Form.Handler id={myFormId}>...</Form.Handler> + <MyComponent /> + </> + ) +} +``` + More examples can be found in the [useData](/uilib/extensions/forms/Form/useData/) hook docs. ### TypeScript support @@ -76,7 +135,7 @@ const data: MyDataSet = { function MyForm() { return ( <Form.Handler - data={data} + defaultData={data} onSubmit={(data) => { console.log(data.firstName) }} @@ -89,7 +148,7 @@ const submitHandler = (data: MyDataSet) => { console.log(data.firstName) } function MyForm() { - return <Form.Handler data={data} onSubmit={submitHandler} /> + return <Form.Handler defaultData={data} onSubmit={submitHandler} /> } // Method #3 @@ -98,7 +157,7 @@ const submitHandler: OnSubmit<MyDataSet> = (data) => { console.log(data.firstName) } function MyForm() { - return <Form.Handler data={data} onSubmit={submitHandler} /> + return <Form.Handler defaultData={data} onSubmit={submitHandler} /> } ``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/info.mdx index ff06f7272f5..123e0cd6786 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/info.mdx @@ -6,14 +6,30 @@ showTabs: true `Form` provides the main forms-helpers including data provider and event handling. -```jsx +```tsx import { Form, Field } from '@dnb/eufemia/extensions/forms' -render( - <Form.Handler data={existingData} onSubmit={submitHandler}> - <Field.Email path="/email" /> - <Form.ButtonRow> - <Form.SubmitButton /> - </Form.ButtonRow> - </Form.Handler>, -) + +const existingData = { + email: 'name@email.no', +} + +function MyForm() { + return ( + <Form.Handler + defaultData={existingData} + onSubmit={async (data) => { + await makeRequest(data) + }} + > + <Form.MainHeading>Heading</Form.MainHeading> + <Form.Card> + <Field.Email path="/email" /> + </Form.Card> + + <Form.ButtonRow> + <Form.SubmitButton /> + </Form.ButtonRow> + </Form.Handler> + ) +} ``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/all-features.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/all-features.mdx index 81241caf89d..cf0082c4d5f 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/all-features.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/all-features.mdx @@ -56,7 +56,7 @@ const submitHandler = async (data) => { function Component() { return ( - <Form.Handler data={existingData} onSubmit={submitHandler}> + <Form.Handler defaultData={existingData} onSubmit={submitHandler}> <Field.Email path="/email" /> <Value.Date path="/date" /> <Form.SubmitButton /> diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx index bcd12191b4f..a533a97e9e0 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx @@ -231,16 +231,16 @@ You may check out an [interactive example](/uilib/extensions/forms/Form/useData/ For filtering data during form submit (`onSubmit`), you can use the `filterData` method given as a parameter to the `onSubmit` event callback: ```tsx -const onSubmit = (data, { filterData }) => { - // Same method as in the previous example - const filteredDataA = filterData(filterDataPaths) - const filteredDataB = filterData(filterDataHandler) - console.log(filteredDataA) - console.log(filteredDataB) -} - render( - <Form.Handler onSubmit={onSubmit}> + <Form.Handler + onSubmit={(data, { filterData }) => { + // Same method as in the previous example + const filteredDataA = filterData(filterDataPaths) + const filteredDataB = filterData(filterDataHandler) + console.log(filteredDataA) + console.log(filteredDataB) + }} + > <Field.String path="/foo" /> </Form.Handler>, ) @@ -428,7 +428,7 @@ Eufemia Forms will easily link up with the [GlobalStatus](/uilib/components/glob ```tsx <GlobalStatus /> -<Form.Handler > +<Form.Handler> My Form </Form.Handler> ``` diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts index 62e684e83bc..899dced8734 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts @@ -181,8 +181,10 @@ export interface ContextState { disabled?: boolean required?: boolean submitState: Partial<EventStateObject> - isInsideFormElement?: boolean prerenderFieldProps?: boolean + decoupleFormElement?: boolean + hasElementRef?: React.MutableRefObject<boolean> + restHandlerProps?: Record<string, unknown> props: ProviderProps<unknown> } @@ -211,7 +213,6 @@ export const defaultContextState: ContextState = { hasFieldError: () => false, ajvInstance: makeAjvInstance(), contextErrorMessages: undefined, - isInsideFormElement: false, props: null, } diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx index 4817b0db407..8cb7ae6afd2 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -73,122 +73,122 @@ export type SharedAttachments<Data = unknown> = { fieldConnectionsRef?: ContextState['fieldConnectionsRef'] } -export interface Props<Data extends JsonObject> - extends IsolationProviderProps<Data> { - /** - * Unique ID to communicate with the hook Form.useData - */ - id?: SharedStateId - /** - * Unique ID to connect with a GlobalStatus - */ - globalStatusId?: string - /** - * Source data, will be used instead of defaultData, and leading to updates if changed after mount - */ - data?: Data - /** - * Default source data, only used if no other source is available, and not leading to updates if changed after mount - */ - defaultData?: Data - /** - * Empty data, used to clear the data set. - */ - emptyData?: unknown - /** - * JSON Schema to validate the data against. - */ - schema?: AllJSONSchemaVersions<Data> - /** - * Custom Ajv instance, if you want to use your own - */ - ajvInstance?: Ajv - /** - * Custom error messages for the whole data set - */ - errorMessages?: GlobalErrorMessagesWithPaths - /** - * @deprecated Use the `filterData` in the second event parameter in the `onSubmit` or `onChange` events. - */ - filterSubmitData?: FilterData - /** - * Transform the data context (internally as well) based on your criteria: `({ path, value, data, props, internal }) => 'new value'`. It will iterate on each data entry (/path). - */ - transformIn?: TransformData - /** - * Mutate the data before it enters onSubmit or onChange based on your criteria: `({ path, value, data, props, internal }) => 'new value'`. It will iterate on each data entry (/path). - */ - transformOut?: TransformData - /** - * Change handler for the whole data set. - * You can provide an async function to show an indicator on the current label during a field change. - */ - onChange?: OnChange<Data> - /** - * Change handler for each value - */ - onPathChange?: ( - path: Path, - value: unknown - ) => - | EventReturnWithStateObject - | void - | Promise<EventReturnWithStateObject | void> - /** - * Will emit on a form submit – if validation has passed. - * You can provide an async function to shows a submit indicator during submit. All form elements will be disabled during the submit. - */ - onSubmit?: OnSubmit<Data> - /** - * Submit was requested, but data was invalid - */ - onSubmitRequest?: () => void - /** - * Will be called when the onSubmit is finished and had not errors - */ - onSubmitComplete?: ( - data: Data, +export type Props<Data extends JsonObject> = + IsolationProviderProps<Data> & { /** - * The result of the onSubmit function + * Unique ID to communicate with the hook Form.useData */ - result: unknown - ) => - | EventReturnWithStateObject - | void - | Promise<EventReturnWithStateObject | void> - /** - * Minimum time to display the submit indicator. - */ - minimumAsyncBehaviorTime?: number - /** - * The maximum time to display the submit indicator before it changes back to normal. In case something went wrong during submission. - */ - asyncSubmitTimeout?: number - /** - * Scroll to top on submit - */ - scrollTopOnSubmit?: boolean - /** - * Key for caching the data in session storage - */ - sessionStorageId?: string - /** - * Locale to use for all nested Eufemia components - */ - locale?: ContextProps['locale'] - /** - * Provide your own translations. Use the same format as defined in the translation files - */ - translations?: ContextProps['translations'] - /** - * Make all fields required - */ - required?: boolean - /** - * The children of the context provider - */ - children: React.ReactNode -} + id?: SharedStateId + /** + * Unique ID to connect with a GlobalStatus + */ + globalStatusId?: string + /** + * Source data, will be used instead of defaultData, and leading to updates if changed after mount + */ + data?: Data + /** + * Default source data, only used if no other source is available, and not leading to updates if changed after mount + */ + defaultData?: Data + /** + * Empty data, used to clear the data set. + */ + emptyData?: unknown + /** + * JSON Schema to validate the data against. + */ + schema?: AllJSONSchemaVersions<Data> + /** + * Custom Ajv instance, if you want to use your own + */ + ajvInstance?: Ajv + /** + * Custom error messages for the whole data set + */ + errorMessages?: GlobalErrorMessagesWithPaths + /** + * @deprecated Use the `filterData` in the second event parameter in the `onSubmit` or `onChange` events. + */ + filterSubmitData?: FilterData + /** + * Transform the data context (internally as well) based on your criteria: `({ path, value, data, props, internal }) => 'new value'`. It will iterate on each data entry (/path). + */ + transformIn?: TransformData + /** + * Mutate the data before it enters onSubmit or onChange based on your criteria: `({ path, value, data, props, internal }) => 'new value'`. It will iterate on each data entry (/path). + */ + transformOut?: TransformData + /** + * Change handler for the whole data set. + * You can provide an async function to show an indicator on the current label during a field change. + */ + onChange?: OnChange<Data> + /** + * Change handler for each value + */ + onPathChange?: ( + path: Path, + value: unknown + ) => + | EventReturnWithStateObject + | void + | Promise<EventReturnWithStateObject | void> + /** + * Will emit on a form submit – if validation has passed. + * You can provide an async function to shows a submit indicator during submit. All form elements will be disabled during the submit. + */ + onSubmit?: OnSubmit<Data> + /** + * Submit was requested, but data was invalid + */ + onSubmitRequest?: () => void + /** + * Will be called when the onSubmit is finished and had not errors + */ + onSubmitComplete?: ( + data: Data, + /** + * The result of the onSubmit function + */ + result: unknown + ) => + | EventReturnWithStateObject + | void + | Promise<EventReturnWithStateObject | void> + /** + * Minimum time to display the submit indicator. + */ + minimumAsyncBehaviorTime?: number + /** + * The maximum time to display the submit indicator before it changes back to normal. In case something went wrong during submission. + */ + asyncSubmitTimeout?: number + /** + * Scroll to top on submit + */ + scrollTopOnSubmit?: boolean + /** + * Key for caching the data in session storage + */ + sessionStorageId?: string + /** + * Locale to use for all nested Eufemia components + */ + locale?: ContextProps['locale'] + /** + * Provide your own translations. Use the same format as defined in the translation files + */ + translations?: ContextProps['translations'] + /** + * Make all fields required + */ + required?: boolean + /** + * The children of the context provider + */ + children: React.ReactNode + } const isArrayJsonPointer = /^\/\d+(\/|$)/ diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Element/Element.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Element/Element.tsx index d26e9914899..74344c0af3c 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Element/Element.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Element/Element.tsx @@ -1,24 +1,40 @@ -import React, { useCallback, useContext } from 'react' -import Context from '../../DataContext/Context' -import Space from '../../../../components/space/Space' +import React, { useCallback, useContext, useRef } from 'react' import classnames from 'classnames' +import DataContext from '../../DataContext/Context' +import Space from '../../../../components/space/Space' +import useId from '../../../../shared/helpers/useId' import type { SpacingProps } from '../../../../shared/types' +import { FormStatus } from '../../../../components' +import { combineLabelledBy } from '../../../../shared/component-helper' + +export type Props = Omit< + React.HTMLProps<HTMLFormElement>, + 'ref' | 'autoComplete' +> & + SpacingProps -export type Props = React.HTMLAttributes<HTMLFormElement> & SpacingProps +export default function FormElement(props: Props) { + const id = useId() + const dataContext = useContext(DataContext) + const { submitState, restHandlerProps } = dataContext || {} + const states = Object.entries(submitState || {}).filter( + ([, value]) => value + ) -export default function FormElement({ - children, - className = null, - onSubmit = null, - ...rest -}: Props) { - const dataContext = useContext(Context) + const { children, className, onSubmit, ...restProps } = { + ...restHandlerProps, + ...props, + } as Props /** * Set to true, * this way we prevent "handleSubmit" to be called twice when the SubmitButton is pressed. */ - dataContext.isInsideFormElement = true + const hasElementRef = useRef(false) + if (!dataContext.hasElementRef) { + dataContext.hasElementRef = hasElementRef + } + dataContext.hasElementRef.current = true const onSubmitHandler = useCallback( (event: React.SyntheticEvent<HTMLFormElement>) => { @@ -43,9 +59,34 @@ export default function FormElement({ element="form" className={classnames('dnb-forms-form', className)} onSubmit={onSubmitHandler} - {...rest} + aria-labelledby={ + combineLabelledBy( + restProps, + states.map(([key]) => { + return `${id}-form-status-${key}` + }) + ) || undefined + } + {...restProps} > {children} + + {['error', 'warning', 'info'].map((key) => { + const value = submitState?.[key] + return ( + <FormStatus + key={key} + state={key} + id={`${id}-form-status-${key}`} + className="dnb-forms-status" + show={Boolean(value)} + no_animation={false} + shellSpace={{ top: 'small' }} + > + {String(value?.['message'] || value || '')} + </FormStatus> + ) + })} </Space> ) } diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Element/__tests__/Element.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Element/__tests__/Element.test.tsx index 49c25c84137..ab76f72b219 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Element/__tests__/Element.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Element/__tests__/Element.test.tsx @@ -56,7 +56,11 @@ describe('Form.Element', () => { { foo: 'Value' }, expect.anything() ) + expect(onSubmitElement).toHaveBeenCalledTimes(1) + expect(onSubmitElement).toHaveBeenCalledWith( + expect.objectContaining({ type: 'submit', target: inputElement }) + ) fireEvent.click(buttonElement) @@ -65,8 +69,8 @@ describe('Form.Element', () => { { foo: 'Value' }, expect.anything() ) - expect(onSubmitElement).toHaveBeenCalledTimes(2) + expect(onSubmitElement).toHaveBeenCalledTimes(2) expect(onSubmitElement).toHaveBeenCalledWith( expect.objectContaining({ type: 'submit', target: inputElement }) ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx index f9146ff3235..f45ef195b0c 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx @@ -1,125 +1,96 @@ -import React, { useContext } from 'react' +import React, { useEffect, useRef } from 'react' import { JsonObject } from '../../utils/json-pointer' +import { warn } from '../../../../shared/helpers' import DataContextProvider, { Props as ProviderProps, } from '../../DataContext/Provider' -import DataContext from '../../DataContext/Context' -import FormElement from '../Element' -import type { ElementAllProps } from '../../../../elements/Element' -import FormStatus from '../../../../components/FormStatus' -import useId from '../../../../shared/helpers/useId' -import { combineLabelledBy } from '../../../../shared/component-helper' +import FormElement, { Props as FormElementProps } from '../Element' +import { ContextState } from '../../DataContext' -export type Props = Omit< - ElementAllProps, - 'data' | 'as' | 'autoComplete' -> & { +export type Props = FormElementProps & { /** * Will enable autoComplete for all nested Field.String fields */ autoComplete?: boolean + + /** + * Will decouple the form element from rendering + */ + decoupleFormElement?: boolean } -export default function FormHandler<Data extends JsonObject>({ - children, - defaultData, - data, - schema, - ajvInstance, - errorMessages, - globalStatusId, - filterSubmitData, - transformIn, - transformOut, - onChange, - onPathChange, - onSubmit, - onSubmitRequest, - onSubmitComplete, - onClear, - minimumAsyncBehaviorTime, - asyncSubmitTimeout, - scrollTopOnSubmit, - sessionStorageId, - autoComplete, - locale, - translations, - disabled, - required, - ...rest -}: ProviderProps<Data> & Omit<Props, keyof ProviderProps<Data>>) { +type AllowedProviderContextProps = ProviderProps<unknown> & + Pick<Props, 'decoupleFormElement' | 'autoComplete' | 'disabled'> & + Pick<ContextState, 'restHandlerProps' | 'hasElementRef'> + +const allowedProviderContextProps: Array< + keyof AllowedProviderContextProps +> = [ + 'id', + 'defaultData', + 'data', + 'schema', + 'ajvInstance', + 'errorMessages', + 'globalStatusId', + 'filterSubmitData', + 'transformIn', + 'transformOut', + 'onChange', + 'onPathChange', + 'onSubmit', + 'onSubmitRequest', + 'onSubmitComplete', + 'onClear', + 'minimumAsyncBehaviorTime', + 'asyncSubmitTimeout', + 'scrollTopOnSubmit', + 'sessionStorageId', + 'locale', + 'translations', + 'autoComplete', + 'disabled', + 'required', + 'decoupleFormElement', + 'restHandlerProps', +] + +export default function FormHandler<Data extends JsonObject>( + props: ProviderProps<Data> & Omit<Props, keyof ProviderProps<Data>> +) { + const { decoupleFormElement, children } = props + + const hasElementRef = useRef(false) + useEffect(() => { + if (decoupleFormElement && !hasElementRef.current) { + warn('Please include a Form.Element when using decoupleFormElement!') + } + }, [decoupleFormElement]) + const providerProps = { - id: rest.id, - defaultData, - data, - schema, - ajvInstance, - errorMessages, - globalStatusId, - filterSubmitData, - transformIn, - transformOut, - onChange, - onPathChange, - onSubmit, - onSubmitRequest, - onSubmitComplete, - onClear, - minimumAsyncBehaviorTime, - asyncSubmitTimeout, - scrollTopOnSubmit, - sessionStorageId, - autoComplete, - locale, - translations, - disabled, - required, + hasElementRef, + restHandlerProps: {}, + } as AllowedProviderContextProps + + for (const key in props) { + if ( + allowedProviderContextProps.includes( + key as keyof AllowedProviderContextProps + ) + ) { + providerProps[key] = props[key] + } else if (key !== 'children') { + providerProps.restHandlerProps[key] = props[key] + } } return ( <DataContextProvider {...providerProps}> - <FormElementWithState {...rest}>{children}</FormElementWithState> + {decoupleFormElement ? ( + children + ) : ( + <FormElement>{children}</FormElement> + )} </DataContextProvider> ) } - -function FormElementWithState({ children, ...rest }) { - const id = useId() - const { submitState } = useContext(DataContext) || {} - const states = Object.entries(submitState || {}).filter( - ([, value]) => value - ) - - return ( - <FormElement - {...rest} - aria-labelledby={ - combineLabelledBy( - rest, - states.map(([key]) => { - return `${id}-form-status-${key}` - }) - ) || undefined - } - > - {children} - - {['error', 'warning', 'info'].map((key) => { - const value = submitState?.[key] - return ( - <FormStatus - key={key} - state={key} - id={`${id}-form-status-${key}`} - className="dnb-forms-status" - show={Boolean(value)} - no_animation={false} - shellSpace={{ top: 'small' }} - > - {String(value?.['message'] || value || '')} - </FormStatus> - ) - })} - </FormElement> - ) -} diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx index e0cce0782fa..b1ae7915977 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx @@ -1160,4 +1160,84 @@ describe('Form.Handler', () => { expect(document.body).toHaveTextContent('content') }) }) + + describe('decoupleFormElement', () => { + it('should contain one form element', () => { + render( + <Form.Handler decoupleFormElement> + <Form.Element>content</Form.Element> + </Form.Handler> + ) + + const formElements = document.querySelectorAll('form') + expect(formElements).toHaveLength(1) + }) + + it('should call onSubmit when form is submitted', () => { + const onSubmit = jest.fn() + + render( + <Form.Handler decoupleFormElement onSubmit={onSubmit}> + <Form.Element>content</Form.Element> + </Form.Handler> + ) + + fireEvent.submit(document.querySelector('form')) + + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + it('should spread rest props to form element', () => { + render( + <Form.Handler decoupleFormElement aria-label="Aria Label"> + <Form.Element>content</Form.Element> + </Form.Handler> + ) + + expect(document.querySelector('form')).toHaveAttribute( + 'aria-label', + 'Aria Label' + ) + }) + + it('should overwrite rest props from handler', () => { + render( + <Form.Handler decoupleFormElement aria-label="Aria Label"> + <Form.Element aria-label="Overwrite">content</Form.Element> + </Form.Handler> + ) + + expect(document.querySelector('form')).toHaveAttribute( + 'aria-label', + 'Overwrite' + ) + }) + + it('should render form element inside wrapper', () => { + render( + <Form.Handler decoupleFormElement> + <div className="wrapper"> + <Form.Element>content</Form.Element> + </div> + </Form.Handler> + ) + + const formElements = document.querySelectorAll('.wrapper > form') + expect(formElements).toHaveLength(1) + }) + + it('should warn when no form element is found', () => { + const log = jest.spyOn(global.console, 'log').mockImplementation() + + render(<Form.Handler decoupleFormElement>content</Form.Handler>) + + expect(log).toHaveBeenCalledTimes(1) + expect(log).toHaveBeenCalledWith( + expect.any(String), + 'Please include a Form.Element when using decoupleFormElement!' + ) + + log.mockRestore() + }) + }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/SubmitButton/SubmitButton.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/SubmitButton/SubmitButton.tsx index 4c877bf88f1..91ece835449 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/SubmitButton/SubmitButton.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/SubmitButton/SubmitButton.tsx @@ -32,16 +32,16 @@ function SubmitButton(props: Props) { const { formState, handleSubmit, - isInsideFormElement, + hasElementRef, props: dataContextProps, } = useContext(DataContext) || {} const { isolate } = dataContextProps || {} const onClickHandler = useCallback(() => { - if (!isInsideFormElement) { + if (!hasElementRef?.current) { handleSubmit?.() } - }, [isInsideFormElement, handleSubmit]) + }, [hasElementRef, handleSubmit]) return ( <Button From ebad212df87cf2cd207fc18d885ff5b7b6e3d0fc Mon Sep 17 00:00:00 2001 From: Anders <anderslangseth@gmail.com> Date: Mon, 25 Nov 2024 22:08:34 +0100 Subject: [PATCH 06/13] fix(Forms.Card): remove outline when variant="basic" on Section containers when used in Wizard (#4336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://github.com/dnbexperience/eufemia/issues/4308 --------- Co-authored-by: Tobias Høegh <tobias@tujo.no> --- .../extensions/forms/Form/Card/Examples.tsx | 34 +++++++++++++++++- .../extensions/forms/Form/Card/demos.mdx | 4 +++ .../Card/__tests__/Card.screenshot.test.ts | 8 +++++ ...have-to-match-when-used-in-wizard.snap.png | Bin 0 -> 17458 bytes .../Form/Section/style/dnb-form-section.scss | 3 +- .../themes/dnb-wizard-layout-theme-ui.scss | 2 ++ 6 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 packages/dnb-eufemia/src/extensions/forms/Form/Card/__tests__/__image_snapshots__/formcard-have-to-match-when-used-in-wizard.snap.png diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Card/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Card/Examples.tsx index cdef9b83088..dc3cd285546 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Card/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Card/Examples.tsx @@ -1,6 +1,11 @@ import ComponentBox from '../../../../../../shared/tags/ComponentBox' import { Flex, P } from '@dnb/eufemia/src' -import { Form, Field } from '@dnb/eufemia/src/extensions/forms' +import { + Form, + Field, + Wizard, + Value, +} from '@dnb/eufemia/src/extensions/forms' export const BasicUsage = () => { return ( @@ -19,3 +24,30 @@ export const BasicUsage = () => { </ComponentBox> ) } + +export const UsageInWizard = () => { + return ( + <ComponentBox data-visual-test="forms-card-in-wizard"> + <Form.Handler> + <Wizard.Container> + <Wizard.Step> + <Form.Card> + <Form.Section> + <Form.Section.ViewContainer + title="In a Wizard" + variant="basic" + > + <Value.String defaultValue="Something" /> + </Form.Section.ViewContainer> + <Form.Section.EditContainer variant="basic"> + <Field.String defaultValue="Something" /> + </Form.Section.EditContainer> + </Form.Section> + </Form.Card> + </Wizard.Step> + </Wizard.Container> + <Form.SubmitButton text="Happy coding!" /> + </Form.Handler> + </ComponentBox> + ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Card/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Card/demos.mdx index c02baef433b..7d9f516c67e 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Card/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Card/demos.mdx @@ -8,3 +8,7 @@ import * as Examples from './Examples' ## Demos <Examples.BasicUsage /> + +<VisibleWhenVisualTest> + <Examples.UsageInWizard /> +</VisibleWhenVisualTest> diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Card/__tests__/Card.screenshot.test.ts b/packages/dnb-eufemia/src/extensions/forms/Form/Card/__tests__/Card.screenshot.test.ts index 56716295014..573e29690af 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Card/__tests__/Card.screenshot.test.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Card/__tests__/Card.screenshot.test.ts @@ -18,6 +18,14 @@ describe('Form.Card', () => { }) expect(screenshot).toMatchImageSnapshot() }) + + it('have to match when used in wizard', async () => { + const screenshot = await makeScreenshot({ + url, + selector: '[data-visual-test="forms-card-in-wizard"]', + }) + expect(screenshot).toMatchImageSnapshot() + }) }) describe.each(['ui', 'sbanken'])( diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Card/__tests__/__image_snapshots__/formcard-have-to-match-when-used-in-wizard.snap.png b/packages/dnb-eufemia/src/extensions/forms/Form/Card/__tests__/__image_snapshots__/formcard-have-to-match-when-used-in-wizard.snap.png new file mode 100644 index 0000000000000000000000000000000000000000..da932417d0f9c0e14c04ed5344475c7a6fc7ec0d GIT binary patch literal 17458 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 literal 0 HcmV?d00001 diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/dnb-form-section.scss b/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/dnb-form-section.scss index 4e14c27fa90..88f1a5462bc 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/dnb-form-section.scss +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Section/style/dnb-form-section.scss @@ -38,7 +38,8 @@ &--variant-basic { --border-color: transparent; - .dnb-card { + .dnb-card, + .dnb-card & .dnb-card { --card-outline-color: transparent; } } diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/style/themes/dnb-wizard-layout-theme-ui.scss b/packages/dnb-eufemia/src/extensions/forms/Wizard/style/themes/dnb-wizard-layout-theme-ui.scss index abdabed701d..d676d7d91d2 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/style/themes/dnb-wizard-layout-theme-ui.scss +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/style/themes/dnb-wizard-layout-theme-ui.scss @@ -1,6 +1,8 @@ .dnb-forms-wizard-layout { &__contents { .dnb-card { + // Change the default outline color to match the StepIndicator v2. + // This is deprecated and can be removed when the StepIndicator v3 (without a sidebar) is released. --card-outline-color: var(--color-pistachio); } } From a5623f5d18e5a54b5a0c9baa34443688d31577ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= <tobias@tujo.no> Date: Mon, 25 Nov 2024 22:14:25 +0100 Subject: [PATCH 07/13] chore: rename `decoupleFormElement` to `decoupleForm` (#4341) Fixes a missing commit for PR #4332 --- .../extensions/forms/Form/Handler/info.mdx | 4 ++-- .../extensions/forms/DataContext/Context.ts | 2 +- .../extensions/forms/Form/Handler/Handler.tsx | 20 ++++++++----------- .../Form/Handler/__tests__/Handler.test.tsx | 16 +++++++-------- 4 files changed, 19 insertions(+), 23 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx index af4d9bedcb7..cd52cd8f912 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx @@ -28,14 +28,14 @@ function MyForm() { ## Decoupling the form element -For more flexibility, you can decouple the form element from the form context by using the `decoupleFormElement` property. It is recommended to use the `Form.Element` to wrap your rest of your form: +For more flexibility, you can decouple the form element from the form context by using the `decoupleForm` property. It is recommended to use the `Form.Element` to wrap your rest of your form: ```jsx import { Form } from '@dnb/eufemia/extensions/forms' function MyApp() { return ( - <Form.Handler decoupleFormElement> + <Form.Handler decoupleForm> <AppRelatedThings> <Form.Element> <Form.MainHeading>Heading</Form.MainHeading> diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts index 899dced8734..4bec10bb3d9 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts @@ -182,7 +182,7 @@ export interface ContextState { required?: boolean submitState: Partial<EventStateObject> prerenderFieldProps?: boolean - decoupleFormElement?: boolean + decoupleForm?: boolean hasElementRef?: React.MutableRefObject<boolean> restHandlerProps?: Record<string, unknown> props: ProviderProps<unknown> diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx index f45ef195b0c..5a736c0832b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx @@ -16,11 +16,11 @@ export type Props = FormElementProps & { /** * Will decouple the form element from rendering */ - decoupleFormElement?: boolean + decoupleForm?: boolean } type AllowedProviderContextProps = ProviderProps<unknown> & - Pick<Props, 'decoupleFormElement' | 'autoComplete' | 'disabled'> & + Pick<Props, 'decoupleForm' | 'autoComplete' | 'disabled'> & Pick<ContextState, 'restHandlerProps' | 'hasElementRef'> const allowedProviderContextProps: Array< @@ -51,21 +51,21 @@ const allowedProviderContextProps: Array< 'autoComplete', 'disabled', 'required', - 'decoupleFormElement', + 'decoupleForm', 'restHandlerProps', ] export default function FormHandler<Data extends JsonObject>( props: ProviderProps<Data> & Omit<Props, keyof ProviderProps<Data>> ) { - const { decoupleFormElement, children } = props + const { decoupleForm, children } = props const hasElementRef = useRef(false) useEffect(() => { - if (decoupleFormElement && !hasElementRef.current) { - warn('Please include a Form.Element when using decoupleFormElement!') + if (decoupleForm && !hasElementRef.current) { + warn('Please include a Form.Element when using decoupleForm!') } - }, [decoupleFormElement]) + }, [decoupleForm]) const providerProps = { hasElementRef, @@ -86,11 +86,7 @@ export default function FormHandler<Data extends JsonObject>( return ( <DataContextProvider {...providerProps}> - {decoupleFormElement ? ( - children - ) : ( - <FormElement>{children}</FormElement> - )} + {decoupleForm ? children : <FormElement>{children}</FormElement>} </DataContextProvider> ) } diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx index b1ae7915977..0a9ac25f22e 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx @@ -1161,10 +1161,10 @@ describe('Form.Handler', () => { }) }) - describe('decoupleFormElement', () => { + describe('decoupleForm', () => { it('should contain one form element', () => { render( - <Form.Handler decoupleFormElement> + <Form.Handler decoupleForm> <Form.Element>content</Form.Element> </Form.Handler> ) @@ -1177,7 +1177,7 @@ describe('Form.Handler', () => { const onSubmit = jest.fn() render( - <Form.Handler decoupleFormElement onSubmit={onSubmit}> + <Form.Handler decoupleForm onSubmit={onSubmit}> <Form.Element>content</Form.Element> </Form.Handler> ) @@ -1189,7 +1189,7 @@ describe('Form.Handler', () => { it('should spread rest props to form element', () => { render( - <Form.Handler decoupleFormElement aria-label="Aria Label"> + <Form.Handler decoupleForm aria-label="Aria Label"> <Form.Element>content</Form.Element> </Form.Handler> ) @@ -1202,7 +1202,7 @@ describe('Form.Handler', () => { it('should overwrite rest props from handler', () => { render( - <Form.Handler decoupleFormElement aria-label="Aria Label"> + <Form.Handler decoupleForm aria-label="Aria Label"> <Form.Element aria-label="Overwrite">content</Form.Element> </Form.Handler> ) @@ -1215,7 +1215,7 @@ describe('Form.Handler', () => { it('should render form element inside wrapper', () => { render( - <Form.Handler decoupleFormElement> + <Form.Handler decoupleForm> <div className="wrapper"> <Form.Element>content</Form.Element> </div> @@ -1229,12 +1229,12 @@ describe('Form.Handler', () => { it('should warn when no form element is found', () => { const log = jest.spyOn(global.console, 'log').mockImplementation() - render(<Form.Handler decoupleFormElement>content</Form.Handler>) + render(<Form.Handler decoupleForm>content</Form.Handler>) expect(log).toHaveBeenCalledTimes(1) expect(log).toHaveBeenCalledWith( expect.any(String), - 'Please include a Form.Element when using decoupleFormElement!' + 'Please include a Form.Element when using decoupleForm!' ) log.mockRestore() From d02a0afa838e3eb0816d0d8a9d026f707c692c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= <tobias@tujo.no> Date: Tue, 26 Nov 2024 11:29:37 +0100 Subject: [PATCH 08/13] fix(forms): add `sessionStorageId` support to Field.Upload with empty file list rendering (#4339) When using `sessionStorageId` on `Form.Handler` alongside `Field.Upload` with a specified path, file data is stored. However, during serialization, Blob information is not preserved in session storage. As a result, reading this data causes `Field.Upload` to throw an exception. This PR ensures invalid files are not rendered, preventing such errors. In the future, we could explore storing Blobs in IndexedDB, but that would require a more extensive effort. --- .../extensions/forms/Field/Upload/Upload.tsx | 6 ++- .../Field/Upload/__tests__/Upload.test.tsx | 53 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx index b2ced390916..08dae3728d1 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx @@ -118,7 +118,11 @@ function UploadComponent(props: Props) { const { files: fileContext, setFiles } = useUpload(id) useEffect(() => { - setFiles(value) + // Files stored in session storage will not have a property (due to serialization). + const hasInvalidFiles = value?.some(({ file }) => !file?.name) + if (!hasInvalidFiles) { + setFiles(value) + } }, [setFiles, value]) const handleChangeAsync = useCallback( diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx index fe53f07ca5b..97488363022 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx @@ -1253,4 +1253,57 @@ describe('Field.Upload', () => { ) }) }) + + it('should not set files from session storage if they are invalid', async () => { + const file = createMockFile('fileName.png', 100, 'image/png') + + const { unmount } = render( + <Form.Handler sessionStorageId="session-storage-id"> + <Field.Upload path="/myFiles" /> + </Form.Handler> + ) + + const element = getRootElement() + + await waitFor(() => + fireEvent.drop(element, { + dataTransfer: { + files: [file], + }, + }) + ) + + expect( + document.querySelectorAll('.dnb-upload__file-cell').length + ).toBe(1) + + let dataContext = null + + // Don't rerender, but render again to make sure the files are not set + unmount() + render( + <Form.Handler sessionStorageId="session-storage-id"> + <Field.Upload path="/myFiles" /> + <DataContext.Consumer> + {(context) => { + dataContext = context + return null + }} + </DataContext.Consumer> + </Form.Handler> + ) + + expect(dataContext.internalDataRef.current.myFiles).toEqual([ + { + exists: false, + file: {}, + id: expect.any(String), + }, + ]) + const [title] = Array.from(document.querySelectorAll('p')) + expect(title).toHaveTextContent(nbShared.Upload.title) + expect( + document.querySelectorAll('.dnb-upload__file-cell').length + ).toBe(0) + }) }) From 96613ed769d80bcd0d51fed6e739ef0c405a3cd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= <tobias@tujo.no> Date: Tue, 26 Nov 2024 19:14:05 +0100 Subject: [PATCH 09/13] fix(NumberFormat): improve regex for parsing phone numbers with country codes (#4340) Fixes #4337 --- .../forms/Value/PhoneNumber/Examples.tsx | 10 ++- .../components/number-format/NumberUtils.js | 64 ++++++++------- .../__tests__/NumberUtils.test.ts | 79 ++++++++++++++++--- .../forms/Value/PhoneNumber/PhoneNumber.tsx | 8 +- .../extensions/forms/constants/countries.ts | 2 +- 5 files changed, 107 insertions(+), 56 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/PhoneNumber/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/PhoneNumber/Examples.tsx index 34b2c4e3d04..5de51301893 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/PhoneNumber/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/PhoneNumber/Examples.tsx @@ -1,5 +1,5 @@ import ComponentBox from '../../../../../../shared/tags/ComponentBox' -import { P } from '@dnb/eufemia/src' +import { Flex, P } from '@dnb/eufemia/src' import { Value } from '@dnb/eufemia/src/extensions/forms' export const Empty = () => { @@ -45,9 +45,11 @@ export const LabelAndValue = () => { export const InternationalSuffix = () => { return ( <ComponentBox> - <Value.PhoneNumber label="Label text" value="+47 98712345" /> - <Value.PhoneNumber label="Label text" value="+886 0998472751" /> - <Value.PhoneNumber label="Label text" value="+1-868 6758288" /> + <Flex.Stack> + <Value.PhoneNumber label="Label text" value="+47 98712345" /> + <Value.PhoneNumber label="Label text" value="+886 0998472751" /> + <Value.PhoneNumber label="Label text" value="+1-868 6758288" /> + </Flex.Stack> </ComponentBox> ) } diff --git a/packages/dnb-eufemia/src/components/number-format/NumberUtils.js b/packages/dnb-eufemia/src/components/number-format/NumberUtils.js index fef86cd0218..734860a9bac 100644 --- a/packages/dnb-eufemia/src/components/number-format/NumberUtils.js +++ b/packages/dnb-eufemia/src/components/number-format/NumberUtils.js @@ -261,11 +261,6 @@ export const format = ( currencyDisplay: 'name', }) aria = enhanceSR(cleanedNumber, aria, locale) // also calls prepareMinus - - // get only the currency name - // const num = aria.replace(/([^0-9])+$/g, '') - // const name = aria.replace(num, '') - // aria = cleanedNumber + name } else { handleCompactBeforeDisplay({ value, locale, compact, decimals, opts }) @@ -590,49 +585,52 @@ export const formatPhone = (number, locale = null) => { switch (locale) { default: { - // cleanup - number = String(number).replace(/[^+0-9]/g, '') - let code = '' - if ( - number.length > 8 && - number.substring(0, 2) !== '00' && - !number.startsWith('+') - ) { - number = '+' + number + number = String(number) + // Edge case for when a Norwegian number is given without a space after the country code + .replace(/^(00|\+|)47([^\s])/, '+47 $2') + .replace(/^00/, '+') + + if (number.substring(0, 1) === '+') { + const codeAndNumber = number.match( + // Split the number into the country code and the rest of the number + /^\+([\d-]{1,8})\s{0,2}([\d\s-]{1,20})$/ + ) + if (codeAndNumber) { + code = `+${codeAndNumber[1]} ` + number = codeAndNumber[2] + } } - if (number[0] === '+') { - code = number.substring(0, 3) + ' ' - number = number.substring(3) - } else if (number.substring(0, 2) === '00') { - code = number.substring(0, 4) + ' ' - number = number.substring(4) - } - code = code.replace(/^00/, '+') + number = number.replace(/[^+\d]/g, '') const length = number.length - // get 800 22 222 - if (length === 8 && number[0] === '8') { + // Get 800 22 222 + if (length === 8 && number.substring(0, 1) === '8') { display = code + number - .split(/([0-9]{3})([0-9]{2})/) + .split(/([\d]{3})([\d]{2})/) .filter((s) => s) .join(' ') } else { - // get 02000 + // Get 02000 if (length < 6) { display = code + number } else { - // get 6 or 8 formatting + if (code.includes('-')) { + // Convert +12-3456 to +12 (3456) + code = code.replace(/(\+[\d]{1,2})-([\d]{1,6})/, '$1 ($2)') + } + + // Get 6 or 8 formatting display = code + number .split( length === 6 - ? /^(\+[0-9]{2})|([0-9]{3})/ - : /^(\+[0-9]{2})|([0-9]{2})/ + ? /^(\+[\d]{2})|([\d]{3})/ + : /^(\+[\d]{2})|([\d]{2})/ ) .filter((s) => s) .join(' ') @@ -642,7 +640,7 @@ export const formatPhone = (number, locale = null) => { aria = code + number - .split(/([0-9]{2})/) + .split(/([\d]{2})/) .filter((s) => s) .join(' ') } @@ -671,7 +669,7 @@ export const formatBAN = (number, locale = null) => { switch (locale) { default: { - // get 2000 12 34567 + // Get 2000 12 34567 display = number .split(/([0-9]{4})([0-9]{2})([0-9]{1,})/) .filter((s) => s) @@ -707,7 +705,7 @@ export const formatORG = (number, locale = null) => { switch (locale) { default: { - // get 123 456 789 + // Get 123 456 789 display = number .split(/([0-9]{3})/) .filter((s) => s) @@ -743,7 +741,7 @@ export const formatNIN = (number, locale = null) => { switch (locale) { default: { - // get 180892 12345 + // Get 180892 12345 display = number .split(/([0-9]{6})/) .filter((s) => s) diff --git a/packages/dnb-eufemia/src/components/number-format/__tests__/NumberUtils.test.ts b/packages/dnb-eufemia/src/components/number-format/__tests__/NumberUtils.test.ts index 6acdbc6d9b4..02b61f55f70 100644 --- a/packages/dnb-eufemia/src/components/number-format/__tests__/NumberUtils.test.ts +++ b/packages/dnb-eufemia/src/components/number-format/__tests__/NumberUtils.test.ts @@ -4,6 +4,7 @@ */ import { mockClipboard } from '../../../core/jest/jestSetup' +import countries from '../../../extensions/forms/constants/countries' import { InternalLocale } from '../../../shared/Context' import { LOCALE } from '../../../shared/defaults' import * as helpers from '../../../shared/helpers' @@ -941,10 +942,28 @@ describe('formatPhone', () => { expect(number).toBe('12 34 56 78') }) - it('should format a phone number with country code', () => { - const result = formatPhone('+4712345678') - expect(result.number).toBe('+47 12 34 56 78') - expect(result.aria).toBe('+47 12 34 56 78') + it('should format a phone number with single country code', () => { + const result = formatPhone('+1 23456789') + expect(result.number).toBe('+1 23 45 67 89') + expect(result.aria).toBe('+1 23 45 67 89') + }) + + it('should format a phone number with three country code digits', () => { + const result = formatPhone('+358 23456789') + expect(result.number).toBe('+358 23 45 67 89') + expect(result.aria).toBe('+358 23 45 67 89') + }) + + it('should format a phone number with slash in country code', () => { + const result = formatPhone('+44-1534 12345678') + expect(result.number).toBe('+44 (1534) 12 34 56 78') + expect(result.aria).toBe('+44 (1534) 12 34 56 78') + }) + + it('should format a long number with', () => { + const result = formatPhone('+123456 123456789123456789') + expect(result.number).toBe('+123456 12 34 56 78 91 23 45 67 89') + expect(result.aria).toBe('+123456 12 34 56 78 91 23 45 67 89') }) it('should format a phone number without country code', () => { @@ -953,12 +972,6 @@ describe('formatPhone', () => { expect(result.aria).toBe('12 34 56 78') }) - it('should format a phone number with leading 00 country code', () => { - const result = formatPhone('004712345678') - expect(result.number).toBe('+47 12 34 56 78') - expect(result.aria).toBe('+47 12 34 56 78') - }) - it('should format a short phone number', () => { const result = formatPhone('12345') expect(result.number).toBe('12345') @@ -972,9 +985,9 @@ describe('formatPhone', () => { }) it('should handle invalid characters in phone number', () => { - const result = formatPhone('+47-123-456-78') - expect(result.number).toBe('+47 12 34 56 78') - expect(result.aria).toBe('+47 12 34 56 78') + const result = formatPhone('+123 123-456-78') + expect(result.number).toBe('+123 12 34 56 78') + expect(result.aria).toBe('+123 12 34 56 78') }) it('should handle empty input', () => { @@ -994,4 +1007,44 @@ describe('formatPhone', () => { expect(result.number).toBe('') expect(result.aria).toBe('') }) + + it.each(countries.map(({ cdc, i18n }) => [`${i18n.en}`, cdc]))( + 'should handle %s country code', + (_, cdc) => { + const result = formatPhone(`+${cdc} 12345678`) + + if (cdc.includes('-')) { + cdc = cdc.replace(/([\d]{1,2})-([\d]{1,6})/, '$1 ($2)') + } + + expect(result.number).toBe(`+${cdc} 12 34 56 78`) + expect(result.aria).toBe(`+${cdc} 12 34 56 78`) + } + ) + + describe('Norway', () => { + it('should format a the country code without space', () => { + const result = formatPhone('+4712345678') + expect(result.number).toBe('+47 12 34 56 78') + expect(result.aria).toBe('+47 12 34 56 78') + }) + + it('should format the country code without + or 00', () => { + const result = formatPhone('4712345678') + expect(result.number).toBe('+47 12 34 56 78') + expect(result.aria).toBe('+47 12 34 56 78') + }) + + it('should format the country code with 00', () => { + const result = formatPhone('004712345678') + expect(result.number).toBe('+47 12 34 56 78') + expect(result.aria).toBe('+47 12 34 56 78') + }) + + it('should format the country code with +', () => { + const result = formatPhone('+47 12345678') + expect(result.number).toBe('+47 12 34 56 78') + expect(result.aria).toBe('+47 12 34 56 78') + }) + }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/PhoneNumber/PhoneNumber.tsx b/packages/dnb-eufemia/src/extensions/forms/Value/PhoneNumber/PhoneNumber.tsx index 27bda94a6dd..28f2e42fa08 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Value/PhoneNumber/PhoneNumber.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Value/PhoneNumber/PhoneNumber.tsx @@ -1,9 +1,6 @@ import React, { useCallback } from 'react' import StringValue, { Props as StringValueProps } from '../String' -import { - format, - cleanNumber, -} from '../../../../components/number-format/NumberUtils' +import { format } from '../../../../components/number-format/NumberUtils' import useTranslation from '../../hooks/useTranslation' export type Props = StringValueProps @@ -15,7 +12,8 @@ function PhoneNumber(props: Props) { props.label ?? (props.inline ? undefined : translations.label) const toInput = useCallback((value) => { - return format(cleanNumber(value), { + // We can't use the "cleanNumber" function here, because we need to keep the country code separate from the number + return format(value, { phone: true, }).toString() }, []) diff --git a/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts b/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts index f4acbd8d33a..c0e0e90fe74 100644 --- a/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts +++ b/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts @@ -1650,7 +1650,7 @@ const countries: Array<CountryType> = [ en: 'Puerto Rico', nb: 'Puerto Rico', }, - cdc: '1-787, 1-939', + cdc: '1-787', iso: 'PR', continent: 'North America', }, From 4a0fea1b72606f88ef384ce14b91755deef83a3b Mon Sep 17 00:00:00 2001 From: Snorre Kim <snorre.kim@dnb.no> Date: Wed, 27 Nov 2024 13:38:13 +0100 Subject: [PATCH 10/13] chore(portal): expand button no longer below scrollbar (#4338) * Also fixed not scrolling to current page when opening menu on small screens --- .../src/shared/menu/SidebarMenu.module.scss | 4 ++-- .../dnb-design-system-portal/src/shared/menu/SidebarMenu.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/dnb-design-system-portal/src/shared/menu/SidebarMenu.module.scss b/packages/dnb-design-system-portal/src/shared/menu/SidebarMenu.module.scss index 8a9c19a8994..af09e235248 100644 --- a/packages/dnb-design-system-portal/src/shared/menu/SidebarMenu.module.scss +++ b/packages/dnb-design-system-portal/src/shared/menu/SidebarMenu.module.scss @@ -234,7 +234,7 @@ &__expand-button { align-self: center; - margin-right: 0.5rem; + margin-right: 1rem; &:hover { background-color: white; outline: 1px solid currentcolor; @@ -255,7 +255,7 @@ } > .dnb-sidebar-menu__item .dnb-sidebar-menu__expand-button { - margin-right: 0.25rem; + margin-right: 0.75rem; } } } diff --git a/packages/dnb-design-system-portal/src/shared/menu/SidebarMenu.tsx b/packages/dnb-design-system-portal/src/shared/menu/SidebarMenu.tsx index 4e9b32e1cc5..abb8f855952 100644 --- a/packages/dnb-design-system-portal/src/shared/menu/SidebarMenu.tsx +++ b/packages/dnb-design-system-portal/src/shared/menu/SidebarMenu.tsx @@ -417,6 +417,7 @@ function ListItem({ </div> {hasSubheadings && ( <HeightAnimation + animate={isAccordion === true} element="ul" open={isExpanded} onAnimationEnd={(state) => { @@ -433,7 +434,6 @@ function ListItem({ </HeightAnimation> )} </li> - {/* Currently not nesting list items with an <ul/> inside <li/> as it breaks the styling for the time being */} </> ) } From 10b199bfab4a040bd39e12a06df74385286d7c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= <tobias@tujo.no> Date: Fri, 29 Nov 2024 10:07:18 +0100 Subject: [PATCH 11/13] feat(Forms): enhance typing and add docs on how to deal with TypeScript types (#4343) The main change is that we go from: ```tsx type JsonObject = any ``` to ```tsx type JsonObject = Record<string | number, unknown> | Array<unknown> ``` and enhance the TypeScript support docs for the "Getting Started" section the `Form.Handler` and `Form.Isolation` docs. To disable types you can do so by: ```tsx <Form.Handler<any>> ... </Form.Handler> ``` or ```tsx const { data } = Form.useData<any>() ``` --- .../forms/DataContext/Provider/Examples.tsx | 20 +-- .../extensions/forms/Form/Handler/info.mdx | 116 +++++++++++------- .../forms/Form/Isolation/Examples.tsx | 23 ++-- .../extensions/forms/Form/Isolation/info.mdx | 38 +++++- .../extensions/forms/Form/useData/info.mdx | 19 +-- .../forms/Iterate/PushContainer/Examples.tsx | 7 +- .../extensions/forms/getting-started.mdx | 42 ++++++- .../extensions/forms/DataContext/Context.ts | 4 +- .../forms/DataContext/Provider/Provider.tsx | 4 +- .../Provider/__tests__/Provider.test.tsx | 16 ++- .../forms/Field/Provider/FieldProvider.tsx | 5 +- .../extensions/forms/Form/Handler/Handler.tsx | 2 +- .../Form/Handler/__tests__/Handler.test.tsx | 46 +++++++ .../forms/Form/Isolation/Isolation.tsx | 9 +- .../Isolation/__tests__/Isolation.test.tsx | 5 +- .../Isolation/stories/Isolation.stories.tsx | 5 +- .../extensions/forms/Form/Section/Section.tsx | 22 ++-- .../Iterate/stories/PushContainer.stories.tsx | 4 +- .../extensions/forms/Tools/ListAllProps.tsx | 19 +-- .../Tools/__tests__/ListAllProps.test.tsx | 12 +- .../extensions/forms/hooks/useSnapshot.tsx | 12 +- .../dnb-eufemia/src/extensions/forms/types.ts | 13 +- .../forms/utils/json-pointer/json-pointer.ts | 4 +- 23 files changed, 309 insertions(+), 138 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/DataContext/Provider/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/DataContext/Provider/Examples.tsx index 20e00c36cbc..7c6912b7a4c 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/DataContext/Provider/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/DataContext/Provider/Examples.tsx @@ -8,7 +8,7 @@ import { } from '@dnb/eufemia/src/extensions/forms' import { Flex } from '@dnb/eufemia/src' -export const TestdataSchema: JSONSchema = { +export const TestDataSchema: JSONSchema = { type: 'object', properties: { requiredString: { type: 'string' }, @@ -37,7 +37,7 @@ export const TestdataSchema: JSONSchema = { required: ['requiredString'], } -export interface Testdata { +export type TestData = { requiredString: string string?: string number?: number @@ -53,7 +53,7 @@ export interface Testdata { }> } -export const testdata: Testdata = { +export const testData: TestData = { requiredString: 'This is a text', string: 'String value', number: 123, @@ -81,12 +81,12 @@ export const Default = () => { scope={{ DataContext, Value, - testdata, - TestdataSchema, + testData, + TestDataSchema, }} > <DataContext.Provider - defaultData={testdata} + defaultData={testData} onChange={(data) => console.log('onChange', data)} onPathChange={(path, value) => console.log('onPathChange', path, value) @@ -177,13 +177,13 @@ export const ValidationWithJsonSchema = () => { scope={{ DataContext, Value, - testdata, - TestdataSchema, + testData, + TestDataSchema, }} > <DataContext.Provider - data={testdata} - schema={TestdataSchema} + data={testData} + schema={TestDataSchema} onChange={(data) => console.log('onChange', data)} onPathChange={(path, value) => console.log('onPathChange', path, value) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx index cd52cd8f912..8482711bd81 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx @@ -9,7 +9,7 @@ import AsyncChangeExample from './parts/async-change-example.mdx' The `Form.Handler` is the root component of your form. It provides a HTML form element and handles the form data. -```jsx +```tsx import { Form } from '@dnb/eufemia/extensions/forms' const existingData = { firstName: 'Nora' } @@ -26,6 +26,75 @@ function MyForm() { } ``` +### TypeScript support + +You can define the TypeScript type structure for your data like so: + +```tsx +import { Form } from '@dnb/eufemia/extensions/forms' + +type MyDataContext = { + firstName?: string +} + +// Method #1 – without initial data +function MyForm() { + return ( + <Form.Handler<MyDataContext> + onSubmit={(data) => { + // "firstName" is of type string + console.log(data.firstName) + }} + > + ... + </Form.Handler> + ) +} + +// Method #2 – with data (initial values) +const existingData: MyDataContext = { + firstName: 'Nora', +} +function MyForm() { + return ( + <Form.Handler + defaultData={existingData} + onSubmit={(data) => { + // "firstName" is of type string + console.log(data.firstName) + }} + > + ... + </Form.Handler> + ) +} + +// Method #3 – type definition on the event parameter +const submitHandler = (data: MyDataContext) => { + // "firstName" is of type string + console.log(data.firstName) +} +function MyForm() { + return <Form.Handler onSubmit={submitHandler}>...</Form.Handler> +} + +// Method #4 – type definition for the submit handler +import type { OnSubmit } from '@dnb/eufemia/extensions/forms' +const submitHandler: OnSubmit<MyDataContext> = (data) => { + // "firstName" is of type string + console.log(data.firstName) +} +function MyForm() { + return <Form.Handler onSubmit={submitHandler}>...</Form.Handler> +} +``` + +To disable types you can: + +```tsx +<Form.Handler<any>>...</Form.Handler> +``` + ## Decoupling the form element For more flexibility, you can decouple the form element from the form context by using the `decoupleForm` property. It is recommended to use the `Form.Element` to wrap your rest of your form: @@ -116,51 +185,6 @@ function MyApp() { More examples can be found in the [useData](/uilib/extensions/forms/Form/useData/) hook docs. -### TypeScript support - -You can define the TypeScript type structure for your data like so: - -```tsx -import { Form } from '@dnb/eufemia/extensions/forms' - -type MyDataSet = { - firstName?: string -} - -const data: MyDataSet = { - firstName: 'Nora', -} - -// Method #1 -function MyForm() { - return ( - <Form.Handler - defaultData={data} - onSubmit={(data) => { - console.log(data.firstName) - }} - /> - ) -} - -// Method #2 -const submitHandler = (data: MyDataSet) => { - console.log(data.firstName) -} -function MyForm() { - return <Form.Handler defaultData={data} onSubmit={submitHandler} /> -} - -// Method #3 -import type { OnSubmit } from '@dnb/eufemia/extensions/forms' -const submitHandler: OnSubmit<MyDataSet> = (data) => { - console.log(data.firstName) -} -function MyForm() { - return <Form.Handler defaultData={data} onSubmit={submitHandler} /> -} -``` - ## Async `onChange` and `onSubmit` event handlers **NB:** When using an async `onChange` event handler, the `data` parameter will only include validated data. This lets you utilize the `data` parameter directly in your request, without having to further process or transform it. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/Examples.tsx index f4f0547bb9b..8efe06bec36 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/Examples.tsx @@ -66,14 +66,17 @@ export const CommitHandleRef = () => { <Form.Isolation commitHandleRef={commitHandleRef} transformOnCommit={(isolatedData, handlerData) => { - const value = - isolatedData.newPerson.title.toLowerCase() + // Because of missing TypeScript support + const contactPersons = handlerData['contactPersons'] + const newPerson = isolatedData['newPerson'] + + const value = newPerson.title.toLowerCase() const transformedData = { ...handlerData, contactPersons: [ - ...handlerData.contactPersons, + ...contactPersons, { - ...isolatedData.newPerson, + ...newPerson, value, }, ], @@ -151,14 +154,18 @@ export const TransformCommitData = () => { <Form.Isolation transformOnCommit={(isolatedData, handlerData) => { + // Because of missing TypeScript support + const contactPersons = + handlerData['contactPersons'] + const newPerson = isolatedData['newPerson'] + return { ...handlerData, contactPersons: [ - ...handlerData.contactPersons, + ...contactPersons, { - ...isolatedData.newPerson, - value: - isolatedData.newPerson.title.toLowerCase(), + ...newPerson, + value: newPerson.title.toLowerCase(), }, ], } diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/info.mdx index d28b6bcd1d9..871d6bdae1b 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/info.mdx @@ -42,6 +42,37 @@ export function MyForm(props) { } ``` +## TypeScript support + +You can define the TypeScript type structure for your data like so: + +```tsx +import { Form, Field } from '@dnb/eufemia/extensions/forms' + +type IsolationData = { + persons: Array<{ name: string }> + newPerson: Array<{ name: string }> +} + +function MyForm() { + return ( + <Form.Isolation<IsolationData> + onCommit={(data) => { + data // <-- is of type IsolationData + }} + transformOnCommit={(isolatedData, handlerData) => { + return { + ...handlerData, + persons: [...handlerData.persons, isolatedData.newPerson], + } + }} + > + ... + </Form.Isolation> + ) +} +``` + ## Commit the data to the form You can either use the `Form.Isolation.CommitButton` or provide a custom ref handler you can use (call) when you want to commit the data to the `Form.Handler` context: @@ -111,7 +142,7 @@ render( ## Clear data from isolated fields -You can clear the isolation by calling `Form.clearData` with the `id` of the form. +You can clear the isolation by calling `clearData`: ```jsx import { Form, Field } from '@dnb/eufemia/extensions/forms' @@ -120,9 +151,8 @@ function MyForm() { return ( <Form.Handler> <Form.Isolation - id="my-isolated-data" - onCommit={() => { - Form.clearData('my-isolated-data') + onCommit={(data, { clearData }) => { + clearData() }} > <Field.String path="/isolated" /> diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx index 98e561d6ebb..19f6733aaba 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx @@ -44,6 +44,16 @@ render( You can use the `Form.useData` hook with or without an `id` (string, function, object or React Context as the reference) property, which is optional and can be used to link the data to a specific [Form.Handler](/uilib/extensions/forms/Form/Handler/) component. +### TypeScript support + +You can define the TypeScript type structure for your data like so: + +```tsx +type Data = { foo: string } + +const { data } = Form.useData<Data>() +``` + ### Without an `id` property Here "Component" is rendered inside the `Form.Handler` component and does not need an `id` property to access the form data: @@ -89,15 +99,6 @@ function Component() { This is beneficial when you need to utilize the form data in other places within your application. -### TypeScript support - -You can define the TypeScript type structure for your data like so: - -```tsx -type Data = { foo: string } -const { data } = Form.useData<Data>('unique') -``` - ### Select a single value ```jsx diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/Examples.tsx index 6485fe45460..f9d8d5151ec 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/Examples.tsx @@ -143,7 +143,7 @@ export const IsolatedData = () => { function ExistingPersonDetails() { const { data, getValue } = Form.useData() - const person = getValue(data.selectedPerson)?.data || {} + const person = getValue(data['selectedPerson'])?.data || {} return ( <Flex.Stack> @@ -172,14 +172,15 @@ export const IsolatedData = () => { function PushContainerContent() { const { data, update } = Form.useData() + const selectedPerson = data['selectedPerson'] // Because of missing TypeScript support // Clear the PushContainer data when the selected person is "other", // so the fields do not inherit existing data. React.useLayoutEffect(() => { - if (data.selectedPerson === 'other') { + if (selectedPerson === 'other') { update('/pushContainerItems/0', {}) } - }, [data.selectedPerson, update]) + }, [selectedPerson, update]) return ( <Flex.Stack> diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx index a533a97e9e0..a90124edf63 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx @@ -20,6 +20,7 @@ import AsyncChangeExample from './Form/Handler/parts/async-change-example.mdx' - [Getting started](#getting-started) - [Creating forms](#creating-forms) + - [TypeScript support](#typescript-support) - [State management](#state-management) - [What is a JSON Pointer?](#what-is-a-json-pointer) - [Data handling](#data-handling) @@ -62,6 +63,37 @@ To build an entire form, there are surrounding components such as form [Handler] The needed styles are included in the Eufemia core package via `dnb-ui-components`. +### TypeScript support + +You can define the TypeScript type structure for your data like so: + +```tsx +import { Form } from '@dnb/eufemia/extensions/forms' + +type MyDataContext = { + firstName?: string +} + +function MyForm() { + return ( + <Form.Handler<MyDataContext> + onSubmit={(data) => { + console.log(data.firstName) // "firstName" is of type string + }} + > + <MyComponent /> + </Form.Handler> + ) +} + +const MyComponent = () => { + const { data } = Form.useData<MyDataContext>() + return data.firstName +} +``` + +Read more about TypeScript support and the other methods in the [Form.Handler](/uilib/extensions/forms/Form/Handler/#typescript-support) section or in the [Form.useData](/uilib/extensions/forms/Form/useData/#typescript-support) hook docs. + ### State management While you can use a controlled method of handling the sate of fields or your form, it is recommended to use a declarative approach, where you keep the state of your form inside the data context, instead of pulling it out via your own `useState` hooks (imperative). @@ -121,7 +153,7 @@ How do I handle complex data logic? You can show or hide parts of your form based on your own logic. This is done by using the [Visibility](/uilib/extensions/forms/Form/Visibility/) component (yes, it even can animate the visibility). -And you can access and modify your form data with [useData](/uilib/extensions/forms/Form/useData/) or [getData](/uilib/extensions/forms/Form/getData/) or even [setData](/uilib/extensions/forms/Form/setData/). +And you can access and modify your form data with [Form.useData](/uilib/extensions/forms/Form/useData/) or [getData](/uilib/extensions/forms/Form/getData/) or even [setData](/uilib/extensions/forms/Form/setData/). Here is an example of how to use these methods: @@ -166,7 +198,7 @@ const { getValue, data, filterData, reduceToVisibleFields } = - `filterData` will filter the data based on your own logic. - `reduceToVisibleFields` will reduce the given data set to only contain the visible fields (mounted fields). -As you can see in the code above, you can even handle the state outside the `Form.Handler` context. You find more details on this topic in the [useData](/uilib/extensions/forms/Form/useData/) documentation. +As you can see in the code above, you can even handle the state outside the `Form.Handler` context. You find more details on this topic in the [Form.useData](/uilib/extensions/forms/Form/useData/) documentation. <Examples.GettingStarted /> @@ -183,9 +215,9 @@ When submitting data to the server, you might want to exclude data that has been In this section we will show how to filter out some data based on your own logic. You can filter data by any given criteria. This is done by utilizing the `filterData` method from e.g.: -- [useData](/uilib/extensions/forms/Form/useData/#filter-data) hook. -- [getData](/uilib/extensions/forms/Form/getData/#filter-data) method. -- [Visibility](/uilib/extensions/forms/Form/Visibility/#filter-data) component. +- [Form.useData](/uilib/extensions/forms/Form/useData/#filter-data) hook. +- [Form.getData](/uilib/extensions/forms/Form/getData/#filter-data) method. +- [Form.Visibility](/uilib/extensions/forms/Form/Visibility/#filter-data) component. You can provide either a function handler or an object with the paths (JSON Pointer) you want to filter out. diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts index 4bec10bb3d9..edfe34510f1 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts @@ -1,5 +1,5 @@ import React from 'react' -import { Ajv, FormError, makeAjvInstance } from '../utils' +import { Ajv, FormError, JsonObject, makeAjvInstance } from '../utils' import { AllJSONSchemaVersions, GlobalErrorMessagesWithPaths, @@ -185,7 +185,7 @@ export interface ContextState { decoupleForm?: boolean hasElementRef?: React.MutableRefObject<boolean> restHandlerProps?: Record<string, unknown> - props: ProviderProps<unknown> + props: ProviderProps<JsonObject> } export const defaultContextState: ContextState = { diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx index 8cb7ae6afd2..d185f0c27da 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -1156,11 +1156,11 @@ export default function Provider<Data extends JsonObject>( } const transformData = (data: Data, handler: TransformData) => { - return mutateDataHandler(data, handler) as TransformData + return mutateDataHandler(data, handler) } const formElement = formElementRef.current - const params: OnSubmitParams = { + const params: OnSubmitParams<Data> = { filterData, reduceToVisibleFields, transformData, diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx index dbb5210eb6b..cc4c06c3e9f 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx @@ -1824,7 +1824,9 @@ describe('DataContext.Provider', () => { }) it('should show error on the field from the event return when complete', async () => { - const onChangeForm: OnChange = async ({ myField }) => { + const onChangeForm: OnChange<{ myField: string }> = async ({ + myField, + }) => { if (myField === 'onChangeForm-error') { return Error('onChangeForm-error') } @@ -1874,7 +1876,9 @@ describe('DataContext.Provider', () => { }) it('should show status message on the field from the event return when complete', async () => { - const onChangeForm: OnChange = async ({ myField }) => { + const onChangeForm: OnChange<{ myField: string }> = async ({ + myField, + }) => { if (myField === 'onChangeForm-info') { return { info: 'onChangeForm-info' } } @@ -1926,7 +1930,9 @@ describe('DataContext.Provider', () => { }) it('should show all status messages on the field from the event return when complete', async () => { - const onChangeForm: OnChange = async ({ myField }) => { + const onChangeForm: OnChange<{ myField: string }> = async ({ + myField, + }) => { return { info: 'onChangeForm-info', error: @@ -1989,7 +1995,9 @@ describe('DataContext.Provider', () => { }) it('should fulfill async onChangeValidator before the form and field event', async () => { - const onChangeForm: OnChange = async ({ myField }) => { + const onChangeForm: OnChange<{ myField: string }> = async ({ + myField, + }) => { return { info: 'onChangeForm-info', error: diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Provider/FieldProvider.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Provider/FieldProvider.tsx index d9e5c14044b..1816ecfce7a 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Provider/FieldProvider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Provider/FieldProvider.tsx @@ -6,6 +6,7 @@ import SharedProvider from '../../../../shared/Provider' import { ContextProps } from '../../../../shared/Context' import useFieldProvider from './useFieldProvider' import { FieldProps, Path } from '../../types' +import { JsonObject } from '../../utils' export type FieldProviderProps = FieldProps & { children: React.ReactNode @@ -13,12 +14,12 @@ export type FieldProviderProps = FieldProps & { /** * Locale to use for all nested Eufemia components */ - locale?: DataContextProps<unknown>['locale'] + locale?: DataContextProps<JsonObject>['locale'] /** * Provide your own translations. Use the same format as defined in the translation files */ - translations?: DataContextProps<unknown>['translations'] + translations?: DataContextProps<JsonObject>['translations'] /** For internal use only */ overwriteProps?: { diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx index 5a736c0832b..a48ad88eda1 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx @@ -19,7 +19,7 @@ export type Props = FormElementProps & { decoupleForm?: boolean } -type AllowedProviderContextProps = ProviderProps<unknown> & +type AllowedProviderContextProps = ProviderProps<JsonObject> & Pick<Props, 'decoupleForm' | 'autoComplete' | 'disabled'> & Pick<ContextState, 'restHandlerProps' | 'hasElementRef'> diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx index 0a9ac25f22e..890bd94fbf9 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx @@ -19,6 +19,52 @@ const nb = nbNO['nb-NO'] const en = enGB['en-GB'] describe('Form.Handler', () => { + it('should support types given to the Form.Handler', () => { + let value = null + + type MyDataContext = { + firstName?: string + } + + render( + <Form.Handler<MyDataContext> + onSubmit={(data) => { + value = data.firstName + }} + > + <Field.String path="/firstName" value="Value" /> + </Form.Handler> + ) + + fireEvent.submit(document.querySelector('form')) + expect(value).toBe('Value') + }) + + it('should support types given to the data prop', () => { + let value = null + + type MyDataContext = { + firstName?: string + } + const data: MyDataContext = { + firstName: 'Value', + } + + render( + <Form.Handler + data={data} + onSubmit={(data) => { + value = data.firstName + }} + > + <Field.String path="/firstName" /> + </Form.Handler> + ) + + fireEvent.submit(document.querySelector('form')) + expect(value).toBe('Value') + }) + it('should call "onSubmit"', () => { const onSubmit: OnSubmit = jest.fn() diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/Isolation.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/Isolation.tsx index c236a2daf56..860ce99a315 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/Isolation.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/Isolation.tsx @@ -30,7 +30,7 @@ import type { OnCommit, Path } from '../../types' */ import structuredClone from '@ungap/structured-clone' -export type IsolationProviderProps<Data> = { +export type IsolationProviderProps<Data extends JsonObject> = { /** * Form.Isolation: Will be called when the isolated context is committed. */ @@ -44,10 +44,7 @@ export type IsolationProviderProps<Data> = { * It will receive the data from the isolated context and the data from the outer context. * You can use this to transform the data before it is committed. */ - transformOnCommit?: ( - isolatedData: JsonObject, - handlerData: JsonObject - ) => unknown + transformOnCommit?: (isolatedData: Data, handlerData: Data) => JsonObject /** * Prevent the form from being submitted when there are fields with errors inside the Form.Isolation. */ @@ -62,7 +59,7 @@ export type IsolationProviderProps<Data> = { isolate?: boolean } -export type IsolationProps<Data> = Omit< +export type IsolationProps<Data extends JsonObject> = Omit< ProviderProps<Data>, | 'onSubmit' | 'onSubmitRequest' diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/__tests__/Isolation.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/__tests__/Isolation.test.tsx index 1977b8a86e6..d652a37087c 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/__tests__/Isolation.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/__tests__/Isolation.test.tsx @@ -1367,7 +1367,10 @@ describe('Form.Isolation', () => { data={{ existing: 'data', persons: [{ name: 'John' }] }} onChange={onChange} > - <Form.Isolation + <Form.Isolation<{ + persons: Array<{ name: string }> + newPerson: Array<{ name: string }> + }> transformOnCommit={(isolatedData, handlerData) => { return { ...handlerData, diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/stories/Isolation.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/stories/Isolation.stories.tsx index f3b998dda5f..beb81a8f55b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/stories/Isolation.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/stories/Isolation.stories.tsx @@ -138,7 +138,10 @@ export const TransformOnCommit = () => { <Flex.Stack> <Form.SubHeading>Ny hovedkontaktperson</Form.SubHeading> - <Form.Isolation + <Form.Isolation<{ + newPerson: { title: string } + contactPersons: Array<{ title: string; value: string }> + }> transformOnCommit={(isolatedData, handlerData) => { return { ...handlerData, diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Section/Section.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Section/Section.tsx index ae593c8371d..71fd440da74 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Section/Section.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Section/Section.tsx @@ -19,7 +19,10 @@ export type OverwritePropsDefaults = { | (FieldProps & SharedFieldBlockProps) | OverwritePropsDefaults } -export type SectionProps<overwriteProps = OverwritePropsDefaults> = { +export type SectionProps< + overwriteProps = OverwritePropsDefaults, + Data extends JsonObject = JsonObject, +> = { /** * Path to the section. * When defined, fields inside the section will get this path as a prefix of their own path. @@ -55,15 +58,18 @@ export type SectionProps<overwriteProps = OverwritePropsDefaults> = { */ errorPrioritization?: SectionContextState['errorPrioritization'] } & Pick< - DataContextProps<JsonObject>, + DataContextProps<Data>, 'data' | 'defaultData' | 'onChange' | 'translations' > -export type LocalProps = SectionProps & { - children: React.ReactNode -} +export type LocalProps<overwriteProps = OverwritePropsDefaults> = + SectionProps<overwriteProps> & { + children: React.ReactNode + } -function SectionComponent(props: LocalProps) { +function SectionComponent<overwriteProps = OverwritePropsDefaults>( + props: LocalProps<overwriteProps> +) { const { path, overwriteProps, @@ -108,12 +114,14 @@ function SectionComponent(props: LocalProps) { ) } + const sectionProps = props as SectionProps + return ( <SectionContext.Provider value={{ path: identifier, errorPrioritization, - props, + props: sectionProps, }} > <SectionContainerProvider diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/PushContainer.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/PushContainer.stories.tsx index 8873adc93da..14b0c715b41 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/PushContainer.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/PushContainer.stories.tsx @@ -60,7 +60,7 @@ function RepresentativesEdit() { } function ExistingPersonDetails() { - const { data, getValue } = Form.useData() + const { data, getValue } = Form.useData<{ selectedPerson: string }>() const person = getValue(data.selectedPerson)?.data || {} return ( @@ -89,7 +89,7 @@ function NewPersonDetails() { } function PushContainerContent() { - const { data, update } = Form.useData() + const { data, update } = Form.useData<{ selectedPerson: string }>() // Clear the PushContainer data when the selected person is "other", // so the fields do not inherit existing data. diff --git a/packages/dnb-eufemia/src/extensions/forms/Tools/ListAllProps.tsx b/packages/dnb-eufemia/src/extensions/forms/Tools/ListAllProps.tsx index 301a0c47fa6..0fda1113732 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Tools/ListAllProps.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Tools/ListAllProps.tsx @@ -2,19 +2,22 @@ import { isValidElement, useCallback, useContext, useRef } from 'react' import pointer, { JsonObject } from '../utils/json-pointer' import DataContext, { FilterData } from '../DataContext/Context' -export type ListAllPropsReturn = { - propsOfFields: JsonObject - propsOfValues: JsonObject +export type ListAllPropsReturn<Data> = { + propsOfFields: Data + propsOfValues: Data } -export type ListAllPropsProps = { +export type ListAllPropsProps<Data> = { log?: boolean - generateRef?: React.MutableRefObject<() => ListAllPropsReturn> + generateRef?: React.MutableRefObject<() => ListAllPropsReturn<Data>> filterData?: FilterData children: React.ReactNode } -export type GenerateRef = ListAllPropsProps['generateRef']['current'] +export type GenerateRef<Data extends JsonObject = JsonObject> = + ListAllPropsProps<Data>['generateRef']['current'] -export default function ListAllProps(props: ListAllPropsProps) { +export default function ListAllProps<Data extends JsonObject = JsonObject>( + props: ListAllPropsProps<Data> +) { const { log, generateRef, filterData, children } = props || {} const { fieldPropsRef, valuePropsRef, data, hasContext } = useContext(DataContext) @@ -71,7 +74,7 @@ export default function ListAllProps(props: ListAllPropsProps) { return acc }, {}) - return { propsOfFields, propsOfValues } as ListAllPropsReturn + return { propsOfFields, propsOfValues } as ListAllPropsReturn<Data> }, [fieldPropsRef, filterData, valuePropsRef]) if (hasContext) { diff --git a/packages/dnb-eufemia/src/extensions/forms/Tools/__tests__/ListAllProps.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Tools/__tests__/ListAllProps.test.tsx index 07e7d3e622e..5326d707cda 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Tools/__tests__/ListAllProps.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Tools/__tests__/ListAllProps.test.tsx @@ -635,7 +635,17 @@ describe('Tools.ListAllProps', () => { }) it('should filter out React elements', () => { - const generateRef = React.createRef<GenerateRef>() + const generateRef = React.createRef< + GenerateRef<{ + items: { + children: { + type: { + name: string + } + } + } + }> + >() render( <Form.Handler data={{ count: 2 }}> diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useSnapshot.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/useSnapshot.tsx index 62da13025d8..b56f06bc2f5 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useSnapshot.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useSnapshot.tsx @@ -1,13 +1,13 @@ import { useCallback, useRef } from 'react' import { makeUniqueId } from '../../../shared/component-helper' -import pointer from '../utils/json-pointer' +import pointer, { JsonObject } from '../utils/json-pointer' import { SharedStateId } from '../../../shared/helpers/useSharedState' import useDataContext from './useDataContext' import { SnapshotId, SnapshotName } from '../Form/Snapshot' import useData from '../Form/data-context/useData' export default function useSnapshot(id?: SharedStateId) { - const internalSnapshotsRef = useRef<Map<SnapshotId, unknown>>() + const internalSnapshotsRef = useRef<Map<SnapshotId, JsonObject>>() if (!internalSnapshotsRef.current) { internalSnapshotsRef.current = new Map() } @@ -19,14 +19,14 @@ export default function useSnapshot(id?: SharedStateId) { ( id: SnapshotId = makeUniqueId(), name: SnapshotName = null, - content: unknown = null + content: JsonObject = null ): SnapshotId => { const { internalDataRef, snapshotsRef } = getContext() if (!content) { const snapshotWithPaths = snapshotsRef?.current?.get?.(name) if (snapshotWithPaths) { - const collectedData = new Map() + const collectedData: Map<string, JsonObject> = new Map() snapshotWithPaths.forEach((isMounted, path) => { if (isMounted && pointer.has(internalDataRef.current, path)) { collectedData.set( @@ -35,7 +35,7 @@ export default function useSnapshot(id?: SharedStateId) { ) } }) - content = collectedData + content = collectedData as unknown as JsonObject } else { content = internalDataRef.current } @@ -52,7 +52,7 @@ export default function useSnapshot(id?: SharedStateId) { ) const getSnapshot = useCallback( - (id: SnapshotId, name: SnapshotName = null): unknown => { + (id: SnapshotId, name: SnapshotName = null): JsonObject => { return internalSnapshotsRef.current.get(combineIdWithName(id, name)) }, [] diff --git a/packages/dnb-eufemia/src/extensions/forms/types.ts b/packages/dnb-eufemia/src/extensions/forms/types.ts index 3228bb4162a..89bbcf96a57 100644 --- a/packages/dnb-eufemia/src/extensions/forms/types.ts +++ b/packages/dnb-eufemia/src/extensions/forms/types.ts @@ -562,21 +562,18 @@ export type EventReturnWithStateObjectAndSuccess = | Error | EventStateObjectWithSuccess -export type OnSubmitParams = { +export type OnSubmitParams<Data = JsonObject> = { /** Will remove data entries of fields that are not visible */ reduceToVisibleFields: ( - data: JsonObject, + data: Data, options?: VisibleDataOptions - ) => Partial<JsonObject> + ) => Partial<Data> /** Will call the given function for each data path. The returned `value` will replace each data entry. It's up to you to define the shape of the value. */ - transformData: ( - data: JsonObject, - handler: TransformData - ) => TransformData + transformData: (data: Data, handler: TransformData) => Partial<Data> /** Will filter data based on the given "filterDataHandler" method */ - filterData: (filterDataHandler: FilterData) => Partial<JsonObject> + filterData: (filterDataHandler: FilterData) => Partial<Data> /** Will remove browser-side stored autocomplete data */ resetForm: () => void diff --git a/packages/dnb-eufemia/src/extensions/forms/utils/json-pointer/json-pointer.ts b/packages/dnb-eufemia/src/extensions/forms/utils/json-pointer/json-pointer.ts index 0e921caf43f..5ca5485846f 100644 --- a/packages/dnb-eufemia/src/extensions/forms/utils/json-pointer/json-pointer.ts +++ b/packages/dnb-eufemia/src/extensions/forms/utils/json-pointer/json-pointer.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ export type PointerPath = string | Array<string> -export type JsonValue = any -export type JsonObject = any +export type JsonValue = unknown +export type JsonObject = Record<string | number, unknown> | Array<unknown> /** * Lookup a json pointer in an object From 4cd52a3ac3e4c1e53332de7d920e104a68d87d7f Mon Sep 17 00:00:00 2001 From: Joakim Bjerknes <joakbjerk@gmail.com> Date: Fri, 29 Nov 2024 10:13:47 +0100 Subject: [PATCH 12/13] fix(DatePicker): make sure the picker and input only reacts to the props that have changed (#4342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [x] Add test - [x] FIx same issue in `range` mode - [x] Update failing `Field.Date` test - [x] Clean up `updateBasedOnProps` code Should hopefully fix the issue described [here](https://dnb-it.slack.com/archives/CMXABCHEY/p1732031524606929) Neither of us were able to recreate the issue in csb, so testing this manually might be a challenge This also improves input deletion behaviour when the dates are prop controlled Before: https://github.com/user-attachments/assets/2331ac62-e758-44aa-8a93-fcd042a7fc6e After: https://github.com/user-attachments/assets/6c3aa9a3-1d01-415e-8028-6626d42b0e3c --------- Co-authored-by: Tobias Høegh <tobias@tujo.no> --- .../date-picker/DatePickerContext.ts | 2 +- .../date-picker/DatePickerFooter.tsx | 16 +- .../date-picker/DatePickerProvider.tsx | 35 +- .../date-picker/__tests__/DatePicker.test.tsx | 305 +++++++++++++++--- .../components/date-picker/hooks/useDates.ts | 168 ++++++++-- .../src/extensions/forms/Field/Date/Date.tsx | 10 +- 6 files changed, 440 insertions(+), 96 deletions(-) diff --git a/packages/dnb-eufemia/src/components/date-picker/DatePickerContext.ts b/packages/dnb-eufemia/src/components/date-picker/DatePickerContext.ts index 71b58afaede..3c0511fb275 100644 --- a/packages/dnb-eufemia/src/components/date-picker/DatePickerContext.ts +++ b/packages/dnb-eufemia/src/components/date-picker/DatePickerContext.ts @@ -23,7 +23,7 @@ export type DatePickerContextValues = ContextProps & translation: ContextProps['translation'] views: Array<CalendarView> hasHadValidDate: boolean - previousDates: DatePickerDateProps + previousDateProps: DatePickerDateProps updateDates: ( dates: DatePickerDates, callback?: (dates: DatePickerDates) => void diff --git a/packages/dnb-eufemia/src/components/date-picker/DatePickerFooter.tsx b/packages/dnb-eufemia/src/components/date-picker/DatePickerFooter.tsx index c0f30ab8c2b..bf013bbf0ec 100644 --- a/packages/dnb-eufemia/src/components/date-picker/DatePickerFooter.tsx +++ b/packages/dnb-eufemia/src/components/date-picker/DatePickerFooter.tsx @@ -39,7 +39,7 @@ function DatePickerFooter({ }: DatePickerFooterProps) { const { updateDates, - previousDates, + previousDateProps, props: contextProps, } = useContext(DatePickerContext) @@ -69,18 +69,18 @@ function DatePickerFooter({ args.event.persist() } - const startDate = previousDates.startDate - ? convertStringToDate(previousDates.startDate, { + const startDate = previousDateProps.startDate + ? convertStringToDate(previousDateProps.startDate, { dateFormat, }) - : previousDates.date - ? convertStringToDate(previousDates.date, { + : previousDateProps.date + ? convertStringToDate(previousDateProps.date, { dateFormat, }) : null - const endDate = previousDates.endDate - ? convertStringToDate(previousDates.endDate, { + const endDate = previousDateProps.endDate + ? convertStringToDate(previousDateProps.endDate, { dateFormat, }) : startDate @@ -95,7 +95,7 @@ function DatePickerFooter({ } ) }, - [dateFormat, updateDates, previousDates, onCancel] + [dateFormat, updateDates, previousDateProps, onCancel] ) const onResetHandler = useCallback( diff --git a/packages/dnb-eufemia/src/components/date-picker/DatePickerProvider.tsx b/packages/dnb-eufemia/src/components/date-picker/DatePickerProvider.tsx index 52ff66a306e..2075d9cf611 100644 --- a/packages/dnb-eufemia/src/components/date-picker/DatePickerProvider.tsx +++ b/packages/dnb-eufemia/src/components/date-picker/DatePickerProvider.tsx @@ -91,22 +91,23 @@ function DatePickerProvider(externalProps: DatePickerProviderProps) { const sharedContext = useContext(SharedContext) - const { dates, updateDates, hasHadValidDate, previousDates } = useDates( - { - date, - startDate, - endDate, - startMonth, - endMonth, - minDate, - maxDate, - }, - { - dateFormat: dateFormat, - isRange: range, - shouldCorrectDate: correctInvalidDate, - } - ) + const { dates, updateDates, hasHadValidDate, previousDateProps } = + useDates( + { + date, + startDate, + endDate, + startMonth, + endMonth, + minDate, + maxDate, + }, + { + dateFormat: dateFormat, + isRange: range, + shouldCorrectDate: correctInvalidDate, + } + ) const { views, setViews, forceViewMonthChange } = useViews({ startMonth: dates.startMonth, @@ -225,7 +226,7 @@ function DatePickerProvider(externalProps: DatePickerProviderProps) { hidePicker: hidePicker, props, ...dates, - previousDates, + previousDateProps, hasHadValidDate, views, setViews, diff --git a/packages/dnb-eufemia/src/components/date-picker/__tests__/DatePicker.test.tsx b/packages/dnb-eufemia/src/components/date-picker/__tests__/DatePicker.test.tsx index 91091144f3d..65baae0bbc7 100644 --- a/packages/dnb-eufemia/src/components/date-picker/__tests__/DatePicker.test.tsx +++ b/packages/dnb-eufemia/src/components/date-picker/__tests__/DatePicker.test.tsx @@ -3,7 +3,7 @@ * */ -import React from 'react' +import React, { useState } from 'react' import { axeComponent, loadScss, wait } from '../../../core/jest/jestSetup' import userEvent from '@testing-library/user-event' import DatePicker, { DatePickerAllProps } from '../DatePicker' @@ -223,6 +223,264 @@ describe('DatePicker component', () => { ).not.toContain('dnb-date-picker--opened') }) + it('should delete input content one number at a time when `date` is "prop controlled"', async () => { + const Component = () => { + const [date, setDate] = useState('2024-05-17') + + return ( + <DatePicker + showInput + date={date} + onChange={({ date }) => setDate(date)} + /> + ) + } + + render(<Component />) + + const [day, month, year]: Array<HTMLInputElement> = Array.from( + document.querySelectorAll('input.dnb-input__input') + ) + + expect(day.value).toBe('17') + expect(month.value).toBe('05') + expect(year.value).toBe('2024') + + await userEvent.click(year) + await userEvent.keyboard('{ArrowRight>4}{Backspace}') + + expect(day.value).toBe('17') + expect(month.value).toBe('05') + expect(year.value).toBe('202å') + + await userEvent.keyboard('{Backspace}') + + expect(day.value).toBe('17') + expect(month.value).toBe('05') + expect(year.value).toBe('20åå') + + await userEvent.keyboard('{Backspace}') + + expect(day.value).toBe('17') + expect(month.value).toBe('05') + expect(year.value).toBe('2ååå') + + await userEvent.keyboard('{Backspace}') + + expect(day.value).toBe('17') + expect(month.value).toBe('05') + expect(year.value).toBe('åååå') + + await userEvent.keyboard('{Backspace>2}') + + expect(day.value).toBe('17') + expect(month.value).toBe('0m') + expect(year.value).toBe('åååå') + + await userEvent.keyboard('{Backspace}') + + expect(day.value).toBe('17') + expect(month.value).toBe('mm') + expect(year.value).toBe('åååå') + + await userEvent.keyboard('{Backspace>2}') + + expect(day.value).toBe('1d') + expect(month.value).toBe('mm') + expect(year.value).toBe('åååå') + + await userEvent.keyboard('{Backspace}') + + expect(day.value).toBe('dd') + expect(month.value).toBe('mm') + expect(year.value).toBe('åååå') + }) + + it('should delete input content one number at a time when `startDate` and `endDate` is "prop controlled" and in ranged mode', async () => { + const Component = () => { + const [startDate, setStartDate] = useState('2024-05-01') + const [endDate, setEndDate] = useState('2025-06-30') + + return ( + <DatePicker + showInput + range + startDate={startDate} + endDate={endDate} + onChange={({ start_date, end_date }) => { + setStartDate(start_date) + setEndDate(end_date) + }} + /> + ) + } + + render(<Component />) + + const [ + startDay, + startMonth, + startYear, + endDay, + endMonth, + endYear, + ]: Array<HTMLInputElement> = Array.from( + document.querySelectorAll('input.dnb-input__input') + ) + + expect(startDay.value).toBe('01') + expect(startMonth.value).toBe('05') + expect(startYear.value).toBe('2024') + expect(endDay.value).toBe('30') + expect(endMonth.value).toBe('06') + expect(endYear.value).toBe('2025') + + await userEvent.click(endYear) + await userEvent.keyboard('{ArrowRight>4}{Backspace}') + + expect(startDay.value).toBe('01') + expect(startMonth.value).toBe('05') + expect(startYear.value).toBe('2024') + expect(endDay.value).toBe('30') + expect(endMonth.value).toBe('06') + expect(endYear.value).toBe('202å') + + await userEvent.keyboard('{Backspace}') + + expect(startDay.value).toBe('01') + expect(startMonth.value).toBe('05') + expect(startYear.value).toBe('2024') + expect(endDay.value).toBe('30') + expect(endMonth.value).toBe('06') + expect(endYear.value).toBe('20åå') + + await userEvent.keyboard('{Backspace}') + + expect(startDay.value).toBe('01') + expect(startMonth.value).toBe('05') + expect(startYear.value).toBe('2024') + expect(endDay.value).toBe('30') + expect(endMonth.value).toBe('06') + expect(endYear.value).toBe('2ååå') + + await userEvent.keyboard('{Backspace}') + + expect(startDay.value).toBe('01') + expect(startMonth.value).toBe('05') + expect(startYear.value).toBe('2024') + expect(endDay.value).toBe('30') + expect(endMonth.value).toBe('06') + expect(endYear.value).toBe('åååå') + + await userEvent.keyboard('{Backspace>2}') + + expect(startDay.value).toBe('01') + expect(startMonth.value).toBe('05') + expect(startYear.value).toBe('2024') + expect(endDay.value).toBe('30') + expect(endMonth.value).toBe('0m') + expect(endYear.value).toBe('åååå') + + await userEvent.keyboard('{Backspace}') + + expect(startDay.value).toBe('01') + expect(startMonth.value).toBe('05') + expect(startYear.value).toBe('2024') + expect(endDay.value).toBe('30') + expect(endMonth.value).toBe('mm') + expect(endYear.value).toBe('åååå') + + await userEvent.keyboard('{Backspace>2}') + + expect(startDay.value).toBe('01') + expect(startMonth.value).toBe('05') + expect(startYear.value).toBe('2024') + expect(endDay.value).toBe('3d') + expect(endMonth.value).toBe('mm') + expect(endYear.value).toBe('åååå') + + await userEvent.keyboard('{Backspace}') + + expect(startDay.value).toBe('01') + expect(startMonth.value).toBe('05') + expect(startYear.value).toBe('2024') + expect(endDay.value).toBe('dd') + expect(endMonth.value).toBe('mm') + expect(endYear.value).toBe('åååå') + + await userEvent.keyboard('{Backspace>2}') + + expect(startDay.value).toBe('01') + expect(startMonth.value).toBe('05') + expect(startYear.value).toBe('202å') + expect(endDay.value).toBe('dd') + expect(endMonth.value).toBe('mm') + expect(endYear.value).toBe('åååå') + + await userEvent.keyboard('{Backspace}') + + expect(startDay.value).toBe('01') + expect(startMonth.value).toBe('05') + expect(startYear.value).toBe('20åå') + expect(endDay.value).toBe('dd') + expect(endMonth.value).toBe('mm') + expect(endYear.value).toBe('åååå') + + await userEvent.keyboard('{Backspace}') + + expect(startDay.value).toBe('01') + expect(startMonth.value).toBe('05') + expect(startYear.value).toBe('2ååå') + expect(endDay.value).toBe('dd') + expect(endMonth.value).toBe('mm') + expect(endYear.value).toBe('åååå') + + await userEvent.keyboard('{Backspace}') + + expect(startDay.value).toBe('01') + expect(startMonth.value).toBe('05') + expect(startYear.value).toBe('åååå') + expect(endDay.value).toBe('dd') + expect(endMonth.value).toBe('mm') + expect(endYear.value).toBe('åååå') + + await userEvent.keyboard('{Backspace>2}') + + expect(startDay.value).toBe('01') + expect(startMonth.value).toBe('0m') + expect(startYear.value).toBe('åååå') + expect(endDay.value).toBe('dd') + expect(endMonth.value).toBe('mm') + expect(endYear.value).toBe('åååå') + + await userEvent.keyboard('{Backspace}') + + expect(startDay.value).toBe('01') + expect(startMonth.value).toBe('mm') + expect(startYear.value).toBe('åååå') + expect(endDay.value).toBe('dd') + expect(endMonth.value).toBe('mm') + expect(endYear.value).toBe('åååå') + + await userEvent.keyboard('{Backspace>2}') + + expect(startDay.value).toBe('0d') + expect(startMonth.value).toBe('mm') + expect(startYear.value).toBe('åååå') + expect(endDay.value).toBe('dd') + expect(endMonth.value).toBe('mm') + expect(endYear.value).toBe('åååå') + + await userEvent.keyboard('{Backspace}') + + expect(startDay.value).toBe('dd') + expect(startMonth.value).toBe('mm') + expect(startYear.value).toBe('åååå') + expect(endDay.value).toBe('dd') + expect(endMonth.value).toBe('mm') + expect(endYear.value).toBe('åååå') + }) + it('will render the result of "onDaysRender"', () => { const customClassName = 'dnb-date-picker__day--weekend' const onDaysRender = jest.fn((days) => { @@ -2315,51 +2573,6 @@ describe('DatePicker calc', () => { rightPicker.querySelector('.dnb-date-picker__header__title') ).toHaveTextContent('november 2024') }) - - it('should remove end date from ranged input, where dates are prop controlled, when pressing backspace', async () => { - const Component = () => { - const [startDate, setStartDate] = React.useState('2024-10-10') - const [endDate, setEndDate] = React.useState('2024-11-21') - - return ( - <DatePicker - range - showInput - date={startDate} - startDate={startDate} - endDate={endDate} - onChange={({ start_date, end_date }) => { - setStartDate(start_date) - setEndDate(end_date) - }} - /> - ) - } - - render(<Component />) - - const [startDay, startMonth, startYear, endDay, endMonth, endYear] = - Array.from( - document.querySelectorAll('.dnb-date-picker__input') - ) as Array<HTMLInputElement> - - expect(startDay.value).toBe('10') - expect(startMonth.value).toBe('10') - expect(startYear.value).toBe('2024') - expect(endDay.value).toBe('21') - expect(endMonth.value).toBe('11') - expect(endYear.value).toBe('2024') - - await userEvent.click(endYear) - await userEvent.keyboard('{backspace>3}') - - expect(startDay.value).toBe('10') - expect(startMonth.value).toBe('10') - expect(startYear.value).toBe('2024') - expect(endDay.value).toBe('dd') - expect(endMonth.value).toBe('mm') - expect(endYear.value).toBe('åååå') - }) }) describe('DatePicker scss', () => { diff --git a/packages/dnb-eufemia/src/components/date-picker/hooks/useDates.ts b/packages/dnb-eufemia/src/components/date-picker/hooks/useDates.ts index 15368647a06..ee2433cfcd7 100644 --- a/packages/dnb-eufemia/src/components/date-picker/hooks/useDates.ts +++ b/packages/dnb-eufemia/src/components/date-picker/hooks/useDates.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { convertStringToDate, isDisabled } from '../DatePickerCalc' import isValid from 'date-fns/isValid' import format from 'date-fns/format' -import { addMonths } from 'date-fns' +import { addMonths, isSameDay } from 'date-fns' import { DateType } from '../DatePickerContext' export type DatePickerDateProps = { @@ -53,9 +53,9 @@ export default function useDates( shouldCorrectDate = false, }: UseDatesOptions ) { - const [previousDates, setPreviousDates] = useState(dateProps) + const [previousDateProps, setPreviousDateProps] = useState(dateProps) const [dates, setDates] = useState<DatePickerDates>({ - ...mapDates(dateProps, previousDates, { + ...mapDates(dateProps, { dateFormat, isRange, shouldCorrectDate, @@ -65,21 +65,41 @@ export default function useDates( const hasDatePropChanges = useMemo( () => Object.keys(dateProps).some((date) => { - return dateProps[date] !== previousDates[date] + const dateProp = dateProps[date] + const previousDate = previousDateProps[date] + + const convertedDateProp = convertStringToDate(dateProp, { + dateFormat, + }) + const convertedPreviousDate = convertStringToDate(previousDate, { + dateFormat, + }) + // Make sure that same dates does not trigger a change + // i.e. 2021-01-01 and new Date('2021-01-01') + if ( + convertedDateProp instanceof Date && + convertedPreviousDate instanceof Date + ) { + return !isSameDay(convertedDateProp, convertedPreviousDate) + } + + return dateProp !== previousDate }), - [dateProps, previousDates] + [dateProps, previousDateProps, dateFormat] ) // Update dates on prop change if (hasDatePropChanges) { - setDates({ - ...mapDates({ ...dates, ...dateProps }, previousDates, { - dateFormat, - isRange, - shouldCorrectDate, - }), + const derivedDates = deriveDatesFromProps({ + dates, + dateProps, + previousDateProps, + dateFormat, + isRange, }) - setPreviousDates(dateProps) + + setDates((currentDates) => ({ ...currentDates, ...derivedDates })) + setPreviousDateProps(dateProps) } const hasHadValidDate = useRef<boolean>(false) @@ -128,8 +148,8 @@ export default function useDates( // Updated input dates based on start and end dates, move to DatePickerInput // TODO: Move to DatePickerInput useEffect(() => { - const startDates = updateInputDates('start', dates) - const endDates = updateInputDates('end', dates) + const startDates = updateInputDates('start', dates.startDate) + const endDates = updateInputDates('end', dates.endDate) hasHadValidDate.current = isValid(dates.startDate) || isValid(dates.endDate) @@ -146,14 +166,13 @@ export default function useDates( dates, updateDates, hasHadValidDate: hasHadValidDate.current, - previousDates, + previousDateProps, } as const } // TODO: Move to DatePickerInput -function updateInputDates(type: 'start' | 'end', dates: DatePickerDates) { +function updateInputDates(type: 'start' | 'end', date: Date | undefined) { const updatedDates = {} - const date = dates[`${type}Date`] if (isValid(date)) { updatedDates[`__${type}Day`] = pad(format(date, 'dd'), 2) @@ -170,17 +189,13 @@ function updateInputDates(type: 'start' | 'end', dates: DatePickerDates) { function mapDates( dateProps: DatePickerDateProps, - previousDates: DatePickerDateProps, { dateFormat, isRange, shouldCorrectDate, }: Omit<UseDatesOptions, 'isLinked'> ) { - const date = - previousDates.date !== dateProps.date - ? dateProps.date - : previousDates.date + const date = dateProps.date const startDate = typeof dateProps?.startDate !== 'undefined' @@ -224,6 +239,7 @@ function mapDates( : {} const dates = { + date, startDate, endDate, startMonth, @@ -252,6 +268,92 @@ function mapDates( } } +function deriveDatesFromProps({ + dates, + dateProps, + previousDateProps, + dateFormat, + isRange, +}: { + dates: DatePickerDates + dateProps: DatePickerDateProps + previousDateProps: DatePickerDateProps + dateFormat: UseDatesOptions['dateFormat'] + isRange: UseDatesOptions['isRange'] +}) { + const derivedDates: DatePickerDates = {} + + const startDate = getStartDate(dateProps, previousDateProps) + + // Handle updates related to date and startDate changes when not in range mode + if (typeof startDate !== 'undefined' && startDate !== dates.startDate) { + derivedDates.startDate = + convertStringToDate(startDate, { + dateFormat, + }) || undefined + + // Set endDate and startMonth to startDate if not in range mode + if (!isRange) { + derivedDates.startMonth = + convertStringToDate(startDate, { + dateFormat, + }) || undefined + + derivedDates.endDate = derivedDates.startDate + } + } + + // update endDate based on endDate prop if in range mode + if ( + isRange && + typeof dateProps.endDate !== 'undefined' && + dateProps.endDate !== dates.endDate + ) { + derivedDates.endDate = + convertStringToDate(dateProps.endDate, { + dateFormat, + }) || undefined + } + + // Handle startMonth/endMonth + if ( + typeof dateProps.startMonth !== 'undefined' && + dateProps.startMonth !== previousDateProps.startMonth + ) { + derivedDates.startMonth = convertStringToDate(dateProps.startMonth, { + dateFormat, + }) + } + if ( + typeof dateProps.endMonth !== 'undefined' && + dateProps.endMonth !== previousDateProps.endMonth + ) { + derivedDates.endMonth = convertStringToDate(dateProps.endMonth, { + dateFormat, + }) + } + + // Handle minDate/maxDate + if ( + typeof dateProps.minDate !== 'undefined' && + dateProps.minDate !== previousDateProps.minDate + ) { + derivedDates.minDate = convertStringToDate(dateProps.minDate, { + dateFormat, + }) + } + if ( + typeof dateProps.maxDate !== 'undefined' && + dateProps.maxDate !== previousDateProps.maxDate + ) { + derivedDates.maxDate = convertStringToDate(dateProps.maxDate, { + dateFormat, + }) + } + + return derivedDates +} + function correctDates({ startDate, endDate, @@ -309,6 +411,28 @@ function getDate(date: DateType, dateFormat: string) { }) } +function getStartDate( + dateProps: DatePickerDateProps, + previousDateProps: DatePickerDateProps +) { + // priortize startDate over date if provided + if ( + typeof dateProps.startDate !== 'undefined' && + dateProps.startDate !== previousDateProps.startDate + ) { + return dateProps.startDate + } + + if ( + typeof dateProps.date !== 'undefined' && + dateProps.date !== previousDateProps.date + ) { + return dateProps.date + } + + return undefined +} + export function pad(date: string, size: number) { const dateWithPadding = '000000000' + date diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Date/Date.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Date/Date.tsx index ff6d0a6c11c..1e70692572c 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Date/Date.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Date/Date.tsx @@ -147,12 +147,18 @@ function DateComponent(props: Props) { const { value, startDate, endDate } = useMemo(() => { if (!range || !valueProp) { - return { value: valueProp, startDate: undefined, endDate: undefined } + return { + // Assign to null if falsy value, to properly clear input values + value: valueProp ?? null, + startDate: undefined, + endDate: undefined, + } } const [startDate, endDate] = valueProp .split('|') - .map((value) => (/(undefined|null)/.test(value) ? undefined : value)) + // Assign to null if falsy value, to properly clear input values + .map((value) => (/(undefined|null)/.test(value) ? null : value)) return { value: undefined, From 93b00bf06c1401472214903b37a483840556d5f2 Mon Sep 17 00:00:00 2001 From: Anders <anderslangseth@gmail.com> Date: Fri, 29 Nov 2024 10:15:45 +0100 Subject: [PATCH 13/13] chore: adds changelog for v10.58.0 (#4334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tobias Høegh <tobias@tujo.no> --- .../src/docs/uilib/extensions/forms/changelog.mdx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/changelog.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/changelog.mdx index 7e88677912b..f2184166ec1 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/changelog.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/changelog.mdx @@ -13,6 +13,18 @@ breadcrumb: Change log for the Eufemia Forms extension. +## v10.58 + +- Added `variant="filled"` to [Iterate.ViewContainer](/uilib/extensions/forms/Iterate/ViewContainer/) and [Iterate.EditContainer](/uilib/extensions/forms/Iterate/EditContainer/), to render with a background color. +- Added `toolbarVariant="custom"` to [Iterate.ViewContainer](/uilib/extensions/forms/Iterate/ViewContainer/) and [Iterate.EditContainer](/uilib/extensions/forms/Iterate/EditContainer/), to render the given toolbar without any spacing so it can be customized to your needs. +- Added `showConfirmDialog` to [Iterate.RemoveButton](/uilib/extensions/forms/Iterate/RemoveButton/), to open a confirmation dialog before removing the item. +- Added `decoupleForm` to [Form.Handler](/uilib/extensions/forms/Form/Handler/), to be able to use the data context in a more flexible way. +- Added support for using function reference instead of a string based `id` in [Form.Handler](/uilib/extensions/forms/Form/Handler/). +- Added `sessionStorageId` support to [Field.Upload](/uilib/extensions/forms/feature-fields/more-fields/Upload/) with empty file list rendering. +- Added [docs on how to deal with TypeScript types](/uilib/extensions/forms/getting-started/#typescript-support), and enhanced typings. +- Fixed so there is no outline when using `variant="basic"` in [Form.Section](/uilib/extensions/forms/Form/Section/) containers when used in [Wizard](/uilib/extensions/forms/Wizard/). +- Fixed formatting of country prefixes in [Value.PhoneNumber](/uilib/extensions/forms/Value/PhoneNumber/). + ## v10.57 - Added possibility for disabling individual options in [Field.Selection](/uilib/extensions/forms/base-fields/Selection/) and [Field.ArraySelection](/uilib/extensions/forms/base-fields/ArraySelection/).