From a9f86e4ed9c0d84f7b580a8567b5c6f18f5a9053 Mon Sep 17 00:00:00 2001 From: "Anders.Breilid" Date: Tue, 10 Sep 2024 14:13:01 +0200 Subject: [PATCH 01/14] fix(countries): Fixes wrong country code for Martinique #3915 --- .../dnb-eufemia/src/extensions/forms/constants/countries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts b/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts index 1f703f5ceef..0bbd75ca511 100644 --- a/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts +++ b/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts @@ -1289,7 +1289,7 @@ const countries: Array = [ nb: 'Martinique', }, cdc: '596', - iso: 'MH', + iso: 'MQ', continent: 'South America', }, { From 36ef5cfb88b288399b2ef8fcc57fb34d05f2efd2 Mon Sep 17 00:00:00 2001 From: "Anders.Breilid" Date: Tue, 10 Sep 2024 14:13:11 +0200 Subject: [PATCH 02/14] fix(countries): Remove outdated countries #3915 --- .../extensions/forms/constants/countries.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts b/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts index 0bbd75ca511..4b0bbff0530 100644 --- a/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts +++ b/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts @@ -1445,15 +1445,6 @@ const countries: Array = [ iso: 'NL', continent: 'Europe', }, - { - i18n: { - en: 'Netherlands Antilles', - nb: 'Nederlandske Antiller', - }, - cdc: '599', - iso: 'AN', - continent: 'North America', - }, { i18n: { en: 'New Caledonia', @@ -1986,15 +1977,6 @@ const countries: Array = [ iso: 'SJ', continent: 'Europe', }, - { - i18n: { - en: 'Swaziland', - nb: 'Swaziland', - }, - cdc: '268', - iso: 'SZ', - continent: 'Africa', - }, { i18n: { en: 'Sweden', From 0ac8d1727eed7340a29982aed3c7134098e5e58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sindre=20B=C3=B8yum?= Date: Tue, 10 Sep 2024 16:03:14 +0200 Subject: [PATCH 03/14] chore: rename 'Macedonia' to 'North Macedonia' --- .../extensions/forms/constants/countries.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts b/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts index 4b0bbff0530..7fba70aa9eb 100644 --- a/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts +++ b/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts @@ -1211,15 +1211,6 @@ const countries: Array = [ iso: 'MO', continent: 'Asia', }, - { - i18n: { - en: 'Macedonia', - nb: 'Makedonia', - }, - cdc: '389', - iso: 'MK', - continent: 'Europe', - }, { i18n: { en: 'Madagascar', @@ -1517,6 +1508,15 @@ const countries: Array = [ iso: 'KP', continent: 'Asia', }, + { + i18n: { + en: 'North Macedonia', + nb: 'Nord-Makedonia', + }, + cdc: '389', + iso: 'MK', + continent: 'Europe', + }, { i18n: { en: 'Northern Mariana Islands', From 702118a966858e73f3e7bae77ab5e026f083fd54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sindre=20B=C3=B8yum?= Date: Tue, 10 Sep 2024 20:25:13 +0200 Subject: [PATCH 04/14] fix(Forms): rename "Hviterussland" to "Belarus" (#3917) --- .../dnb-eufemia/src/extensions/forms/constants/countries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts b/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts index 7fba70aa9eb..cce1b804a8b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts +++ b/packages/dnb-eufemia/src/extensions/forms/constants/countries.ts @@ -210,7 +210,7 @@ const countries: Array = [ { i18n: { en: 'Belarus', - nb: 'Hviterussland', + nb: 'Belarus', }, cdc: '375', iso: 'BY', From 52326bf136ea3d26c835accf8932cb0e93259e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Thu, 12 Sep 2024 10:59:20 +0200 Subject: [PATCH 05/14] feat(Forms): auto-open iterate containers when validation errors and make `Iterate.Toolbar` fully customizable (#3877) - This PR allows a dev to fully customize the buttons for the edit and view container toolbar. - I also auto-opens the edit container, when there is a validation error. - A followup-up PR #3919 has been created. Quick example of a fully customized Toolbar: ```tsx {({ items }) => { if (items.length === 1) { return } return ( <> ) }} } > {({ items }) => { if (items.length === 1) { return null } return ( <> ) }} } > ``` --------- Co-authored-by: Anders --- .../forms/Iterate/Array/Examples.tsx | 77 +- .../extensions/forms/Iterate/Array/demos.mdx | 10 + .../extensions/forms/Iterate/Array/info.mdx | 8 + .../forms/Iterate/EditContainer/info.mdx | 18 + .../forms/Iterate/PushContainer/Examples.tsx | 4 +- .../extensions/forms/Iterate/Toolbar/info.mdx | 50 +- .../forms/Iterate/ViewContainer/info.mdx | 21 +- .../docs/uilib/extensions/forms/changelog.mdx | 4 + .../extensions/forms/DataContext/Context.ts | 1 + .../FieldBoundary/FieldBoundaryProvider.tsx | 3 +- .../forms/DataContext/Provider/Provider.tsx | 1 + .../Field/SelectCountry/SelectCountry.tsx | 2 +- .../Field/String/__tests__/String.test.tsx | 4 +- .../forms/Form/Isolation/Isolation.tsx | 7 +- .../AnimatedContainer/ElementBlockContext.ts | 9 - .../extensions/forms/Iterate/Array/Array.tsx | 189 +++-- .../forms/Iterate/Array/ArrayDocs.ts | 5 + .../ArrayItemArea.tsx} | 143 ++-- .../Iterate/Array/ArrayItemAreaContext.ts | 9 + .../Array/__tests__/Array.screenshot.test.ts | 2 +- .../Iterate/Array/__tests__/Array.test.tsx | 156 +++- .../Array/__tests__/ArrayElement.test.tsx | 99 --- .../__tests__/ArrayItemArea.test.tsx} | 145 +++- ...rray-have-to-match-edit-container.snap.png | Bin 7882 -> 8040 bytes ...rray-have-to-match-view-container.snap.png | Bin 11162 -> 11993 bytes .../extensions/forms/Iterate/Array/types.ts | 7 +- .../Iterate/EditContainer/CancelButton.tsx | 107 +++ .../Iterate/EditContainer/DoneButton.tsx | 69 ++ .../Iterate/EditContainer/EditContainer.tsx | 54 +- .../EditContainer/EditToolbarTools.tsx | 138 ---- ...arTools.test.tsx => CancelButton.test.tsx} | 42 +- .../__tests__/DoneButton.test.tsx | 79 ++ .../__tests__/EditAndViewContainer.test.tsx | 736 +++++++++++++++--- .../__tests__/EditContainer.test.tsx | 8 +- .../forms/Iterate/EditContainer/index.ts | 2 + .../forms/Iterate/IterateItemContext.ts | 16 +- .../forms/Iterate/PushButton/PushButton.tsx | 51 +- .../PushButton/__tests__/PushButton.test.tsx | 88 ++- .../Iterate/PushContainer/PushContainer.tsx | 134 +++- .../PushContainer/PushContainerDocs.ts | 5 + .../__tests__/PushContainer.test.tsx | 253 +++++- .../Iterate/RemoveButton/RemoveButton.tsx | 16 +- .../forms/Iterate/Toolbar/Toolbar.tsx | 63 +- .../forms/Iterate/Toolbar/ToolbarContext.ts | 11 + .../Toolbar/__tests__/Toolbar.test.tsx | 6 +- .../Iterate/ViewContainer/EditButton.tsx | 26 + .../Iterate/ViewContainer/RemoveButton.tsx | 9 + .../Iterate/ViewContainer/ViewContainer.tsx | 42 +- .../ViewContainer/ViewToolbarTools.tsx | 32 - .../__tests__/EditButton.test.tsx | 41 + ...arTools.test.tsx => RemoveButton.test.tsx} | 20 +- .../__tests__/ViewContainer.test.tsx | 8 +- .../forms/Iterate/ViewContainer/index.ts | 2 + .../extensions/forms/Iterate/hooks/index.ts | 2 + .../Iterate/hooks/useSwitchContainerMode.ts | 114 +++ .../forms/Iterate/stories/Iterate.stories.tsx | 137 +++- .../src/extensions/forms/Tools/Log.tsx | 25 + .../src/extensions/forms/Tools/index.ts | 1 + .../ChildrenWithAge/ChildrenWithAge.tsx | 2 - .../ChildrenWithAge.test.tsx.snap | 8 - .../forms/constants/locales/en-GB.ts | 2 +- .../forms/constants/locales/nb-NO.ts | 2 +- .../forms/hooks/__tests__/usePath.test.tsx | 7 + .../forms/hooks/useExternalValue.ts | 14 +- .../extensions/forms/hooks/useFieldProps.ts | 38 +- .../src/extensions/forms/hooks/usePath.ts | 10 +- 66 files changed, 2575 insertions(+), 819 deletions(-) delete mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/ElementBlockContext.ts rename packages/dnb-eufemia/src/extensions/forms/Iterate/{AnimatedContainer/ElementBlock.tsx => Array/ArrayItemArea.tsx} (54%) create mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemAreaContext.ts delete mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/ArrayElement.test.tsx rename packages/dnb-eufemia/src/extensions/forms/Iterate/{AnimatedContainer/__tests__/ElementBlock.test.tsx => Array/__tests__/ArrayItemArea.test.tsx} (71%) create mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/CancelButton.tsx create mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/DoneButton.tsx delete mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditToolbarTools.tsx rename packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/{EditToolbarTools.test.tsx => CancelButton.test.tsx} (62%) create mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/DoneButton.test.tsx create mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/Toolbar/ToolbarContext.ts create mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/EditButton.tsx create mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/RemoveButton.tsx delete mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewToolbarTools.tsx create mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/EditButton.test.tsx rename packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/{ViewToolbarTools.test.tsx => RemoveButton.test.tsx} (64%) create mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/index.ts create mode 100644 packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/useSwitchContainerMode.ts create mode 100644 packages/dnb-eufemia/src/extensions/forms/Tools/Log.tsx 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 a613dea4543..0154e03209c 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 @@ -5,6 +5,7 @@ import { Field, Value, Form, + Tools, } from '@dnb/eufemia/src/extensions/forms' export { Default as AnimatedContainer } from '../AnimatedContainer/Examples' @@ -172,7 +173,7 @@ export const ArrayFromFormHandler = () => { }} onChange={(data) => console.log('DataContext/onChange', data)} > - + Avengers @@ -212,7 +213,7 @@ export const ArrayFromFormHandler = () => { pushValue={{}} /> - + ) @@ -282,7 +283,7 @@ export const ViewAndEditContainer = () => { accounts: [ { firstName: 'Tony', - lastName: undefined, // initiate error + lastName: 'Rogers', }, ], }} @@ -291,7 +292,7 @@ export const ViewAndEditContainer = () => { } onSubmit={async (data) => console.log('onSubmit', data)} > - + Accounts @@ -304,7 +305,7 @@ export const ViewAndEditContainer = () => { - + ) } @@ -364,3 +365,69 @@ export const WithVisibility = () => { ) } + +export const InitialOpen = () => { + return ( + + console.log('onSubmit', data)} + onSubmitRequest={() => console.log('onSubmitRequest')} + > + + Statsborgerskap + + + + + + + {({ items }) => + items.length === 1 ? ( + + ) : ( + <> + + + + ) + } + + + + + + + {({ items }) => + items.length === 1 ? null : ( + <> + + + + ) + } + + + + + + + + + + + + + + ) +} 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 13221d8dc00..f56b88b5021 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 @@ -21,6 +21,8 @@ import * as Examples from './Examples' ### Render props with primitive items +You can provide the child as a function that receives the value of the item as the first argument, and the index of which item you are on as the second. + ### Render props with object items @@ -47,6 +49,14 @@ With an optional `title` and [Iterate.Toolbar](/uilib/extensions/forms/Iterate/T +### Initially open + +This example uses a customized [Iterate.Toolbar](/uilib/extensions/forms/Iterate/Toolbar/). + +It hides the toolbar in the `EditContainer` when there is only one item in the array. And it hides the remove button in the `ViewContainer` when there is only one item in the array. + + + ### With DataContext and add/remove buttons diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/info.mdx index 3887fd0aa60..4fedb43fd87 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/info.mdx @@ -88,6 +88,14 @@ render( ) ``` +### Initial container mode + +This section describes the behavior of the `EditContainer` and the `ViewContainer` components. + +By default, the container mode is set to `auto`. This means that the container will open (switch to `edit` mode) when there is an error in the container or the value is falsy (empty string, null, undefined, etc.). + +When a new item is added via the [Iterate.PushButton](/uilib/extensions/forms/Iterate/PushButton/) component, the item before it will change to `view` mode, if it had no validation errors. + ## Filter data You can filter data by paths specific or all paths. 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 6f631cf92a3..362ac4bdcc0 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 @@ -47,6 +47,24 @@ render( ) ``` +## Customize the Toolbar + +```tsx +import { Iterate, Field } from '@dnb/eufemia/extensions/forms' + +render( + + + + + + + + + , +) +``` + ## Get the internal item object You can get the internal item object by using the `Iterate.useItem` hook. 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 c90549eab1b..6d9992ff1b4 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 @@ -74,7 +74,7 @@ export const InitiallyOpen = () => { } onSubmit={async (data) => console.log('onSubmit', data)} > - + Accounts @@ -87,7 +87,7 @@ export const InitiallyOpen = () => { - + ) } diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Toolbar/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Toolbar/info.mdx index 98959df27f6..528c891b984 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Toolbar/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Toolbar/info.mdx @@ -7,6 +7,8 @@ hideInMenu: true Use `Iterate.Toolbar` to enhance each item in the array with additional functionality. It's particularly useful within components like [Iterate.AnimatedContainer](/uilib/extensions/forms/Iterate/AnimatedContainer) to incorporate a toolbar with extra tools. +The Toolbar is integrated into the containers [Iterate.ViewContainer](/uilib/extensions/forms/Iterate/ViewContainer/) and [Iterate.EditContainer](/uilib/extensions/forms/Iterate/EditContainer/). + ```tsx import { Iterate } from '@dnb/eufemia/extensions/forms' @@ -22,4 +24,50 @@ render( ) ``` -The Toolbar is integrated into the containers [Iterate.ViewContainer](/uilib/extensions/forms/Iterate/ViewContainer/) and [Iterate.EditContainer](/uilib/extensions/forms/Iterate/EditContainer/). +## Customize the Toolbar + +You can customize the toolbar by either passing a function as a child or as a JSX element: + +```tsx +import { Iterate } from '@dnb/eufemia/extensions/forms' + +render( + + + Item Content + + + + + + , +) +``` + +The function receives the following parameters as an object: + +- `index` the index of the current item in the array. +- `value` the value of the current item. +- `items` the array of items. + +```tsx +import { Iterate } from '@dnb/eufemia/extensions/forms' + +render( + + + Item Content + + {({ items, index, value }) => { + return items.length === 1 ? null : ( + <> + + + + ) + }} + + + , +) +``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ViewContainer/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ViewContainer/info.mdx index 4026d2f34f5..3002f328cd2 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ViewContainer/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ViewContainer/info.mdx @@ -33,7 +33,7 @@ render( You can use the `{itemNr}` variable in the `title` property to display the current item number. ```tsx -import { Iterate, Field, Value } from '@dnb/eufemia/extensions/forms' +import { Iterate, Value } from '@dnb/eufemia/extensions/forms' render( @@ -44,6 +44,25 @@ render( ) ``` +## Customize the Toolbar + +```tsx +import { Iterate, Value } from '@dnb/eufemia/extensions/forms' + +render( + + + + + + + + + + , +) +``` + ## Accessibility The `Iterate.ViewContainer` component has an `aria-label` attribute, which is set to the `title` prop value. It uses a section element to wrap the content, which helps users with screen readers to get the needed announcement. 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 19a50a653fd..255bcbe57a6 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,10 @@ breadcrumb: Change log for the Eufemia Forms extension. +## v10.48 + +- Make [Iterate.Toolbar](/uilib/extensions/forms/Iterate/Toolbar/) customizable. + ## v10.46 - Added [Value.SelectCountry](/uilib/extensions/forms/Value/SelectCountry/) component to render a country value. diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts index 460417e9b67..8f8ca056d86 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts @@ -81,6 +81,7 @@ export interface ContextState { handlePathChangeUnvalidated: (path: Path, value: any) => void updateDataValue: (path: Path, value: any) => void setData: (data: any) => void + clearData?: () => void mutateDataHandler?: (data: any, mutate: TransformData) => any filterDataHandler?: (data: any, filter: FilterData) => any validateData: () => void diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryProvider.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryProvider.tsx index 803b8a47dea..05133db43f8 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryProvider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryProvider.tsx @@ -11,7 +11,7 @@ export default function FieldBoundaryProvider({ children }) { const errorsRef = useRef>({}) const showBoundaryErrorsRef = useRef(false) - const hasError = Object.keys(errorsRef.current || {}).length > 0 + const hasError = Object.keys(errorsRef.current).length > 0 const hasSubmitError = showAllErrors && hasError const setFieldError = useCallback((path: Path, error: Error) => { @@ -20,6 +20,7 @@ export default function FieldBoundaryProvider({ children }) { } else { delete errorsRef.current?.[path] } + forceUpdate() }, []) const setShowBoundaryErrors = useCallback( 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 ab8e71cc69b..ccf84cea562 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -1167,6 +1167,7 @@ export default function Provider( validateData, updateDataValue, setData, + clearData, filterDataHandler, addOnChangeHandler, setHandleSubmit, diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountry.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountry.tsx index 9dcbd080369..62d5504e8b6 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountry.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountry.tsx @@ -195,7 +195,7 @@ function SelectCountry(props: Props) { label={label} input_icon={false} data={dataRef.current} - value={value} + value={typeof value === 'string' ? value : null} disabled={disabled} on_show={fillData} on_focus={onFocusHandler} diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx index 0be62c731d9..0a443c6d8b6 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx @@ -274,7 +274,7 @@ describe('Field.String', () => { await userEvent.type(input, '{Backspace>3}aBc') expect(input).toHaveValue('ABC') - expect(transformIn).toHaveBeenCalledTimes(13) + expect(transformIn).toHaveBeenCalledTimes(7) expect(transformIn).toHaveBeenLastCalledWith('abc') expect(transformOut).toHaveBeenCalledTimes(6) expect(transformOut).toHaveBeenLastCalledWith('ABc') @@ -289,7 +289,7 @@ describe('Field.String', () => { await userEvent.type(input, '{Backspace>3}EfG') expect(input).toHaveValue('EFG') - expect(transformIn).toHaveBeenCalledTimes(25) + expect(transformIn).toHaveBeenCalledTimes(13) expect(transformIn).toHaveBeenLastCalledWith('efg') expect(transformOut).toHaveBeenCalledTimes(12) expect(transformOut).toHaveBeenLastCalledWith('EFG') 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 ffad2987bf2..0996397a262 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/Isolation.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/Isolation.tsx @@ -174,7 +174,9 @@ function IsolationProvider( // Commit the internal data to the nested context data handlePathChangeOuter?.( path, - extendDeep({}, outerData, isolatedData) + Array.isArray(isolatedData) + ? isolatedData + : extendDeep({}, outerData, isolatedData) ) return await onCommitProp?.( @@ -202,8 +204,7 @@ function IsolationProvider( const providerProps: IsolationProps = { ...props, - data: internalDataRef.current, - defaultData: undefined, + [defaultData ? 'defaultData' : 'data']: internalDataRef.current, onPathChange: onPathChangeHandler, onCommit, onClear, diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/ElementBlockContext.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/ElementBlockContext.ts deleted file mode 100644 index 06b10ff973b..00000000000 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/ElementBlockContext.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createContext } from 'react' - -type ElementBlockContext = { - handleRemoveBlock?: () => void -} - -const ElementBlockContext = createContext(null) - -export default ElementBlockContext diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx index 8bab72afc3b..78f522b7642 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx @@ -13,6 +13,7 @@ import { useFieldProps } from '../../hooks' import { makeUniqueId } from '../../../../shared/component-helper' import { Flex } from '../../../../components' import { pickSpacingProps } from '../../../../components/flex/utils' +import useMountEffect from '../../../../shared/helpers/useMountEffect' import { BasicProps as FlexContainerProps, Props as FlexContainerAllProps, @@ -24,10 +25,12 @@ import IterateItemContext, { import SummaryListContext from '../../Value/SummaryList/SummaryListContext' import ValueBlockContext from '../../ValueBlock/ValueBlockContext' import FieldBoundaryProvider from '../../DataContext/FieldBoundary/FieldBoundaryProvider' +import DataContext from '../../DataContext/Context' import useDataValue from '../../hooks/useDataValue' +import { useSwitchContainerMode } from '../hooks' import type { ContainerMode, ElementChild, Props, Value } from './types' -import type { Identifier, Path } from '../../types' +import type { Identifier } from '../../types' /** * Deprecated, as it is supported by all major browsers and Node.js >=v18 @@ -81,9 +84,11 @@ function ArrayComponent(props: Props) { const { path, value: arrayValue, + defaultValue, withoutFlex, emptyValue, placeholder, + containerMode, handleChange, onChange, children, @@ -91,7 +96,16 @@ function ArrayComponent(props: Props) { const idsRef = useRef>([]) const isNewRef = useRef>({}) - const modesRef = useRef>({}) + const modesRef = useRef< + Record< + Identifier, + { + current: ContainerMode + previous?: ContainerMode + options?: { omitFocusManagement?: boolean } + } + > + >({}) const valueWhileClosingRef = useRef() const valueCountRef = useRef(arrayValue) const containerRef = useRef() @@ -102,89 +116,110 @@ function ArrayComponent(props: Props) { const omitFlex = withoutFlex ?? (summaryListContext || valueBlockContext) + // To support React.StrictMode, we inject the defaultValue into the data context this way. + // The routine inside useFieldProps where updateDataValueDataContext is called, does not support React.StrictMode + const { handlePathChange } = useContext(DataContext) || {} + useMountEffect(() => { + if (defaultValue) { + handlePathChange?.(path, defaultValue) + } + }) + useEffect(() => { // Update inside the useEffect, to support React.StrictMode valueCountRef.current = arrayValue || [] }, [arrayValue]) - const elementData = useMemo(() => { - return ((valueWhileClosingRef.current || arrayValue) ?? []).map( - (value, index) => { - const id = idsRef.current[index] || makeUniqueId() + const { getNextContainerMode } = useSwitchContainerMode() - const hasNewItems = - arrayValue.length > valueCountRef.current?.length + const arrayItems = useMemo(() => { + const list = valueWhileClosingRef.current || arrayValue + return (list ?? []).map((value, index) => { + const id = idsRef.current[index] || makeUniqueId() - if (!idsRef.current[index]) { - isNewRef.current[id] = hasNewItems - idsRef.current.push(id) - } - - const isNew = isNewRef.current[id] || false - if (!modesRef.current[id]) { - modesRef.current[id] = - value?.['__containerMode'] ?? (isNew ? 'edit' : 'view') - delete value?.['__containerMode'] - } - - return { - id, - path, - value, - index, - arrayValue, - containerRef, - isNew, - containerMode: modesRef.current[id], - switchContainerMode: (mode: ContainerMode) => { - modesRef.current[id] = mode - delete isNewRef.current?.[id] - forceUpdate() - }, - handleChange: (path: Path, value: unknown) => { - const newArrayValue = structuredClone(arrayValue) + const hasNewItems = + arrayValue?.length > valueCountRef.current?.length - // Make sure we have a new object reference, - // else two new objects will be the same - newArrayValue[index] = { ...newArrayValue[index] } + if (!idsRef.current[index]) { + isNewRef.current[id] = hasNewItems + idsRef.current.push(id) + } - pointer.set(newArrayValue, path, value) - handleChange(newArrayValue) - }, - handlePush: (element: unknown) => { - hadPushRef.current = true - handleChange([...(arrayValue ?? []), element]) - }, - handleRemove: ({ keepItems = false } = {}) => { - if (keepItems) { - // Add a backup as the array value while animating - valueWhileClosingRef.current = arrayValue - } + const isNew = isNewRef.current[id] || false + if (!modesRef.current[id]?.current) { + modesRef.current[id] = { + current: + containerMode ?? + (isNew ? getNextContainerMode() ?? 'edit' : 'auto'), + } + } - const newArrayValue = structuredClone(arrayValue) - newArrayValue.splice(index, 1) - handleChange(newArrayValue) - }, - - // - Called after animation end - fulfillRemove: () => { - valueWhileClosingRef.current = null - delete modesRef.current?.[id] - delete isNewRef.current?.[id] - const findIndex = idsRef.current.indexOf(id) - idsRef.current.splice(findIndex, 1) - forceUpdate() - }, - - // - Called when cancel button press - restoreOriginalValue: (value: unknown) => { + const itemContext: IterateItemContextState = { + id, + path, + value, + index, + arrayValue, + containerRef, + isNew, + containerMode: modesRef.current[id].current, + previousContainerMode: modesRef.current[id].previous, + initialContainerMode: containerMode || 'auto', + modeOptions: modesRef.current[id].options, + switchContainerMode: (mode, options = {}) => { + modesRef.current[id].previous = modesRef.current[id].current + modesRef.current[id].current = mode + modesRef.current[id].options = options + delete isNewRef.current?.[id] + forceUpdate() + }, + handleChange: (path, value) => { + const newArrayValue = structuredClone(arrayValue) + + // Make sure we have a new object reference, + // else two new objects will be the same + newArrayValue[index] = { ...newArrayValue[index] } + + pointer.set(newArrayValue, path, value) + handleChange(newArrayValue) + }, + handlePush: (element) => { + hadPushRef.current = true + handleChange([...(arrayValue || []), element]) + }, + handleRemove: ({ keepItems = false } = {}) => { + if (keepItems) { + // Add a backup as the array value while animating + valueWhileClosingRef.current = arrayValue + } + + const newArrayValue = structuredClone(arrayValue) + newArrayValue.splice(index, 1) + handleChange(newArrayValue) + }, + + // - Called after animation end + fulfillRemove: () => { + valueWhileClosingRef.current = null + delete modesRef.current?.[id] + delete isNewRef.current?.[id] + const findIndex = idsRef.current.indexOf(id) + idsRef.current.splice(findIndex, 1) + forceUpdate() + }, + + // - Called when cancel button press + restoreOriginalValue: (value) => { + if (value) { const newArrayValue = structuredClone(arrayValue) newArrayValue[index] = value handleChange(newArrayValue) - }, - } as IterateItemContextState + } + }, } - ) + + return itemContext + }) // In order to update "valueWhileClosingRef" we need to have "salt" in the deps array // eslint-disable-next-line react-hooks/exhaustive-deps @@ -192,13 +227,13 @@ function ArrayComponent(props: Props) { // - Call the onChange callback when a new element is added without calling "handlePush" useMemo(() => { - const last = elementData?.[elementData.length - 1] + const last = arrayItems?.[arrayItems.length - 1] if (last?.isNew && !hadPushRef.current) { onChange?.(arrayValue) } else { hadPushRef.current = false } - }, [arrayValue, elementData, onChange]) + }, [arrayValue, arrayItems, onChange]) const flexProps: FlexContainerProps & { innerRef: FlexContainerAllProps['innerRef'] @@ -219,8 +254,8 @@ function ArrayComponent(props: Props) { {arrayValue === emptyValue || props?.value?.length === 0 ? placeholder - : elementData.map((elementProps) => { - const { id, value, index } = elementProps + : arrayItems.map((itemProps) => { + const { id, value, index } = itemProps const elementRef = (innerRefs.current[id] = innerRefs.current[id] || createRef()) @@ -231,7 +266,7 @@ function ArrayComponent(props: Props) { } const contextValue = { - ...elementProps, + ...itemProps, elementRef, } @@ -267,5 +302,5 @@ function ArrayComponent(props: Props) { ) } -ArrayComponent._supportsSpacingProps = true +ArrayComponent._supportsSpacingProps = false // disable flex support to avoid rerender, which could result in flickering export default ArrayComponent diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayDocs.ts index 9a92c5ebde7..d6035e1b3ac 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayDocs.ts @@ -41,6 +41,11 @@ export const ArrayProperties: PropertiesTableProps = { type: 'unknown', status: 'optional', }, + containerMode: { + doc: 'Defines the container mode for all nested containers. Can be `view`, `edit` or `auto`. When using `auto`, it will automatically open if there is an error in the container. When a new item is added, the item before it will change to `view` mode, if it had no validation errors. Defaults to `auto`.', + type: 'string', + status: 'optional', + }, children: { doc: 'React.Node or a function so you can get the current value and index.', type: 'boolean', diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/ElementBlock.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemArea.tsx similarity index 54% rename from packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/ElementBlock.tsx rename to packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemArea.tsx index 0a971946f9f..d2ab1594f83 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/ElementBlock.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemArea.tsx @@ -10,12 +10,12 @@ import { Flex, HeightAnimation } from '../../../../components' import IterateItemContext, { IterateItemContextState, } from '../IterateItemContext' -import ElementBlockContext from './ElementBlockContext' +import ArrayItemAreaContext from './ArrayItemAreaContext' import FieldBoundaryContext from '../../DataContext/FieldBoundary/FieldBoundaryContext' import { Props as FlexContainerProps } from '../../../../components/flex/Container' -import { ContainerMode } from '../Array/types' +import { ContainerMode } from './types' -export type ElementSectionProps = { +export type ArrayItemAreaProps = { /** * Defines the variant of the ViewContainer or EditContainer. Can be `outline`. * Defaults to `outline`. @@ -28,32 +28,11 @@ export type Props = { open?: boolean | undefined ariaLabel?: string openDelay?: number -} & ElementSectionProps +} & ArrayItemAreaProps -function ElementBlock(props: Props & FlexContainerProps) { +function ArrayItemArea(props: Props & FlexContainerProps) { const [, forceUpdate] = useReducer(() => ({}), {}) - const contextRef = useRef< - IterateItemContextState & { - hasError?: boolean - hasSubmitError?: boolean - } - >() - contextRef.current = useContext(IterateItemContext) || {} - - const { hasError, hasSubmitError } = - useContext(FieldBoundaryContext) || {} - contextRef.current.hasError = hasError - contextRef.current.hasSubmitError = hasSubmitError - - // - Set the container mode to "edit" if we have an error - if (hasSubmitError) { - contextRef.current.containerMode = 'edit' - } - - const { handleRemove, switchContainerMode, containerMode, isNew } = - contextRef.current - const { mode, open, @@ -66,6 +45,41 @@ function ElementBlock(props: Props & FlexContainerProps) { ...restProps } = props + const localContextRef = useRef() + const { hasError, hasSubmitError } = + useContext(FieldBoundaryContext) || {} + localContextRef.current = useContext(IterateItemContext) || {} + const nextFocusElementRef = useRef() + const { isNew, value } = localContextRef.current + if (hasSubmitError || !value) { + localContextRef.current.containerMode = 'edit' + } + if (localContextRef.current.containerMode === 'auto') { + localContextRef.current.containerMode = 'view' + } + + const determineMode = useCallback(() => { + const { initialContainerMode, switchContainerMode } = + localContextRef.current + if ( + mode === 'edit' && + !hasSubmitError && + initialContainerMode === 'auto' + ) { + // - Set the container mode to "edit" if we have an error + if (hasError && !isNew) { + switchContainerMode('edit', { omitFocusManagement: true }) + } + } + }, [hasError, hasSubmitError, isNew, mode]) + + useEffect(() => { + determineMode() + }, [determineMode]) + + const { handleRemove, index, previousContainerMode, containerMode } = + localContextRef.current + const openRef = useRef(open ?? (containerMode === mode && !isNew)) const isRemoving = useRef(false) @@ -94,66 +108,61 @@ function ElementBlock(props: Props & FlexContainerProps) { } }, [containerMode, isNew, mode, open, openDelay, setOpenState]) - // - Remove the block with animation, if it's in the right mode - const handleAnimationEnd = useCallback( + const setFocus = useCallback( (state) => { - // - Keep the block open if we have an error - if (contextRef.current.hasSubmitError) { - switchContainerMode?.('edit') - } - - const preventFocusOnErrorOpening = !contextRef.current.hasSubmitError - if (preventFocusOnErrorOpening) { + if ( + localContextRef.current.modeOptions?.omitFocusManagement !== + true && + !hasSubmitError && + containerMode === mode && // ensure we match the correct mode + containerMode !== previousContainerMode // ensure we have a new mode + ) { if (state === 'opened') { - contextRef.current?.elementRef?.current?.focus?.() - } else { - // Wait until the element is removed, then check if we can set focus - window.requestAnimationFrame(() => { - // try to focus on the second last element - try { - if ( - // But not when we focus is already inside our element - !document.activeElement?.closest( - '.dnb-forms-iterate__element' - ) - ) { - const elements = - contextRef.current?.containerRef.current.querySelectorAll( - '.dnb-forms-iterate__element' - ) - elements[elements.length - 1].focus() - } - } catch (e) { - /**/ - } - }) + localContextRef.current.elementRef?.current?.focus?.() + } else if (state === 'closed') { + nextFocusElementRef.current?.focus?.() } } + }, + [containerMode, hasSubmitError, mode, previousContainerMode] + ) + // - Remove the block with animation, if it's in the right mode + const handleAnimationEnd = useCallback( + (state) => { if (!openRef.current && isRemoving.current) { isRemoving.current = false - contextRef.current?.fulfillRemove?.() + localContextRef.current.fulfillRemove?.() } + setFocus(state) onAnimationEnd?.(state) }, - [onAnimationEnd, switchContainerMode] + [onAnimationEnd, setFocus] ) - const handleRemoveBlock = useCallback(() => { + + const handleRemoveItem = useCallback(() => { + try { + // Because "previousElementSibling" did not work in Jest/JSDOM + nextFocusElementRef.current = Array.from( + localContextRef.current.elementRef.current.parentElement.childNodes + ).at(index - 1) as HTMLElement + } catch (e) { + // + } isRemoving.current = true handleRemove?.({ keepItems: true }) setOpenState(false) - }, [handleRemove, setOpenState]) + }, [handleRemove, index, setOpenState]) return ( - + - + ) } -ElementBlock._supportsSpacingProps = true -export default ElementBlock +ArrayItemArea._supportsSpacingProps = true +export default ArrayItemArea diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemAreaContext.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemAreaContext.ts new file mode 100644 index 00000000000..cd03dc0ef6a --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemAreaContext.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react' + +type ArrayItemAreaContext = { + handleRemoveItem?: () => void +} + +const ArrayItemAreaContext = createContext(null) + +export default ArrayItemAreaContext 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 8e80bdcef01..a4583a99dcc 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 @@ -29,7 +29,7 @@ describe('Iterate.Array', () => { const screenshot = await makeScreenshot({ url, selector: - '[data-visual-test="view-and-edit-container"] .dnb-forms-section-block', + '[data-visual-test="view-and-edit-container"] .dnb-forms-section-view-block', }) expect(screenshot).toMatchImageSnapshot() }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx index 8a3979283e1..298c9d46995 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx @@ -1,8 +1,9 @@ -import React from 'react' +import React, { useEffect } from 'react' import { fireEvent, render } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as Iterate from '../..' import * as DataContext from '../../../DataContext' +import { IterateItemContext } from '../..' import { Field, FieldBlock, Form, Value, ValueBlock } from '../../..' import { FilterData } from '../../../DataContext' @@ -416,6 +417,66 @@ describe('Iterate.Array', () => { expect(onChangeIterate).toHaveBeenLastCalledWith(['foo']) }) + it('should handle "defaultValue" with React.StrictMode', () => { + const onSubmit = jest.fn() + + render( + + + + + + + + ) + + const form = document.querySelector('form') + const input = document.querySelector('input') + + expect(input).toHaveValue('') + + fireEvent.submit(form) + + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmit).toHaveBeenLastCalledWith( + { myList: [''] }, + expect.anything() + ) + }) + + it('should warn when "defaultValue" is used inside iterate', () => { + const log = jest.spyOn(console, 'log').mockImplementation() + const onSubmit = jest.fn() + + render( + + + + + + ) + + const form = document.querySelector('form') + const input = document.querySelector('input') + + expect(input).toHaveValue('') + + fireEvent.submit(form) + + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmit).toHaveBeenLastCalledWith( + { myList: [''] }, + expect.anything() + ) + + expect(log).toHaveBeenCalledWith( + expect.any(String), + 'Using defaultValue="default value" prop inside Iterate is not supported yet' + ) + + log.mockRestore() + }) + describe('with primitive elements', () => { describe('referenced with path', () => { it('should distribute values and receive callbacks on both iterate and context', async () => { @@ -1111,4 +1172,97 @@ describe('Iterate.Array', () => { log.mockRestore() }) }) + + it('should contain tabindex of -1', () => { + render(content) + + expect( + document.querySelector('.dnb-forms-iterate__element') + ).toHaveAttribute('tabindex', '-1') + }) + + it('should set elementRef', () => { + let elementRef = null + + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + + useEffect(() => { + elementRef = context.elementRef.current + }) + + return null + } + + render( + + + + ) + + expect(elementRef).toBeDefined() + expect(elementRef instanceof HTMLElement).toBeTruthy() + }) + + it('should set index and value', () => { + let contextToTest = null + + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + + useEffect(() => { + contextToTest = context + }) + + return null + } + + render( + + + + ) + + expect(contextToTest).toMatchObject({ + index: 0, + value: 'one', + }) + }) + + it('focuses on the block when focusOnOpen prop is true', async () => { + const { rerender } = render( + + {(itemValue, index) => { + return ( + + Content {JSON.stringify(itemValue)} {index} + + ) + }} + + ) + + expect( + document.querySelectorAll('.dnb-forms-iterate__element') + ).toHaveLength(1) + expect(document.querySelector('output')).toHaveTextContent( + 'Content "foo" 0' + ) + + rerender( + + {(itemValue, index) => { + return ( + + Content {JSON.stringify(itemValue)} {index} + + ) + }} + + ) + + const outputs = document.querySelectorAll('output') + expect(outputs[0]).toHaveTextContent('Content "foo" 0') + expect(outputs[1]).toHaveTextContent('Content "bar" 1') + }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/ArrayElement.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/ArrayElement.test.tsx deleted file mode 100644 index 8e722078f2f..00000000000 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/ArrayElement.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React, { useEffect } from 'react' -import { render } from '@testing-library/react' -import IterateItemContext from '../../IterateItemContext' -import Array from '../Array' - -describe('ArrayElement', () => { - it('should contain tabindex of -1', () => { - render(content) - - expect( - document.querySelector('.dnb-forms-iterate__element') - ).toHaveAttribute('tabindex', '-1') - }) - - it('should set elementRef', () => { - let elementRef = null - - const ContextConsumer = () => { - const context = React.useContext(IterateItemContext) - - useEffect(() => { - elementRef = context.elementRef.current - }) - - return null - } - - render( - - - - ) - - expect(elementRef).toBeDefined() - expect(elementRef instanceof HTMLElement).toBeTruthy() - }) - - it('should set index and value', () => { - let contextToTest = null - - const ContextConsumer = () => { - const context = React.useContext(IterateItemContext) - - useEffect(() => { - contextToTest = context - }) - - return null - } - - render( - - - - ) - - expect(contextToTest).toMatchObject({ - index: 0, - value: 'one', - }) - }) - - it('focuses on the block when focusOnOpen prop is true', async () => { - const { rerender } = render( - - {(itemValue, index) => { - return ( - - Content {JSON.stringify(itemValue)} {index} - - ) - }} - - ) - - expect( - document.querySelectorAll('.dnb-forms-iterate__element') - ).toHaveLength(1) - expect(document.querySelector('output')).toHaveTextContent( - 'Content "foo" 0' - ) - - rerender( - - {(itemValue, index) => { - return ( - - Content {JSON.stringify(itemValue)} {index} - - ) - }} - - ) - - const outputs = document.querySelectorAll('output') - expect(outputs[0]).toHaveTextContent('Content "foo" 0') - expect(outputs[1]).toHaveTextContent('Content "bar" 1') - }) -}) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/__tests__/ElementBlock.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/ArrayItemArea.test.tsx similarity index 71% rename from packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/__tests__/ElementBlock.test.tsx rename to packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/ArrayItemArea.test.tsx index 783db2a76cd..fd57c5fd703 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/__tests__/ElementBlock.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/ArrayItemArea.test.tsx @@ -1,26 +1,29 @@ import React from 'react' import { fireEvent, render } from '@testing-library/react' import IterateItemContext from '../../IterateItemContext' -import ElementBlock from '../ElementBlock' +import ArrayItemArea from '../ArrayItemArea' import RemoveButton from '../../RemoveButton' +import FieldBoundaryContext from '../../../DataContext/FieldBoundary/FieldBoundaryContext' import { wait } from '../../../../../core/jest/jestSetup' import { DataContext, Field, Form, Iterate } from '../../..' import { simulateAnimationEnd } from '../../../../../components/height-animation/__tests__/HeightAnimationUtils' -describe('ElementBlock', () => { +describe('ArrayItemArea', () => { it('should call "onAnimationEnd"', () => { const onAnimationEnd = jest.fn() const wrapper = ({ children }) => ( - + {children} ) render( - + - , + , { wrapper } ) @@ -41,9 +44,9 @@ describe('ElementBlock', () => { ) render( - + - , + , { wrapper } ) @@ -57,16 +60,16 @@ describe('ElementBlock', () => { const wrapper = ({ children }) => ( {children} ) render( - + - , + , { wrapper } ) @@ -166,9 +169,9 @@ describe('ElementBlock', () => { ) const { rerender } = render( - + Content - , + , { wrapper } ) @@ -176,9 +179,9 @@ describe('ElementBlock', () => { expect(block).toHaveClass('dnb-height-animation--hidden') rerender( - + Content - + ) expect(block).toHaveClass('dnb-height-animation--hidden') @@ -196,7 +199,7 @@ describe('ElementBlock', () => { ) - render(Content, { wrapper }) + render(Content, { wrapper }) expect( document.querySelector('.dnb-forms-section-block__inner').tagName @@ -211,9 +214,9 @@ describe('ElementBlock', () => { ) render( - + Content - , + , { wrapper } ) @@ -224,7 +227,7 @@ describe('ElementBlock', () => { it('renders content and without errors', () => { const { rerender } = render( - content + content ) const element = document.querySelector('.dnb-forms-section-block') @@ -237,9 +240,9 @@ describe('ElementBlock', () => { expect(inner).toHaveTextContent('content') rerender( - + content - + ) expect(element).not.toHaveClass('dnb-height-animation--hidden') @@ -247,9 +250,9 @@ describe('ElementBlock', () => { it('closes the block when open prop is false', () => { render( - + content - + ) expect( @@ -262,9 +265,9 @@ describe('ElementBlock', () => { render( - + - + ) @@ -279,11 +282,16 @@ describe('ElementBlock', () => { render( - + - + ) @@ -301,8 +309,10 @@ describe('ElementBlock', () => { it('opens component based on containerMode', async () => { const { rerender } = render( - - content + + content ) @@ -311,19 +321,68 @@ describe('ElementBlock', () => { expect(element).not.toHaveClass('dnb-height-animation--hidden') rerender( - - content + + content ) expect(element).toHaveClass('dnb-height-animation--hidden') }) + it('opens in edit mode when falsy value was given', async () => { + let containerMode = null + + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + containerMode = context.containerMode + + return null + } + + render( + + + + + + ) + + expect(containerMode).toBe('edit') + }) + + it('should call switchContainerMode when mode is edit and error is present', async () => { + const switchContainerMode = jest.fn() + + render( + + + content + + + ) + + expect(switchContainerMode).toHaveBeenCalledTimes(1) + expect(switchContainerMode).toHaveBeenLastCalledWith('edit', { + omitFocusManagement: true, + }) + }) + it('opens component based on "open" prop', async () => { const { rerender } = render( - + content - + ) const element = document.querySelector('.dnb-forms-section-block') @@ -331,17 +390,17 @@ describe('ElementBlock', () => { expect(element).not.toHaveClass('dnb-height-animation--hidden') rerender( - + content - + ) expect(element).toHaveClass('dnb-height-animation--hidden') rerender( - + content - + ) expect(element).not.toHaveClass('dnb-height-animation--hidden') @@ -352,7 +411,7 @@ describe('ElementBlock', () => { - content + content ) @@ -370,15 +429,19 @@ describe('ElementBlock', () => { value={{ handleRemove, containerMode: 'view', + value: 'foo', }} > - + - + ) - fireEvent.click(document.querySelector('button')) + const buttons = document.querySelectorAll('button') + expect(buttons).toHaveLength(1) + + fireEvent.click(buttons[0]) expect(handleRemove).toHaveBeenCalledTimes(1) 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 ccbc44fbe3a1f60a5070d332c4a39ecf707d8d14..9a25b32faccb3c837fe856850729ee61d7c2ec71 100644 GIT binary patch literal 8040 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 e3n%1z{FslkZJJ8uV&EM$jh4FJ^l+D)zi diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-view-container.snap.png b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-view-container.snap.png index de37f704140d5e3dcb0f9e9ff2b9c689537a0b9c..d854dd7ee4b3769745c577545cf3e126ae1203f9 100644 GIT binary patch literal 11993 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 Bq8tDK literal 11162 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 export type ElementChild = | React.ReactNode @@ -12,13 +12,14 @@ export type Props = Omit< > & Pick< UseFieldProps, - 'value' | 'emptyValue' | 'onChange' + 'value' | 'defaultValue' | 'emptyValue' | 'onChange' > & { children: ElementChild | Array path?: Path countPath?: Path countPathLimit?: number - countPathTransform?: (params: { value: any; index: number }) => any withoutFlex?: boolean placeholder?: React.ReactNode + containerMode?: ContainerMode + countPathTransform?: (params: { value: any; index: number }) => any } diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/CancelButton.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/CancelButton.tsx new file mode 100644 index 00000000000..43474708c8b --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/CancelButton.tsx @@ -0,0 +1,107 @@ +import React, { useCallback, useContext, useEffect, useRef } from 'react' +import { Button } from '../../../../components' +import useTranslation from '../../hooks/useTranslation' +import IterateItemContext from '../IterateItemContext' +import ToolbarContext from '../Toolbar/ToolbarContext' +import FieldBoundaryContext from '../../DataContext/FieldBoundary/FieldBoundaryContext' +import { close } from '../../../../icons' +import { ButtonProps } from '../../../../components/Button' +import RemoveButton, { Props as RemoveButtonProps } from '../RemoveButton' +import { ContainerMode } from '../Array' + +type Props = ButtonProps + +export default function EditToolbarTools(props: Props) { + const { + restoreOriginalValue, + switchContainerMode, + containerMode, + initialContainerMode, + arrayValue, + isNew, + index, + } = useContext(IterateItemContext) || {} + const { hasError, hasVisibleError, setShowBoundaryErrors } = + useContext(FieldBoundaryContext) || {} + const { setShowError } = useContext(ToolbarContext) || {} + + const { cancelButton, removeButton } = + useTranslation().IterateEditContainer + const valueBackupRef = useRef() + + useEffect(() => { + if (containerMode === 'edit' && !valueBackupRef.current) { + valueBackupRef.current = arrayValue?.[index] + } + if (containerMode === 'view') { + valueBackupRef.current = null + } + }, [arrayValue, containerMode, index]) + + const cancelHandler = useCallback(() => { + if (hasError && initialContainerMode === 'auto') { + setShowBoundaryErrors?.(true) + if (hasVisibleError) { + setShowError(true) + } + } else { + restoreOriginalValue?.(valueBackupRef.current) + setShowError(false) + setShowBoundaryErrors?.(false) + switchContainerMode?.('view') + } + }, [ + hasError, + hasVisibleError, + initialContainerMode, + restoreOriginalValue, + setShowBoundaryErrors, + setShowError, + switchContainerMode, + ]) + + const wasNew = useWasNew({ isNew, containerMode }) + + if (containerMode === 'edit' && arrayValue?.length === 0) { + return <> + } + + if (wasNew) { + return ( + + ) + } + + return ( + + ) +} + +export function useWasNew({ + isNew, + containerMode, +}: { + isNew: boolean + containerMode: ContainerMode +}) { + const wasNewRef = useRef(isNew) + + useEffect(() => { + if (containerMode === 'view') { + wasNewRef.current = false + } + }, [isNew, containerMode]) + + return wasNewRef.current +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/DoneButton.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/DoneButton.tsx new file mode 100644 index 00000000000..130c0a0bf1d --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/DoneButton.tsx @@ -0,0 +1,69 @@ +import React, { useCallback, useContext, useEffect, useRef } from 'react' +import { Button } from '../../../../components' +import useTranslation from '../../hooks/useTranslation' +import IterateItemContext from '../IterateItemContext' +import ToolbarContext from '../Toolbar/ToolbarContext' +import FieldBoundaryContext from '../../DataContext/FieldBoundary/FieldBoundaryContext' +import PushContainerContext from '../PushContainer/PushContainerContext' +import { check } from '../../../../icons' +import { ButtonProps } from '../../../../components/Button' + +type Props = ButtonProps + +export default function DoneButton(props: Props) { + const { switchContainerMode, containerMode, arrayValue, index } = + useContext(IterateItemContext) || {} + const { hasError, hasVisibleError, setShowBoundaryErrors } = + useContext(FieldBoundaryContext) || {} + const { commitHandleRef } = useContext(PushContainerContext) || {} + useContext(FieldBoundaryContext) || {} + const { setShowError } = useContext(ToolbarContext) || {} + + const { doneButton } = useTranslation().IterateEditContainer + const valueBackupRef = useRef() + + useEffect(() => { + if (containerMode === 'edit' && !valueBackupRef.current) { + valueBackupRef.current = arrayValue?.[index] + } + if (containerMode === 'view') { + valueBackupRef.current = null + } + }, [arrayValue, containerMode, index]) + + const doneHandler = useCallback(() => { + if (hasError) { + setShowBoundaryErrors?.(true) + if (hasVisibleError) { + setShowError(true) + } + } else { + setShowBoundaryErrors?.(false) + setShowError(false) + if (commitHandleRef) { + commitHandleRef.current?.() + } else { + switchContainerMode?.('view') + } + } + }, [ + commitHandleRef, + hasError, + hasVisibleError, + setShowBoundaryErrors, + setShowError, + switchContainerMode, + ]) + + return ( + + ) +} 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 6946f1b2480..5f92fae827f 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainer.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainer.tsx @@ -4,11 +4,11 @@ import { convertJsxToString } from '../../../../shared/component-helper' import { Lead } from '../../../../elements' import { Props as FlexContainerProps } from '../../../../components/flex/Container' import IterateItemContext from '../IterateItemContext' -import EditToolbarTools, { useWasNew } from './EditToolbarTools' -import ElementBlock, { - ElementSectionProps, -} from '../AnimatedContainer/ElementBlock' +import ArrayItemArea, { ArrayItemAreaProps } from '../Array/ArrayItemArea' import Toolbar from '../Toolbar' +import { useSwitchContainerMode } from '../hooks' +import DoneButton from './DoneButton' +import CancelButton, { useWasNew } from './CancelButton' export type Props = { /** @@ -32,29 +32,41 @@ export type Props = { toolbar?: React.ReactNode } -export type AllProps = Props & FlexContainerProps & ElementSectionProps +export type AllProps = Props & FlexContainerProps & ArrayItemAreaProps export default function EditContainer(props: AllProps) { - const { toolbar, ...rest } = props + const { toolbar, children, ...rest } = props + + const hasToolbar = + !toolbar && + React.Children.toArray(children).some((child) => { + return child?.['type'] === Toolbar + }) + return ( - - - ) + hasToolbar + ? null + : toolbar ?? ( + + + + + ) } {...rest} - /> + > + {children} + ) } export function EditContainerWithoutToolbar( props: Props & FlexContainerProps & { toolbar?: React.ReactNode } ) { - const iterateItemContext = useContext(IterateItemContext) - const { containerMode, isNew } = iterateItemContext ?? {} + const { containerMode, isNew, index, path } = + useContext(IterateItemContext) const { children, @@ -69,14 +81,13 @@ export function EditContainerWithoutToolbar( let itemTitle = wasNew && titleWhenNew ? titleWhenNew : title let ariaLabel = useMemo(() => convertJsxToString(itemTitle), [itemTitle]) if (ariaLabel.includes('{itemNr}')) { - itemTitle = ariaLabel = ariaLabel.replace( - '{itemNr}', - iterateItemContext.index + 1 - ) + itemTitle = ariaLabel = ariaLabel.replace('{itemNr}', index + 1) } + useSwitchContainerMode({ path }) + return ( - {itemTitle}} {children} {toolbar} - + ) } +EditContainer.DoneButton = DoneButton +EditContainer.CancelButton = CancelButton + EditContainer._supportsSpacingProps = true EditContainerWithoutToolbar._supportsSpacingProps = true diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditToolbarTools.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditToolbarTools.tsx deleted file mode 100644 index fb3486e954c..00000000000 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditToolbarTools.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { - useCallback, - useContext, - useEffect, - useRef, - useState, -} from 'react' -import { Button, Flex, FormStatus } from '../../../../components' -import useTranslation from '../../hooks/useTranslation' -import IterateItemContext from '../IterateItemContext' -import { check, close } from '../../../../icons' -import RemoveButton from '../RemoveButton' -import { ContainerMode } from '../Array/types' -import FieldBoundaryContext from '../../DataContext/FieldBoundary/FieldBoundaryContext' -import PushContainerContext from '../PushContainer/PushContainerContext' - -export default function EditToolbarTools() { - const { - restoreOriginalValue, - switchContainerMode, - containerMode, - arrayValue, - index, - isNew, - } = useContext(IterateItemContext) || {} - const { hasError, hasVisibleError, setShowBoundaryErrors } = - useContext(FieldBoundaryContext) || {} - const { entries, commitHandleRef } = - useContext(PushContainerContext) || {} - - const { doneButton, cancelButton, removeButton, errorInSection } = - useTranslation().IterateEditContainer - const { createButton } = useTranslation().IteratePushContainer - const valueBackupRef = useRef() - const wasNew = useWasNew({ isNew, containerMode }) - const [showError, setShowError] = useState(false) - - useEffect(() => { - if (containerMode === 'edit' && !valueBackupRef.current) { - valueBackupRef.current = arrayValue?.[index] - } - if (containerMode === 'view') { - valueBackupRef.current = null - } - }, [arrayValue, containerMode, index]) - - const cancelHandler = useCallback(() => { - if (valueBackupRef.current) { - restoreOriginalValue?.(valueBackupRef.current) - } - setShowError(false) - setShowBoundaryErrors?.(false) - switchContainerMode?.('view') - }, [restoreOriginalValue, setShowBoundaryErrors, switchContainerMode]) - const doneHandler = useCallback(() => { - if (hasError) { - setShowBoundaryErrors?.(true) - if (hasVisibleError) { - setShowError(true) - } - } else { - setShowBoundaryErrors?.(false) - setShowError(false) - if (commitHandleRef) { - commitHandleRef.current?.() - } else { - switchContainerMode?.('view') - } - } - }, [ - commitHandleRef, - hasError, - hasVisibleError, - setShowBoundaryErrors, - switchContainerMode, - ]) - - return ( - <> - - {errorInSection} - - - {commitHandleRef ? ( - - ) : ( - - )} - - {(!entries || (entries?.length > 0 && containerMode === 'edit')) && - (wasNew ? ( - - ) : ( - - ))} - - - ) -} - -export function useWasNew({ - isNew, - containerMode, -}: { - isNew: boolean - containerMode: ContainerMode -}) { - const wasNewRef = useRef(isNew) - - useEffect(() => { - if (containerMode === 'view') { - wasNewRef.current = false - } - }, [isNew, containerMode]) - - return wasNewRef.current -} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditToolbarTools.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/CancelButton.test.tsx similarity index 62% rename from packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditToolbarTools.test.tsx rename to packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/CancelButton.test.tsx index 9cb2864d79d..81fce673366 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditToolbarTools.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/CancelButton.test.tsx @@ -2,20 +2,19 @@ import React from 'react' import { render, fireEvent } from '@testing-library/react' import IterateItemContext from '../../IterateItemContext' import Toolbar from '../../Toolbar' -import EditToolbarTools from '../EditToolbarTools' - +import CancelButton from '../CancelButton' import nbNO from '../../../constants/locales/nb-NO' const nb = nbNO['nb-NO'].IterateEditContainer -describe('EditToolbarTools', () => { - it('calls "switchContainerMode" when remove button is clicked', () => { +describe('CancelButton', () => { + it('calls "switchContainerMode"', () => { const switchContainerMode = jest.fn() render( - + ) @@ -26,7 +25,7 @@ describe('EditToolbarTools', () => { expect(switchContainerMode).toHaveBeenCalledWith('view') }) - it('calls "switchContainerMode" when remove button is clicked and isNew is true', () => { + it('should not call "switchContainerMode" when isNew is true', () => { const switchContainerMode = jest.fn() render( @@ -34,18 +33,17 @@ describe('EditToolbarTools', () => { value={{ switchContainerMode, isNew: true }} > - + ) fireEvent.click(document.querySelectorAll('button')[0]) - expect(switchContainerMode).toHaveBeenCalledTimes(1) - expect(switchContainerMode).toHaveBeenCalledWith('view') + expect(switchContainerMode).toHaveBeenCalledTimes(0) }) - it('calls "restoreOriginalValue" when cancel button is clicked', () => { + it('calls "restoreOriginalValue"', () => { const restoreOriginalValue = jest.fn() render( @@ -58,18 +56,18 @@ describe('EditToolbarTools', () => { }} > - + ) - fireEvent.click(document.querySelectorAll('button')[1]) + fireEvent.click(document.querySelectorAll('button')[0]) expect(restoreOriginalValue).toHaveBeenCalledTimes(1) expect(restoreOriginalValue).toHaveBeenCalledWith('original value') }) - describe('to have buttons with correct text', () => { + describe('to have button with correct text', () => { it('and isNew is true', () => { render( { }} > - + ) - const buttons = document.querySelectorAll('button') - - expect(buttons).toHaveLength(2) - expect(buttons[0]).toHaveTextContent(nb.doneButton) - expect(buttons[1]).toHaveTextContent(nb.removeButton) + const button = document.querySelector('button') + expect(button).toHaveTextContent(nb.removeButton) }) it('and isNew is not set', () => { render( - + ) - const buttons = document.querySelectorAll('button') - - expect(buttons).toHaveLength(2) - expect(buttons[0]).toHaveTextContent(nb.doneButton) - expect(buttons[1]).toHaveTextContent(nb.cancelButton) + const button = document.querySelector('button') + expect(button).toHaveTextContent(nb.cancelButton) }) }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/DoneButton.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/DoneButton.test.tsx new file mode 100644 index 00000000000..0c5ca02404a --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/DoneButton.test.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import { render, fireEvent } from '@testing-library/react' +import IterateItemContext from '../../IterateItemContext' +import Toolbar from '../../Toolbar' +import DoneButton from '../DoneButton' +import nbNO from '../../../constants/locales/nb-NO' + +const nb = nbNO['nb-NO'].IterateEditContainer + +describe('DoneButton', () => { + it('calls "switchContainerMode"', () => { + const switchContainerMode = jest.fn() + + render( + + + + + + ) + + fireEvent.click(document.querySelectorAll('button')[0]) + + expect(switchContainerMode).toHaveBeenCalledTimes(1) + expect(switchContainerMode).toHaveBeenCalledWith('view') + }) + + it('calls "switchContainerMode" when isNew is true', () => { + const switchContainerMode = jest.fn() + + render( + + + + + + ) + + fireEvent.click(document.querySelectorAll('button')[0]) + + expect(switchContainerMode).toHaveBeenCalledTimes(1) + expect(switchContainerMode).toHaveBeenCalledWith('view') + }) + + describe('to have button with correct text', () => { + it('and isNew is true', () => { + render( + + + + + + ) + + const button = document.querySelector('button') + expect(button).toHaveTextContent(nb.doneButton) + }) + + it('and isNew is not set', () => { + render( + + + + + + ) + + const button = document.querySelector('button') + expect(button).toHaveTextContent(nb.doneButton) + }) + }) +}) 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 d509f08f7b0..bcc78da8312 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,13 +1,14 @@ import React from 'react' import { render, fireEvent, waitFor } from '@testing-library/react' import IterateItemContext from '../../IterateItemContext' -import EditContainer from '../EditContainer' -import ViewContainer from '../../ViewContainer' -import { Field, Form, Iterate } from '../../..' +import { Field, Form, Iterate, Value } from '../../..' import userEvent from '@testing-library/user-event' import nbNO from '../../../constants/locales/nb-NO' -const nb = nbNO['nb-NO'].IterateEditContainer +const tr = { + viewContainer: nbNO['nb-NO'].IterateViewContainer, + editContainer: nbNO['nb-NO'].IterateEditContainer, +} describe('EditContainer and ViewContainer', () => { it('should switch mode on pressing edit button', async () => { @@ -69,7 +70,7 @@ describe('EditContainer and ViewContainer', () => { expect(secondElement).toHaveTextContent('Edit Content') }) - it('should switch mode from view to edit on error during submit', async () => { + it('should switch mode from view to edit when a field errors', async () => { let containerMode = null const ContextConsumer = () => { @@ -81,118 +82,308 @@ describe('EditContainer and ViewContainer', () => { render( - - - - - content + + + { + if (value === '01') { + return new Error('error') + } + }} + /> + + content ) - const input = document.querySelector('input') - await userEvent.type(input, 'x{Backspace}') expect(containerMode).toBe('view') - const form = document.querySelector('form') - fireEvent.submit(form) + const input = document.querySelector('input') + await userEvent.type(input, '1') expect(containerMode).toBe('edit') }) - it('should set focus on __element when containerMode changes', async () => { + it('should switch mode from view to edit on error during submit', async () => { + let containerMode = null + + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + containerMode = context.containerMode + + return null + } + render( - - View Content - Edit Content - + + + + { + if (value === '01') { + return new Error('error') + } + }} + /> + + content + + + + ) - const elements = document.querySelectorAll( - '.dnb-forms-iterate__element' - ) - expect(elements).toHaveLength(2) + expect(containerMode).toBe('view') - const firstElement = elements[0] - const [viewBlock, editBlock] = Array.from( - firstElement.querySelectorAll('.dnb-forms-section-block') - ) - const [editButton] = Array.from(viewBlock.querySelectorAll('button')) - const [cancelButton] = Array.from(editBlock.querySelectorAll('button')) + const input = document.querySelector('input') + await userEvent.type(input, '1') - expect(viewBlock).toHaveClass('dnb-forms-section-view-block') - expect(editBlock).toHaveClass('dnb-forms-section-edit-block') + expect(containerMode).toBe('view') - expect(document.body).toHaveFocus() + const button = document.querySelector('button') + await userEvent.click(button) - // Switch to edit mode - fireEvent.click(editButton) - expect(firstElement).toHaveTextContent('Edit Content') + expect(containerMode).toBe('edit') + }) - await waitFor(() => { - expect(firstElement).toHaveFocus() - }) + describe('focus management', () => { + it('should not set focus when container opens initially in edit mode', async () => { + let containerMode = null - // Reset focus, so we can test focus during close - ;(document.activeElement as HTMLElement).blur() + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + containerMode = context.containerMode - // Switch to view mode - fireEvent.click(cancelButton) - expect(firstElement).toHaveTextContent('View Content') + return null + } - await waitFor(() => { - expect(firstElement).toHaveFocus() - }) + render( + + View Content + + + + + + ) - // Reset focus, so we can test focus during close - ;(document.activeElement as HTMLElement).blur() + expect(document.body).toHaveFocus() + expect(containerMode).toBe('edit') - // Switch to edit mode - fireEvent.click(editButton) - expect(firstElement).toHaveTextContent('Edit Content') + const elements = document.querySelectorAll( + '.dnb-forms-iterate__element' + ) + const firstElement = elements[0] + const [viewBlock] = Array.from( + firstElement.querySelectorAll('.dnb-forms-section-block') + ) - await waitFor(() => { - expect(firstElement).toHaveFocus() + await waitFor(() => { + expect(viewBlock).toHaveClass('dnb-height-animation--hidden') + }) + + expect(document.body).toHaveFocus() + expect(containerMode).toBe('edit') }) - }) - it('should set focus on other item __element when containerMode gets removed', async () => { - render( - - View Content - Edit Content - - ) + it('should only set focus on newly added element when containerMode changes', async () => { + const containerMode = [] + + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + containerMode.push(context.containerMode) + + return null + } + + render( + + + + + + + + + + + + + ) - const elements = document.querySelectorAll( - '.dnb-forms-iterate__element' - ) - expect(elements).toHaveLength(2) + expect(document.body).toHaveFocus() + expect(containerMode).toEqual([]) + + const button = document.querySelector('button') + await userEvent.click(button) + + expect(containerMode).toEqual(['edit', 'edit', 'edit']) + + { + const elements = document.querySelectorAll( + '.dnb-forms-iterate__element' + ) + expect(elements).toHaveLength(1) + + const firstElement = elements[0] + const [, editBlock] = Array.from( + firstElement.querySelectorAll('.dnb-forms-section-block') + ) + + expect( + document.querySelector('.dnb-forms-iterate-push-button') + ).toHaveFocus() + await waitFor(() => { + expect(editBlock).toHaveStyle('height: auto;') + }) + expect(firstElement).toHaveFocus() + } + + await userEvent.click(button) + + { + expect(containerMode).toEqual([ + 'edit', + 'edit', + 'edit', + 'edit', + 'edit', + 'edit', + ]) + + const elements = document.querySelectorAll( + '.dnb-forms-iterate__element' + ) + expect(elements).toHaveLength(2) + + const firstElement = elements[0] + const secondElement = elements[1] + const [, editBlock] = Array.from( + secondElement.querySelectorAll('.dnb-forms-section-block') + ) + + expect( + document.querySelector('.dnb-forms-iterate-push-button') + ).toHaveFocus() + await waitFor(() => { + expect(editBlock).toHaveStyle('height: auto;') + }) + expect( + firstElement.querySelector('.dnb-forms-section-block') + ).toHaveStyle('height: 0;') + expect(secondElement).toHaveFocus() + expect(containerMode).toEqual([ + 'edit', + 'edit', + 'edit', + 'edit', + 'edit', + 'edit', + 'view', + 'edit', + ]) + } + }) - const firstElement = elements[0] - const [viewBlock, editBlock] = Array.from( - firstElement.querySelectorAll('.dnb-forms-section-block') - ) - const [, removeButton] = Array.from( - viewBlock.querySelectorAll('button') - ) + it('should set focus on __element when containerMode changes', async () => { + render( + + View Content + Edit Content + + ) - expect(viewBlock).toHaveClass('dnb-forms-section-view-block') - expect(editBlock).toHaveClass('dnb-forms-section-edit-block') + const elements = document.querySelectorAll( + '.dnb-forms-iterate__element' + ) + expect(elements).toHaveLength(2) - expect(document.body).toHaveFocus() + const firstElement = elements[0] + const [viewBlock, editBlock] = Array.from( + firstElement.querySelectorAll('.dnb-forms-section-block') + ) + const [editButton] = Array.from(viewBlock.querySelectorAll('button')) + const [cancelButton] = Array.from( + editBlock.querySelectorAll('button') + ) - // Remove the element - fireEvent.click(removeButton) - expect(removeButton).toHaveTextContent(nb.removeButton) + expect(viewBlock).toHaveClass('dnb-forms-section-view-block') + expect(editBlock).toHaveClass('dnb-forms-section-edit-block') + + expect(document.body).toHaveFocus() + + // Switch to edit mode + fireEvent.click(editButton) + expect(firstElement).toHaveTextContent('Edit Content') + + await waitFor(() => { + expect(firstElement).toHaveFocus() + }) + + // Reset focus, so we can test focus during close + ;(document.activeElement as HTMLElement).blur() + + // Switch to view mode + fireEvent.click(cancelButton) + expect(firstElement).toHaveTextContent('View Content') + + await waitFor(() => { + expect(firstElement).toHaveFocus() + }) + + // Reset focus, so we can test focus during close + ;(document.activeElement as HTMLElement).blur() + + // Switch to edit mode + fireEvent.click(editButton) + expect(firstElement).toHaveTextContent('Edit Content') + + await waitFor(() => { + expect(firstElement).toHaveFocus() + }) + }) + + it('should set focus on other item __element when containerMode gets removed', async () => { + render( + + View Content + Edit Content + + ) - await waitFor(() => { const elements = document.querySelectorAll( '.dnb-forms-iterate__element' ) - expect(elements).toHaveLength(1) - expect(elements[0]).toHaveFocus() + expect(elements).toHaveLength(3) + + const firstElement = elements[0] + const [viewBlock, editBlock] = Array.from( + firstElement.querySelectorAll('.dnb-forms-section-block') + ) + const [, removeButton] = Array.from( + viewBlock.querySelectorAll('button') + ) + + expect(viewBlock).toHaveClass('dnb-forms-section-view-block') + expect(editBlock).toHaveClass('dnb-forms-section-edit-block') + + expect(document.body).toHaveFocus() + + // Remove the element + await userEvent.click(removeButton) + expect(removeButton).toHaveTextContent(tr.editContainer.removeButton) + + await waitFor(() => { + const elements = document.querySelectorAll( + '.dnb-forms-iterate__element' + ) + expect(elements).toHaveLength(2) + expect(elements[1]).toHaveFocus() + }) }) }) @@ -242,10 +433,10 @@ describe('EditContainer and ViewContainer', () => { render( - + - - content + + content ) @@ -257,22 +448,21 @@ describe('EditContainer and ViewContainer', () => { const [doneButton, cancelButton, editButton] = Array.from( document.querySelectorAll('button') ) - expect(doneButton).toHaveTextContent(nb.doneButton) - expect(cancelButton).toHaveTextContent(nb.cancelButton) - expect(editButton).toHaveTextContent( - nbNO['nb-NO'].IterateViewContainer.editButton - ) + expect(doneButton).toHaveTextContent(tr.editContainer.doneButton) + expect(cancelButton).toHaveTextContent(tr.editContainer.cancelButton) + expect(editButton).toHaveTextContent(tr.viewContainer.editButton) await userEvent.click(doneButton) expect(document.querySelector('.dnb-form-status')).toBeInTheDocument() expect(onChange).toHaveBeenCalledTimes(0) await userEvent.click(cancelButton) + + expect(document.querySelector('.dnb-form-status')).toBeInTheDocument() + await userEvent.click(editButton) - expect( - document.querySelector('.dnb-form-status') - ).not.toBeInTheDocument() + expect(document.querySelector('.dnb-form-status')).toBeInTheDocument() await userEvent.click(doneButton) @@ -281,8 +471,370 @@ describe('EditContainer and ViewContainer', () => { await userEvent.type(document.querySelector('input'), 'foo') await userEvent.click(doneButton) + await waitFor(() => { + expect( + document.querySelector('.dnb-form-status') + ).not.toBeInTheDocument() + }) + }) + + it('should set all items to the given containerMode', async () => { + let containerMode = null + + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + containerMode = context.containerMode + + return null + } + + render( + + View Content + Edit Content + + + ) + + expect(containerMode).toBe('edit') + + const elements = document.querySelectorAll( + '.dnb-forms-iterate__element' + ) + expect(elements).toHaveLength(2) + + const [firstElement, secondElement] = Array.from(elements) + + { + const [viewBlock, editBlock] = Array.from( + firstElement.querySelectorAll('.dnb-forms-section-block') + ) + expect(viewBlock).toHaveClass('dnb-forms-section-view-block') + expect(viewBlock).toHaveClass('dnb-height-animation--hidden') + expect(editBlock).toHaveClass('dnb-forms-section-edit-block') + expect(editBlock).not.toHaveClass('dnb-height-animation--hidden') + } + + { + const [viewBlock, editBlock] = Array.from( + secondElement.querySelectorAll('.dnb-forms-section-block') + ) + expect(viewBlock).toHaveClass('dnb-forms-section-view-block') + expect(viewBlock).toHaveClass('dnb-height-animation--hidden') + expect(editBlock).toHaveClass('dnb-forms-section-edit-block') + expect(editBlock).not.toHaveClass('dnb-height-animation--hidden') + } + + expect(containerMode).toBe('edit') + }) + + it('should hide toolbar in view mode', async () => { + render( + + + View Content + + Edit Content + + ) + + 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(2) + expect(viewBlock.querySelectorAll('button')).toHaveLength(0) + } + }) + + it('should render the given toolbar buttons', () => { + const viewToolbar = ( + + {({ items }) => { + if (items.length === 1) { + return + } + return ( + <> + + + + ) + }} + + ) + + const editToolbar = ( + + {({ items }) => { + if (items.length === 1) { + return null + } + return ( + <> + + + + ) + }} + + ) + + const { rerender } = render( + + + View Content + + + Edit Content + + + ) + + { + 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( + + + View Content + + + Edit Content + + + ) + + { + 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 validate on submit', () => { + render( + + + + + + content + + + ) + expect( document.querySelector('.dnb-form-status') ).not.toBeInTheDocument() + + const input = document.querySelector('input') + fireEvent.submit(input) + + expect(document.querySelector('.dnb-form-status')).toBeInTheDocument() + }) + + it('should show "errorInContainer" message', async () => { + render( + + + + + + + + ) + + expect( + document.querySelector('.dnb-form-status') + ).not.toBeInTheDocument() + + const [doneButton, cancelButton] = Array.from( + document.querySelectorAll('button') + ) + const input = document.querySelector('input') + fireEvent.submit(input) + + expect(document.querySelectorAll('.dnb-form-status')).toHaveLength(1) + + fireEvent.submit(input) + + expect(document.querySelectorAll('.dnb-form-status')).toHaveLength(1) + + await userEvent.click(doneButton) + + expect(document.querySelectorAll('.dnb-form-status')).toHaveLength(2) + + await userEvent.type(input, 'x{Backspace}') + + await waitFor(() => { + expect(document.querySelectorAll('.dnb-form-status')).toHaveLength(0) + }) + + await userEvent.click(cancelButton) + + // Expect 2, because we already have an error in the field + expect(document.querySelectorAll('.dnb-form-status')).toHaveLength(2) + expect( + document.querySelectorAll('.dnb-form-status')[1] + ).toHaveTextContent( + nbNO['nb-NO'].IterateEditContainer.errorInContainer + ) + + await userEvent.type(input, 'x') + + await waitFor(() => { + expect(document.querySelectorAll('.dnb-form-status')).toHaveLength(0) + }) + }) + + it('should open in "edit" mode without focusing', async () => { + const containerMode = {} + + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + containerMode[context.index] = context.containerMode + + return null + } + + render( + + + View Content + + + + + + + + ) + + expect(containerMode[0]).toBe('edit') + expect(document.body).toHaveFocus() + }) + + it('should set first item to "view" mode when a new is pushed to the array', async () => { + const containerMode = {} + + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + containerMode[context.index] = context.containerMode + + return null + } + + render( + + + View Content + + + + + + + + + ) + + expect(containerMode[0]).toBe('edit') + + const input = document.querySelector('input') + await userEvent.type(input, 'foo') + + await userEvent.click( + document.querySelector('button.dnb-forms-iterate-push-button') + ) + + const blocks = Array.from( + document.querySelectorAll('.dnb-forms-section-block') + ) + expect(blocks).toHaveLength(4) + const [, secondBlock] = blocks + + expect(containerMode[1]).toBe('edit') + + await userEvent.click(secondBlock.querySelector('button')) + + await waitFor(() => { + expect(containerMode[0]).toBe('view') + expect(containerMode[1]).toBe('edit') + }) + }) + + it('should keep first item in "edit" mode when there is an error', async () => { + const containerMode = {} + + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + containerMode[context.index] = context.containerMode + + return null + } + + render( + + + View Content + + + + + + + + + ) + + expect(containerMode[0]).toBe('edit') + + await userEvent.click( + document.querySelector('button.dnb-forms-iterate-push-button') + ) + + const blocks = Array.from( + document.querySelectorAll('.dnb-forms-section-block') + ) + expect(blocks).toHaveLength(4) + const [, secondBlock] = blocks + + expect(containerMode[0]).toBe('edit') + expect(containerMode[1]).toBe('edit') + + await userEvent.click(secondBlock.querySelector('button')) + + expect(containerMode[0]).toBe('edit') + expect(containerMode[1]).toBe('edit') }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditContainer.test.tsx index 31984fd95c1..920ff488216 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditContainer.test.tsx @@ -11,7 +11,9 @@ const nb = nbNO['nb-NO'].IterateEditContainer describe('EditContainer', () => { it('renders content and without errors', () => { const { rerender } = render( - + content ) @@ -26,7 +28,9 @@ describe('EditContainer', () => { expect(inner).toHaveTextContent('content') rerender( - + content ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/index.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/index.ts index d3b957bc949..bebeac850c3 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/index.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/index.ts @@ -1,2 +1,4 @@ export { default } from './EditContainer' export * from './EditContainer' +export { default as DoneButton } from './DoneButton' +export { default as CancelButton } from './CancelButton' diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts index a18365aa887..136f5c83c37 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts @@ -1,6 +1,10 @@ import React from 'react' import { Path } from '../types' -import { ContainerMode } from './Array/types' +import { ContainerMode, ElementChild } from './Array/types' + +export type ModeOptions = { + omitFocusManagement?: boolean +} export interface IterateItemContextState { id?: string @@ -8,11 +12,17 @@ export interface IterateItemContextState { value?: unknown isNew?: boolean path?: Path - arrayValue?: Array + arrayValue?: Array containerMode?: ContainerMode + previousContainerMode?: ContainerMode + initialContainerMode?: ContainerMode containerRef?: React.RefObject elementRef?: React.RefObject - switchContainerMode?: (mode: ContainerMode) => void + modeOptions?: ModeOptions + switchContainerMode?: ( + mode: ContainerMode, + options?: ModeOptions + ) => void handleChange?: (path: Path, value: unknown) => void handleRemove?: ({ keepItems }?: { keepItems?: boolean }) => void handlePush?: (value: unknown) => void diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushButton/PushButton.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushButton/PushButton.tsx index f9f56253258..2a58b6215b8 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushButton/PushButton.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushButton/PushButton.tsx @@ -3,43 +3,62 @@ import classnames from 'classnames' import { Button } from '../../../../components' import { ButtonProps } from '../../../../components/Button' import IterateItemContext from '../IterateItemContext' -import { useFieldProps } from '../../hooks' -import { - DataValueReadWriteComponentProps, - omitDataValueReadWriteProps, -} from '../../types' +import { useSwitchContainerMode } from '../hooks' +import { omitDataValueReadWriteProps, Path } from '../../types' import { add } from '../../../../icons' +import DataContext from '../../DataContext/Context' +import useDataValue from '../../hooks/useDataValue' -export type Props = ButtonProps & - DataValueReadWriteComponentProps & { - pushValue: unknown | ((value: unknown) => void) - } +export type Props = ButtonProps & { + path?: Path + pushValue: unknown | ((value: unknown) => void) + + /** + * Used internally + */ + value?: unknown +} function PushButton(props: Props) { + const { handlePathChange } = useContext(DataContext) || {} const iterateItemContext = useContext(IterateItemContext) const { handlePush } = iterateItemContext ?? {} - const { pushValue, className, ...restProps } = props + const { pushValue, className, path, children, ...restProps } = props const buttonProps = omitDataValueReadWriteProps(restProps) - const { value, handleChange, children } = useFieldProps(restProps) + const value = useDataValue().getValueByPath(path) if (value !== undefined && !Array.isArray(value)) { throw new Error('PushButton received a non-array value') } - const handleClick = useCallback(() => { + const { setLastItemContainerMode } = useSwitchContainerMode({ + path, + }) + + const handleClick = useCallback(async () => { const newValue = typeof pushValue === 'function' ? pushValue(value) : pushValue if (handlePush) { // Inside an Iterate element - make the change through the Iterate component handlePush(newValue) - return // stop here + } else { + // If not inside an iterate, it could still manipulate a source data set through useFieldProps + await handlePathChange?.(path, [...(value ?? []), newValue]) } - // If not inside an iterate, it could still manipulate a source data set through useFieldProps - handleChange([...(value ?? []), newValue]) - }, [value, pushValue, handlePush, handleChange]) + setTimeout(() => { + setLastItemContainerMode('view') + }, 100) // UX improvement because of the "openDelay" + }, [ + handlePathChange, + handlePush, + path, + pushValue, + setLastItemContainerMode, + value, + ]) return ( + ) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/RemoveButton.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/RemoveButton.tsx new file mode 100644 index 00000000000..1f26e3467f2 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/RemoveButton.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import RemoveButton from '../RemoveButton' +import useTranslation from '../../hooks/useTranslation' + +export default function ViewContainerRemoveButton() { + const { removeButton } = useTranslation().IterateViewContainer + + return +} 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 40524d58fee..2c29b06c896 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainer.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainer.tsx @@ -4,12 +4,11 @@ import { convertJsxToString } from '../../../../shared/component-helper' import { Flex } from '../../../../components' import { Props as FlexContainerProps } from '../../../../components/flex/Container' import { Lead } from '../../../../elements' -import ElementBlock, { - ElementSectionProps, -} from '../AnimatedContainer/ElementBlock' +import ArrayItemArea, { ArrayItemAreaProps } from '../Array/ArrayItemArea' import IterateItemContext from '../IterateItemContext' import Toolbar from '../Toolbar' -import ViewToolbarTools from './ViewToolbarTools' +import EditButton from './EditButton' +import RemoveButton from './RemoveButton' export type Props = { /** @@ -22,23 +21,26 @@ export type Props = { toolbar?: React.ReactNode } -export type AllProps = Props & FlexContainerProps & ElementSectionProps +export type AllProps = Props & FlexContainerProps & ArrayItemAreaProps function ViewContainer(props: AllProps) { const { children, className, title, toolbar, ...restProps } = props || {} - const iterateItemContext = useContext(IterateItemContext) + const { index } = useContext(IterateItemContext) let itemTitle = title let ariaLabel = useMemo(() => convertJsxToString(itemTitle), [itemTitle]) if (ariaLabel.includes('{itemNr}')) { - itemTitle = ariaLabel = ariaLabel.replace( - '{itemNr}', - iterateItemContext.index + 1 - ) + itemTitle = ariaLabel = ariaLabel.replace('{itemNr}', index + 1) } + const hasToolbar = + !toolbar && + React.Children.toArray(children).some((child) => { + return child?.['type'] === Toolbar + }) + return ( - {itemTitle && {itemTitle}} {children} - {toolbar ?? ( - - - - )} + {hasToolbar + ? null + : toolbar ?? ( + + + + + )} - + ) } +ViewContainer.EditButton = EditButton +ViewContainer.RemoveButton = RemoveButton + ViewContainer._supportsSpacingProps = true export default ViewContainer diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewToolbarTools.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewToolbarTools.tsx deleted file mode 100644 index c105e64f00f..00000000000 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewToolbarTools.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, { useCallback, useContext } from 'react' -import { Button, Flex } from '../../../../components' -import RemoveButton from '../RemoveButton' -import useTranslation from '../../hooks/useTranslation' -import IterateItemContext from '../IterateItemContext' -import { edit } from '../../../../icons' - -export default function ViewToolbarTools() { - const iterateItemContext = useContext(IterateItemContext) - const { switchContainerMode } = iterateItemContext ?? {} - - const translation = useTranslation().IterateViewContainer - - const editHandler = useCallback(() => { - switchContainerMode?.('edit') - }, [switchContainerMode]) - - return ( - - - - - - ) -} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/EditButton.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/EditButton.test.tsx new file mode 100644 index 00000000000..e07b51753b0 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/EditButton.test.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { fireEvent, render } from '@testing-library/react' +import IterateItemContext from '../../IterateItemContext' +import Toolbar from '../../Toolbar' +import EditButton from '../EditButton' +import nbNO from '../../../constants/locales/nb-NO' + +const nb = nbNO['nb-NO'].IterateViewContainer + +describe('EditButton', () => { + it('to have buttons with correct text', () => { + render( + + + + + + ) + + const button = document.querySelector('button') + expect(button).toHaveTextContent(nb.editButton) + }) + + it('calls "switchContainerMode" when remove button is clicked', () => { + const switchContainerMode = jest.fn() + + render( + + + + + + ) + + const button = document.querySelector('button') + fireEvent.click(button) + + expect(switchContainerMode).toHaveBeenCalledTimes(1) + expect(switchContainerMode).toHaveBeenCalledWith('edit') + }) +}) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/ViewToolbarTools.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/RemoveButton.test.tsx similarity index 64% rename from packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/ViewToolbarTools.test.tsx rename to packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/RemoveButton.test.tsx index 111c09f859d..89eaa24ee22 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/ViewToolbarTools.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/RemoveButton.test.tsx @@ -2,27 +2,23 @@ import React from 'react' import { fireEvent, render } from '@testing-library/react' import IterateItemContext from '../../IterateItemContext' import Toolbar from '../../Toolbar' -import ViewToolbarTools from '../ViewToolbarTools' - +import RemoveButton from '../RemoveButton' import nbNO from '../../../constants/locales/nb-NO' const nb = nbNO['nb-NO'].IterateViewContainer -describe('ViewToolbarTools', () => { +describe('RemoveButton', () => { it('to have buttons with correct text', () => { render( - + ) - const buttons = document.querySelectorAll('button') - - expect(buttons).toHaveLength(2) - expect(buttons[0]).toHaveTextContent(nb.editButton) - expect(buttons[1]).toHaveTextContent(nb.removeButton) + const button = document.querySelector('button') + expect(button).toHaveTextContent(nb.removeButton) }) it('calls "handleRemove" when remove button is clicked', () => { @@ -31,13 +27,13 @@ describe('ViewToolbarTools', () => { render( - + ) - const buttons = document.querySelectorAll('button') - fireEvent.click(buttons[1]) + const button = document.querySelector('button') + fireEvent.click(button) expect(handleRemove).toHaveBeenCalledTimes(1) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/ViewContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/ViewContainer.test.tsx index bf95b4a7946..dba3f137ee5 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/ViewContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/ViewContainer.test.tsx @@ -10,7 +10,9 @@ const nb = nbNO['nb-NO'].IterateViewContainer describe('ViewContainer', () => { it('renders content and without errors', () => { const { rerender } = render( - + content ) @@ -25,7 +27,9 @@ describe('ViewContainer', () => { expect(inner).toHaveTextContent('content') rerender( - + content ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/index.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/index.ts index 3cdea15bcb3..196e8cc7d2c 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/index.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/index.ts @@ -1,2 +1,4 @@ export { default } from './ViewContainer' export * from './ViewContainer' +export { default as EditButton } from './EditButton' +export { default as RemoveButton } from './RemoveButton' diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/index.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/index.ts new file mode 100644 index 00000000000..02f30c1811c --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/index.ts @@ -0,0 +1,2 @@ +export { default as useItem } from './useItem' +export { default as useSwitchContainerMode } from './useSwitchContainerMode' diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/useSwitchContainerMode.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/useSwitchContainerMode.ts new file mode 100644 index 00000000000..3ba92e333d3 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/useSwitchContainerMode.ts @@ -0,0 +1,114 @@ +import { useCallback, useContext, useEffect, useRef } from 'react' +import { ContainerMode } from '../Array' +import FieldBoundaryContext, { + FieldBoundaryContextState, +} from '../../DataContext/FieldBoundary/FieldBoundaryContext' +import IterateItemContext, { + IterateItemContextState, +} from '../IterateItemContext' +import { Path } from '../../types' + +type GlobalCacheHash = string +type GlobalCacheId = string +type GlobalCache = { + index: IterateItemContextState['index'] + hasError: FieldBoundaryContextState['hasError'] + count: number + switchContainerMode: IterateItemContextState['switchContainerMode'] +} +type GlobalCacheItem = { + [string: GlobalCacheHash]: GlobalCache +} + +const globalContainerModeRef = { current: undefined } +const globalCache: Record< + GlobalCacheHash, + Record +> = {} + +/** + * This is a helper for the Iterate component. + * It is used to switch the container mode of the items inside the Iterate component. + * You can use the hook outside of the Iterate component, and it will communicate with the items inside the Iterate component. + * Therefore, it is imported and used in both e.g. the EditContainer and e.g. the PushButton. + */ +export default function useSwitchContainerMode({ + path, +}: { path?: Path } = {}) { + const nextContainerModeRef = useRef() + const { hasError } = useContext(FieldBoundaryContext) || {} + const iterateItemContext = useContext(IterateItemContext) + + const id = iterateItemContext?.id + const hash = (path || '') + 'useSwitchContainerMode' + + useEffect(() => { + nextContainerModeRef.current = globalContainerModeRef.current + requestAnimationFrame(() => { + if (nextContainerModeRef.current) { + globalContainerModeRef.current = undefined + } + }) + }) + + useEffect(() => { + if (hash && iterateItemContext) { + globalCache[hash] = globalCache[hash] || ({} as GlobalCacheItem) + const { index, arrayValue, switchContainerMode } = iterateItemContext + globalCache[hash][id] = { + index, + hasError, + count: arrayValue?.length, + switchContainerMode, + } + } + + return () => { + if (id) { + globalCache[hash][id] = undefined + } + } + }, [hasError, hash, id, iterateItemContext]) + + const setNextContainerMode = useCallback((mode: ContainerMode) => { + globalContainerModeRef.current = mode + }, []) + + const setContainerMode = useCallback( + (fn: ({ hasError, index, count }) => ContainerMode) => { + const data = globalCache[hash] + for (const id in data) { + const item = data[id] + const mode = item && fn?.(item) + if (mode) { + item.switchContainerMode(mode, { omitFocusManagement: true }) + } + } + }, + [hash] + ) + + const setLastItemContainerMode = useCallback( + (mode: ContainerMode) => { + setContainerMode(({ hasError, index, count }) => { + if (!hasError && index === count - 2) { + return mode + } + }) + }, + [setContainerMode] + ) + + const getNextContainerMode = useCallback((): + | ContainerMode + | undefined => { + return nextContainerModeRef.current + }, []) + + return { + getNextContainerMode, + nextContainerModeRef, + setNextContainerMode, + setLastItemContainerMode, + } +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx index c18f34716ea..fb62fd9046d 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx @@ -1,6 +1,6 @@ -import React from 'react' +import React, { useCallback } from 'react' import { Field, Form, Iterate, Value } from '../..' -import { Card, Flex } from '../../../../components' +import { Card, Flex, Section } from '../../../../components' export default { title: 'Eufemia/Extensions/Forms/Iterate', @@ -102,16 +102,16 @@ const MyViewItem = () => { export const ViewAndEditContainer = () => { return ( - + <> console.log('onSubmit', data)} @@ -131,6 +131,123 @@ export const ViewAndEditContainer = () => { - + + ) +} + +export const InitialOpen = () => { + const MyEditItemForm = useCallback(() => { + return ( + + ) + }, []) + + const MyEditItem = useCallback(() => { + return ( + + + + + {({ items }) => { + if (items.length === 1) { + return null + } + + return ( + <> + + + + ) + }} + + + ) + }, [MyEditItemForm]) + + const MyViewItem = useCallback(() => { + return ( + + + + + {({ items }) => { + if (items.length === 1) { + return + } + + return ( + <> + + + + ) + }} + + + ) + }, []) + + const [count, setCount] = React.useState(0) + + return ( + <> + console.log('onSubmit', data)} + onSubmitRequest={() => console.log('onSubmitRequest')} + > + + Statsborgerskap + + + + + + + + + + + + + + + {count} + + + + + ) +} + +const Output = () => { + const { data } = Form.useData() + + return ( +
+
All data: {JSON.stringify(data)}
+
) } diff --git a/packages/dnb-eufemia/src/extensions/forms/Tools/Log.tsx b/packages/dnb-eufemia/src/extensions/forms/Tools/Log.tsx new file mode 100644 index 00000000000..28861e3e34c --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Tools/Log.tsx @@ -0,0 +1,25 @@ +import { useContext } from 'react' +import DataContext from '../DataContext/Context' +import Section, { SectionProps } from '../../../components/Section' + +function Log(props: SectionProps) { + const { data } = useContext(DataContext) + + return ( +
+
+        {JSON.stringify(data)}
+        {' ' /* Ensure one line of spacing */}
+      
+
+ ) +} + +Log._supportsSpacingProps = true +export default Log diff --git a/packages/dnb-eufemia/src/extensions/forms/Tools/index.ts b/packages/dnb-eufemia/src/extensions/forms/Tools/index.ts index 402da7a21b2..97c7f557999 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Tools/index.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Tools/index.ts @@ -1,2 +1,3 @@ export { default as GenerateSchema } from './GenerateSchema' export { default as ListAllProps } from './ListAllProps' +export { default as Log } from './Log' diff --git a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/ChildrenWithAge.tsx b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/ChildrenWithAge.tsx index 05db24eb0e3..2c57b4a2f3a 100644 --- a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/ChildrenWithAge.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/ChildrenWithAge.tsx @@ -114,7 +114,6 @@ function EditContent({ enableAdditionalQuestions }: Props) { diff --git a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/__snapshots__/ChildrenWithAge.test.tsx.snap b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/__snapshots__/ChildrenWithAge.test.tsx.snap index a8952bb93b4..1dff11bfff6 100644 --- a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/__snapshots__/ChildrenWithAge.test.tsx.snap +++ b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/__snapshots__/ChildrenWithAge.test.tsx.snap @@ -107,7 +107,6 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "width": "small", }, "hasDaycare": { - "defaultValue": false, "itemPath": "/hasDaycare", "label": "Er på SFO/AKS", "required": true, @@ -194,7 +193,6 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "valueType": "boolean", }, "jointResponsibility": { - "defaultValue": false, "itemPath": "/jointResponsibility", "label": "Delt omsorg", "required": true, @@ -385,7 +383,6 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "width": "small", }, "hasDaycare": { - "defaultValue": false, "itemPath": "/hasDaycare", "label": "Er på SFO/AKS", "required": true, @@ -472,7 +469,6 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "valueType": "boolean", }, "jointResponsibility": { - "defaultValue": false, "itemPath": "/jointResponsibility", "label": "Delt omsorg", "required": true, @@ -563,10 +559,6 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "countPathLimit": 20, "path": "/children", "required": true, - "space": { - "bottom": 0, - "top": "medium", - }, "translations": { "en-GB": { "ChildrenWithAge": { 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 15862f5b307..18873752946 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 @@ -48,7 +48,7 @@ export default { removeButton: 'Remove', doneButton: 'Done', cancelButton: 'Cancel', - errorInSection: 'Please correct the errors above', + errorInContainer: 'Please correct the errors above', }, IteratePushContainer: { createButton: 'Add', 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 1941d5e5208..a83ff630201 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 @@ -48,7 +48,7 @@ export default { removeButton: 'Fjern', doneButton: 'Ferdig', cancelButton: 'Avbryt', - errorInSection: 'Feilene ovenfor må rettes', + errorInContainer: 'Feilene ovenfor må rettes', }, IteratePushContainer: { createButton: 'Legg til', diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/usePath.test.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/usePath.test.tsx index a30180f0b86..05d54e66d9b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/usePath.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/usePath.test.tsx @@ -43,6 +43,13 @@ describe('usePath', () => { expect(result.current.path).toBe(`${sectionPath}${path}`) }) + it('joinPath', () => { + const { result } = renderHook(() => usePath(), {}) + expect( + result.current.joinPath([undefined, null, '', 'foo/', '/bar//']) + ).toBe('/foo/bar') + }) + it('should return the correct identifier when itemPath is defined', () => { const path = '/path' const iteratePath = '/iteratePath' diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useExternalValue.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useExternalValue.ts index 788ba213666..159e1e10d8f 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useExternalValue.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useExternalValue.ts @@ -22,10 +22,10 @@ export default function useExternalValue(props: Props) { transformers, emptyValue = undefined, } = props - const dataContext = useContext(DataContext) + const { data } = useContext(DataContext) || {} const iterateItemContext = useContext(IterateElementContext) const inIterate = Boolean(iterateItemContext) - const { value: iterateElementValue } = iterateItemContext ?? {} + const { value: iterateElementValue } = iterateItemContext || {} return useMemo(() => { if (value !== emptyValue) { @@ -44,20 +44,18 @@ export default function useExternalValue(props: Props) { : emptyValue } - if (dataContext.data && path) { + if (data && path) { // There is a surrounding data context and a path for where in the source to find the data if (path === '/') { - return dataContext.data + return data } - return pointer.has(dataContext.data, path) - ? pointer.get(dataContext.data, path) - : emptyValue + return pointer.has(data, path) ? pointer.get(data, path) : emptyValue } return emptyValue }, [ - dataContext.data, + data, emptyValue, inIterate, itemPath, diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts index af070f875e3..7fbfdf17fc5 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts @@ -711,7 +711,8 @@ export default function useFieldProps( } // Ideally, we should rather call "callOnChangeValidator", but sadly it's not possible, - // because the we get an additional delay due to the async nature, which is too much. + // because we get an additional delay due to the async nature, which is too much. + // So when a submit button is pressed, and there is a sync validator, it needs to be validated without a delay. const tmpValue = valueRef.current let result = isAsync(onChangeValidatorRef.current) ? await callValidatorFnAsync(onChangeValidatorRef.current) @@ -770,12 +771,12 @@ export default function useFieldProps( return {} } - // Since the validator can return either a synchronous result or an asynchronous const value = transformers.current.toEvent( overrideValue ?? valueRef.current, 'onBlurValidator' ) + // Since the validator can return either a synchronous result or an asynchronous. let result = isAsync(onBlurValidatorRef.current) ? await callValidatorFnAsync(onBlurValidatorRef.current, value) : callValidatorFnSync(onBlurValidatorRef.current, value) @@ -817,11 +818,27 @@ export default function useFieldProps( setFieldState('validating') } - const { result } = await callOnBlurValidator({ overrideValue }) + const value = transformers.current.toEvent( + overrideValue ?? valueRef.current, + 'onBlurValidator' + ) + + // Since the validator can return either a synchronous result or an asynchronous. + // Ideally, we should rather call "callOnBlurValidator", but sadly it's not possible, + // because we get an additional delay due to the async nature, which is too much. + // So when a submit button is pressed, and there is a sync validator, it needs to be validated without a delay. + let result = isAsync(onBlurValidatorRef.current) + ? await callValidatorFnAsync(onBlurValidatorRef.current, value) + : callValidatorFnSync(onBlurValidatorRef.current, value) + if (result instanceof Promise) { + result = await result + } + revealOnBlurValidatorResult({ result }) }, [ - callOnBlurValidator, + callValidatorFnAsync, + callValidatorFnSync, defineAsyncProcess, revealOnBlurValidatorResult, setFieldState, @@ -1390,19 +1407,26 @@ export default function useFieldProps( validateValue() }, [schema, validateValue]) + const isEmptyData = useCallback(() => { + return ( + dataContext.internalDataRef?.current === + (dataContext.props?.emptyData ?? clearedData) + ) + }, [dataContext.internalDataRef, dataContext.props?.emptyData]) + useEffect(() => { - if (dataContext.data === clearedData) { + if (isEmptyData()) { hideError() } - }, [dataContext.data, hideError]) + }, [externalValue, hideError, isEmptyData]) useUpdateEffect(() => { // Error or removed error for this field from the surrounding data context (by path) if (valueRef.current !== externalValue) { valueRef.current = externalValue validateValue() + forceUpdate() } - forceUpdate() }, [externalValue, validateValue]) useEffect(() => { diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/usePath.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/usePath.ts index 7f6496436d7..24f1406272b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/usePath.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/usePath.ts @@ -24,6 +24,13 @@ export default function usePath(props: Props = {}) { throw new Error(`itemPath="${itemPathProp}" must start with a slash`) } + const joinPath = useCallback((paths: Array) => { + return paths + .reduce((acc, cur) => (cur ? `${acc}/${cur}` : acc), '/') + .replace(/\/{2,}/g, '/') + .replace(/\/+$/, '') + }, []) + const makeSectionPath = useCallback( (path: Path) => { return `${ @@ -44,7 +51,7 @@ export default function usePath(props: Props = {}) { root = makeSectionPath('') } - return `${root}${iteratePath}/${iterateElementIndex}${ + return `${root}${iteratePath || ''}/${iterateElementIndex}${ itemPath && itemPath !== '/' ? itemPath : '' }` }, @@ -88,6 +95,7 @@ export default function usePath(props: Props = {}) { identifier, path, itemPath, + joinPath, makePath, makeIteratePath, makeSectionPath, From 21d67e983a7d1d66f66824b526e9c5d0402eb261 Mon Sep 17 00:00:00 2001 From: Anders Date: Thu, 12 Sep 2024 11:15:46 +0200 Subject: [PATCH 06/14] chore: commited -> committed (#3922) --- .../docs/uilib/extensions/forms/Form/Isolation/Examples.tsx | 2 +- .../src/docs/uilib/extensions/forms/Form/Isolation/info.mdx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 7391d45eb31..a8caa60589d 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 @@ -22,7 +22,7 @@ export const UsingCommitButton = () => { Date: Thu, 12 Sep 2024 12:45:24 +0200 Subject: [PATCH 07/14] chore: heading size -> heading sizes (#3923) --- .../components/form-label/properties.mdx | 24 +++++++++---------- .../forms/FieldBlock/FieldBlockDocs.ts | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/form-label/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/form-label/properties.mdx index 4a79af5f30b..81981e1e774 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/form-label/properties.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/form-label/properties.mdx @@ -4,15 +4,15 @@ showTabs: true ## Properties -| Properties | Description | -| --------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `forId` | _(required)_ the same unique `id` like the linked HTML element has. | -| `text` | _(optional)_ the `text` of the label. You can use `children` as well. | -| `srOnly` | _(optional)_ when `true`, the label will be invisible and only accessible for screen readers. | -| `vertical` | _(optional)_ if set to `true`, will do the same as `label_direction` when set to **vertical**. | -| `size` | _(optional)_ define one of the following [heading size](/uilib/elements/heading/): `medium` or `large`. | -| `skeleton` | _(optional)_ if set to `true`, an overlaying skeleton with animation will be shown. | -| `disabled` | _(optional)_ if set to `true`, the label will behave as not interactive. | -| `element` | _(optional)_ defines the HTML element used. Defaults to `label`. | -| `innerRef` | _(optional)_ attach a React Ref to the inner label `element`. | -| [Space](/uilib/layout/space/properties) | _(optional)_ spacing properties like `top` or `bottom` are supported. | +| Properties | Description | +| --------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `forId` | _(required)_ the same unique `id` like the linked HTML element has. | +| `text` | _(optional)_ the `text` of the label. You can use `children` as well. | +| `srOnly` | _(optional)_ when `true`, the label will be invisible and only accessible for screen readers. | +| `vertical` | _(optional)_ if set to `true`, will do the same as `label_direction` when set to **vertical**. | +| `size` | _(optional)_ define one of the following [heading sizes](/uilib/elements/heading/): `medium` or `large`. | +| `skeleton` | _(optional)_ if set to `true`, an overlaying skeleton with animation will be shown. | +| `disabled` | _(optional)_ if set to `true`, the label will behave as not interactive. | +| `element` | _(optional)_ defines the HTML element used. Defaults to `label`. | +| `innerRef` | _(optional)_ attach a React Ref to the inner label `element`. | +| [Space](/uilib/layout/space/properties) | _(optional)_ spacing properties like `top` or `bottom` are supported. | diff --git a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlockDocs.ts b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlockDocs.ts index 407be2df08d..85e051a00a2 100644 --- a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlockDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlockDocs.ts @@ -41,7 +41,7 @@ export const fieldBlockSharedProperties: PropertiesTableProps = { export const fieldBlockProperties: PropertiesTableProps = { ...fieldBlockSharedProperties, labelSize: { - doc: 'Define one of the following [heading size](/uilib/elements/heading/): `medium` or `large`.', + doc: 'Define one of the following [heading sizes](/uilib/elements/heading/): `medium` or `large`.', type: ['string', 'false'], status: 'optional', }, From 9b49006d3d60bde2904facde28d070867f3c8e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Thu, 12 Sep 2024 13:50:14 +0200 Subject: [PATCH 08/14] feat(Forms): auto-open Form.Section container when fields have errors and add `validateInitially` prop (#3878) This feature allows a dev to put on a Form.Section. When a field inside has an error/ or is validating negatively, the section will automatically switch to edit mode and show these errors, without pressing submit on the form. [Demo](https://eufemia-git-feat-section-error-mode-eufemia.vercel.app/uilib/extensions/forms/Form/Section/demos/#show-errors-on-the-whole-section). Quick example: ```tsx ``` --------- Co-authored-by: Anders --- .../forms/Form/Section/Examples.tsx | 51 ++ .../extensions/forms/Form/Section/demos.mdx | 6 + .../FieldBoundary/FieldBoundaryContext.ts | 7 +- .../FieldBoundary/FieldBoundaryProvider.tsx | 23 +- .../__tests__/FieldBoundaryProvider.test.tsx | 21 + .../Section/EditContainer/EditContainer.tsx | 31 +- .../EditContainer/EditToolbarTools.tsx | 29 +- .../__tests__/EditAndViewContainer.test.tsx | 498 ++++++++++++++---- .../extensions/forms/Form/Section/Section.tsx | 18 +- .../forms/Form/Section/SectionDocs.ts | 7 +- ...ave-to-match-basic-edit-container.snap.png | Bin 13609 -> 17962 bytes ...ave-to-match-basic-view-container.snap.png | Bin 10246 -> 13052 bytes ...tion-have-to-match-edit-container.snap.png | Bin 15358 -> 19771 bytes ...tion-have-to-match-view-container.snap.png | Bin 11567 -> 14718 bytes .../Section/containers/SectionContainer.tsx | 53 +- .../containers/SectionContainerContext.ts | 2 + .../containers/SectionContainerProvider.tsx | 9 +- .../Form/Section/stories/Section.stories.tsx | 45 +- .../Form/Section/style/dnb-form-section.scss | 4 + .../hooks/__tests__/useFieldProps.test.tsx | 23 + .../extensions/forms/hooks/useFieldProps.ts | 17 +- 21 files changed, 700 insertions(+), 144 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/Examples.tsx index 9186495f398..4a16f32d4d6 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/Examples.tsx @@ -82,6 +82,7 @@ export const ViewAndEditContainer = () => { defaultData={{ nestedPath: { firstName: 'Nora', + lastName: 'Mørk', }, }} > @@ -100,6 +101,55 @@ export const ViewAndEditContainer = () => { ) } +export const ViewAndEditContainerValidation = () => { + return ( + + {() => { + const MyEditContainer = () => { + return ( + + + + + ) + } + + const MyViewContainer = () => { + return ( + + + + + + + ) + } + + return ( + console.log('onSubmit', data)} + defaultData={{ + nestedPath: { + firstName: 'Nora', + lastName: undefined, // initiate error + }, + }} + > + + Your account + + + + + + + + ) + }} + + ) +} + export const BasicViewAndEditContainer = () => { return ( { defaultData={{ nestedPath: { firstName: 'Nora', + lastName: 'Mørk', }, }} > diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/demos.mdx index cb44d6c26e4..cb0a2017f54 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/demos.mdx @@ -23,6 +23,12 @@ This example uses the [EditContainer](/uilib/extensions/forms/Form/Section/EditC +### Show errors on the whole section + +When a field in the section has an error and the section has `containerMode` set to `auto` (default), the whole section will switch to edit mode. The errors will be shown when `validateInitially` is set to `true`. + + + Using `variant="basic"` will render the view and edit container without the additional Card `outline`. diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryContext.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryContext.ts index 05964b7fd7a..86d6410ed46 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryContext.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryContext.ts @@ -24,13 +24,16 @@ export interface FieldBoundaryContextState { /** * Will be set to true when the boundary context error state should be shown. + * Support a number to ensure we can renew hooks each time we get a new value. */ - showBoundaryErrors?: boolean + showBoundaryErrors?: boolean | number /** * To set the boundary context error state. */ - setShowBoundaryErrors?: (showBoundaryErrors: boolean) => void + setShowBoundaryErrors?: ( + showBoundaryErrors: FieldBoundaryContextState['showBoundaryErrors'] + ) => void /** * To set the local error state. diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryProvider.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryProvider.tsx index 05133db43f8..023f9ff7fc8 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryProvider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryProvider.tsx @@ -5,12 +5,22 @@ import FieldBoundaryContext, { import DataContext from '../Context' import { Path } from '../../types' -export default function FieldBoundaryProvider({ children }) { +export type Props = { + showErrors?: boolean + onPathError?: (path: Path, error: Error) => void + children: React.ReactNode +} + +export default function FieldBoundaryProvider(props: Props) { + const { showErrors = false, onPathError = null, children } = props const [, forceUpdate] = useReducer(() => ({}), {}) const { showAllErrors, hasVisibleError } = useContext(DataContext) + const onPathErrorRef = useRef(onPathError) + onPathErrorRef.current = onPathError const errorsRef = useRef>({}) - const showBoundaryErrorsRef = useRef(false) + const showBoundaryErrorsRef = + useRef(showErrors) const hasError = Object.keys(errorsRef.current).length > 0 const hasSubmitError = showAllErrors && hasError @@ -21,15 +31,14 @@ export default function FieldBoundaryProvider({ children }) { delete errorsRef.current?.[path] } forceUpdate() + onPathErrorRef.current?.(path, error) }, []) - const setShowBoundaryErrors = useCallback( - (showBoundaryErrors: boolean) => { + const setShowBoundaryErrors: FieldBoundaryContextState['setShowBoundaryErrors'] = + useCallback((showBoundaryErrors) => { showBoundaryErrorsRef.current = showBoundaryErrors forceUpdate() - }, - [] - ) + }, []) const context: FieldBoundaryContextState = { hasError, diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/__tests__/FieldBoundaryProvider.test.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/__tests__/FieldBoundaryProvider.test.tsx index 7055e26218b..b36c4b58feb 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/__tests__/FieldBoundaryProvider.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/__tests__/FieldBoundaryProvider.test.tsx @@ -124,6 +124,27 @@ describe('FieldBoundaryProvider', () => { }) }) + it('should set showBoundaryErrorsRef to true when showErrors is true', async () => { + const contextRef: React.MutableRefObject = + React.createRef() + + const ContextConsumer = () => { + contextRef.current = useContext(FieldBoundaryContext) + return null + } + + render( + + + + + + + ) + + expect(contextRef.current.showBoundaryErrors).toBe(true) + }) + it('should set error in boundary context', async () => { const contextRef: React.MutableRefObject = React.createRef() diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/EditContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/EditContainer.tsx index 22f3178c638..33b3ae04a3b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/EditContainer.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/EditContainer.tsx @@ -1,15 +1,17 @@ -import React, { useMemo } from 'react' +import React, { useCallback, useContext, useMemo, useRef } from 'react' import classnames from 'classnames' import { convertJsxToString } from '../../../../../shared/component-helper' import { Flex } from '../../../../../components' import { Props as FlexContainerProps } from '../../../../../components/flex/Container' import { Lead } from '../../../../../elements' import FieldBoundaryProvider from '../../../DataContext/FieldBoundary/FieldBoundaryProvider' +import SectionContainerContext from '../containers/SectionContainerContext' import EditToolbarTools from './EditToolbarTools' import SectionContainer, { SectionContainerProps, } from '../containers/SectionContainer' import Toolbar from '../containers/Toolbar' +import { Path } from '../../../types' export type Props = { title?: React.ReactNode @@ -20,12 +22,37 @@ export type AllProps = Props & SectionContainerProps & FlexContainerProps function EditContainer(props: AllProps) { const { children, className, title, ...restProps } = props || {} const ariaLabel = useMemo(() => convertJsxToString(title), [title]) + const { + containerMode, + initialContainerMode, + validateInitially, + switchContainerMode, + } = useContext(SectionContainerContext) || {} + const omitFocusManagementRef = useRef(false) + + const onPathError = useCallback( + (path: Path, error: Error) => { + if ( + initialContainerMode === 'auto' && + containerMode !== 'edit' && + error instanceof Error + ) { + omitFocusManagementRef.current = true + switchContainerMode?.('edit') + } + }, + [containerMode, initialContainerMode, switchContainerMode] + ) return ( - + diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/EditToolbarTools.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/EditToolbarTools.tsx index a9fe34b32c6..f9bde515659 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/EditToolbarTools.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Section/EditContainer/EditToolbarTools.tsx @@ -11,7 +11,8 @@ export default function EditToolbarTools() { useEditContainerToolbar() const { restoreOriginalData } = useContainerDataStore() - const { switchContainerMode } = useContext(SectionContainerContext) || {} + const { switchContainerMode, initialContainerMode } = + useContext(SectionContainerContext) || {} const { hasVisibleError, hasSubmitError, @@ -24,9 +25,11 @@ export default function EditToolbarTools() { const [showError, setShowError] = useState(false) const cancelHandler = useCallback(() => { - if (hasSubmitError) { - setShowError(true) - setShowBoundaryErrors?.(true) + if (hasSubmitError || (initialContainerMode === 'auto' && hasError)) { + setShowBoundaryErrors?.(Date.now()) + if (hasVisibleError) { + setShowError(true) + } } else { setShowError(false) setShowBoundaryErrors?.(false) @@ -35,13 +38,16 @@ export default function EditToolbarTools() { } }, [ hasSubmitError, - restoreOriginalData, + initialContainerMode, + hasError, setShowBoundaryErrors, + hasVisibleError, + restoreOriginalData, switchContainerMode, ]) const doneHandler = useCallback(() => { if (hasError) { - setShowBoundaryErrors?.(true) + setShowBoundaryErrors?.(Date.now()) if (hasVisibleError) { setShowError(true) } @@ -59,9 +65,6 @@ export default function EditToolbarTools() { return ( <> - - {translation.errorInSection} -