diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/info.mdx index 946aa6c04de..0cd074a197b 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/info.mdx @@ -5,13 +5,19 @@ hideInMenu: true ## Description -`Iterate.PushContainer` enables users to create a new item in the array. It can be used instead of the [PushButton](/uilib/extensions/forms/Iterate/PushButton/). +`Iterate.PushContainer` enables users to create a new item in the array. It can be used instead of the [PushButton](/uilib/extensions/forms/Iterate/PushButton/), but with fields in the container. It allows the user to fill in the fields without storing them in the data context. -Fields inside the container must have an `itemPath` defined. +Good to know: -You can place it below the [Array](/uilib/extensions/forms/Iterate/Array/) component like this: +- Fields inside the container must have an `itemPath` defined, instead of a `path`. +- You can provide `data` or `defaultData` to prefill the fields. +- The `path` you define needs to point to an array an existing [Iterate.Array](/uilib/extensions/forms/Iterate/Array/) path. + +## Usage + +You may place it below the [Iterate.Array](/uilib/extensions/forms/Iterate/Array/) component like this: ```tsx import { Iterate, Field } from '@dnb/eufemia/extensions/forms' @@ -27,8 +33,6 @@ render( ) ``` -Technically it uses the [EditContainer](/uilib/extensions/forms/Iterate/EditContainer/) and the [Form.Isolation](/uilib/extensions/forms/Form/Isolation/) under the hood. - ## Show a button to create a new item By default, it keeps the form open after a new item has been created. You can change this behavior by using the `openButton` and `showOpenButtonWhen` properties. @@ -84,3 +88,9 @@ render( , ) ``` + +## Technical details + +Under the hood, it uses the [Form.Isolation](/uilib/extensions/forms/Form/Isolation/) component to isolate the data from the rest of the form. It also uses the the [EditContainer](/uilib/extensions/forms/Iterate/EditContainer/) inside the [Iterate.Array](/uilib/extensions/forms/Iterate/Array/) component to render the fields. + +All fields inside the container will be stored in the data context at this path: `/pushContainerItems/0`. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/properties.mdx index f1664abf510..516cfa1f8b9 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/properties.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/properties.mdx @@ -5,12 +5,19 @@ hideInMenu: true import TranslationsTable from 'dnb-design-system-portal/src/shared/parts/TranslationsTable' import PropertiesTable from 'dnb-design-system-portal/src/shared/parts/PropertiesTable' -import { PushContainerProperties } from '@dnb/eufemia/src/extensions/forms/Iterate/PushContainer/PushContainerDocs' +import { + PushContainerProperties, + PushContainerEvents, +} from '@dnb/eufemia/src/extensions/forms/Iterate/PushContainer/PushContainerDocs' ## Properties +## Events + + + ## Translations diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts index ef8edd97246..7b38bd80961 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts @@ -154,6 +154,7 @@ export interface ContextState { params?: { remove?: boolean } ) => void setFieldConnection?: (path: Path, connections: FieldConnections) => void + isEmptyDataRef?: React.MutableRefObject fieldPropsRef?: React.MutableRefObject> valuePropsRef?: React.MutableRefObject> fieldConnectionsRef?: React.RefObject> 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 6ad23d97955..325927e8339 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -303,6 +303,7 @@ export default function Provider( // eslint-disable-next-line react-hooks/exhaustive-deps -- Avoid triggering code that should only run initially }, []) const internalDataRef = useRef(initialData) + const isEmptyDataRef = useRef(false) // - Validator const ajvValidatorRef = useRef() @@ -658,8 +659,10 @@ export default function Provider( ) { cacheRef.current.shared = sharedData.data - if (internalDataRef.current === clearedData) { - return clearedData as Data + if (isEmptyDataRef.current) { + return ( + Array.isArray(internalDataRef.current) ? [] : clearedData + ) as Data } return { @@ -683,7 +686,11 @@ export default function Provider( : internalData const clearData = useCallback(() => { - internalDataRef.current = (emptyData ?? clearedData) as Data + isEmptyDataRef.current = true + internalDataRef.current = ((typeof emptyData === 'function' + ? emptyData(internalDataRef.current) + : emptyData) ?? + (Array.isArray(internalDataRef.current) ? [] : clearedData)) as Data if (id) { setSharedData?.(internalDataRef.current) @@ -691,6 +698,10 @@ export default function Provider( forceUpdate() onClear?.() + + requestAnimationFrame?.(() => { + isEmptyDataRef.current = false + }) // Delay so the field validation error message are not shown }, [emptyData, id, onClear, setSharedData]) useEffect(() => { @@ -1329,6 +1340,7 @@ export default function Provider( id, data: internalDataRef.current, internalDataRef, + isEmptyDataRef, props, ...rest, }} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainer.tsx index 58c75f77421..a9050f987c8 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainer.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainer.tsx @@ -8,7 +8,7 @@ import EditContainer, { CancelButton, DoneButton } from '../EditContainer' import IterateArray, { ContainerMode } from '../Array' import OpenButton from './OpenButton' import { Flex, HeightAnimation } from '../../../../components' -import { Path } from '../../types' +import { OnCommit, Path } from '../../types' import { SpacingProps } from '../../../../shared/types' import { useArrayLimit, useSwitchContainerMode } from '../hooks' import Toolbar from '../Toolbar' @@ -39,20 +39,30 @@ export type Props = { showOpenButtonWhen?: (list: unknown[]) => boolean /** - * Prefilled data to add to the fields. + * Prefilled data to add to the fields. The data will be put into this path: "/pushContainerItems/0". */ data?: unknown | Record /** - * Prefilled data to add to the fields. + * Prefilled data to add to the fields. The data will be put into this path: "/pushContainerItems/0". */ defaultData?: unknown | Record + /** + * Provide additional data that will be put into the root of the isolated data context (parallel to "/pushContainerItems/0"). + */ + isolatedData?: Record + /** * A custom toolbar to be shown below the container. */ toolbar?: React.ReactNode + /** + * Will be called when the user clicks on the "Done" button. + */ + onCommit?: OnCommit + /** * The container contents. */ @@ -65,11 +75,13 @@ function PushContainer(props: AllProps) { const { data: dataProp, defaultData: defaultDataProp, + isolatedData, path, title, children, openButton, showOpenButtonWhen, + onCommit, ...rest } = props @@ -101,23 +113,44 @@ function PushContainer(props: AllProps) { if (defaultDataProp) { return // don't return a fallback, because we want to use the defaultData } - return { newItems: [dataProp ?? clearedData] } - }, [dataProp, defaultDataProp]) + return { + ...isolatedData, + pushContainerItems: [dataProp ?? clearedData], + } + }, [dataProp, defaultDataProp, isolatedData]) const defaultData = useMemo(() => { - return { newItems: [defaultDataProp ?? clearedData] } - }, [defaultDataProp]) + return { + ...(!dataProp ? isolatedData : null), + pushContainerItems: [defaultDataProp ?? clearedData], + } + }, [dataProp, defaultDataProp, isolatedData]) + + const emptyData = useCallback( + (data: { pushContainerItems: unknown[] }) => { + const firstItem = data.pushContainerItems?.[0] + if (firstItem === null || typeof firstItem !== 'object') { + return { + ...isolatedData, + pushContainerItems: [null], + } + } + return defaultData + }, + [defaultData, isolatedData] + ) return ( { - return moveValueToPath(path, [...entries, ...newItems]) + transformOnCommit={({ pushContainerItems }) => { + return moveValueToPath(path, [...entries, ...pushContainerItems]) }} - onCommit={(data, { clearData, preventCommit }) => { + onCommit={(data, options) => { + const { clearData, preventCommit } = options if (hasReachedLimit) { preventCommit() setShowStatus(true) @@ -126,11 +159,12 @@ function PushContainer(props: AllProps) { switchContainerModeRef.current?.('view') clearData() } + onCommit?.(data, options) }} > { @@ -548,7 +549,7 @@ describe('PushContainer', () => { ) }) - it('should keep the defaultValue after clearing', async () => { + it('should not show error message after clearing', async () => { const onChange = jest.fn() render( @@ -643,6 +644,68 @@ describe('PushContainer', () => { expect(document.querySelector('.dnb-form-status')).toBeNull() }) }) + + it('should keep the defaultValue after clearing', async () => { + const onChange = jest.fn() + const onCommit = jest.fn() + + let internalContext = null + const CollectInternalData = () => { + internalContext = useContext(DataContext) + return null + } + + render( + + + + + + + ) + + expect(internalContext).toMatchObject({ + data: { + pushContainerItems: ['default value'], + }, + }) + + const input = document.querySelector('input') + + await userEvent.type(input, ' changed') + + const button = document.querySelector('button') + + await userEvent.click(button) + expect(internalContext.internalDataRef.current).toEqual({ + pushContainerItems: ['default value'], + }) + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenLastCalledWith( + ['default value changed'], + expect.anything() + ) + expect(onCommit).toHaveBeenCalledTimes(1) + expect(onCommit).toHaveBeenLastCalledWith( + ['default value changed'], + expect.anything() + ) + + await userEvent.click(button) + expect(internalContext.internalDataRef.current).toEqual({ + pushContainerItems: ['default value'], + }) + expect(onChange).toHaveBeenCalledTimes(2) + expect(onChange).toHaveBeenLastCalledWith( + ['default value changed', 'default value'], + expect.anything() + ) + expect(onCommit).toHaveBeenCalledTimes(2) + expect(onCommit).toHaveBeenLastCalledWith( + ['default value changed', 'default value'], + expect.anything() + ) + }) }) it('should support initial data as a string', async () => { diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/PushContainer.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/PushContainer.stories.tsx new file mode 100644 index 00000000000..73636f1c885 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/PushContainer.stories.tsx @@ -0,0 +1,168 @@ +import React, { useLayoutEffect } from 'react' +import { Field, Form, Iterate, Value, Wizard } from '../..' +import { Card, Flex } from '../../../../components' + +export default { + title: 'Eufemia/Extensions/Forms/Iterate/PushContainer', +} + +const formData = { + persons: [ + { + firstName: 'Test', + lastName: 'Bruker', + }, + { + firstName: 'Some', + lastName: 'Person', + }, + { + firstName: 'Geir', + lastName: 'Service', + }, + ], +} + +export const ComplexPushContainer = () => { + return ( + + + + + ) +} + +function RepresentativesSection() { + return ( + + Representatives + + + + + + + + + ) +} + +function RepresentativesView() { + return ( + + + + + + + ) +} + +function RepresentativesEdit() { + return ( + + + + + ) +} + +function ExistingPersonDetails() { + const { data, getValue } = Form.useData() + const person = getValue(data.selectedPerson)?.data || {} + + return ( + + + + + ) +} + +function NewPersonDetails() { + return ( + + + + + ) +} + +function PushContainerContent() { + const { data, update } = Form.useData() + + // Clear the PushContainer data when the selected person is "other", + // so the fields do not inherit existing data. + useLayoutEffect(() => { + if (data.selectedPerson === 'other') { + update('/pushContainerItems/0', {}) + } + }, [data.selectedPerson, update]) + + return ( + + + + + + typeof value === 'string' && value !== 'other', + }} + > + + + + value === 'other', + }} + > + + + + ) +} + +function RepresentativesCreateNew() { + return ( + { + return { + title: [data.firstName, data.lastName].join(' '), + value: '/persons/' + i, + data, + } + }), + }} + openButton={ + + } + showOpenButtonWhen={(list) => list.length > 0} + > + + + ) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts index a382858069b..973ebea13cd 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts @@ -1785,8 +1785,9 @@ export default function useFieldProps( const isEmptyData = useCallback( () => { return ( + dataContext.isEmptyDataRef?.current || dataContext.internalDataRef?.current === - (dataContext.props?.emptyData ?? clearedData) + (dataContext.props?.emptyData ?? clearedData) ) }, @@ -1822,11 +1823,8 @@ export default function useFieldProps( useEffect(() => { if (isEmptyData()) { - // Fill the data context with the default value after it has been cleared - requestAnimationFrame(() => { - setContextData() - validateValue() - }) + setContextData() + validateValue() } }, [isEmptyData, setContextData, validateValue]) diff --git a/packages/dnb-eufemia/src/extensions/forms/types.ts b/packages/dnb-eufemia/src/extensions/forms/types.ts index 87e9ef9c3c3..6ec8f27a635 100644 --- a/packages/dnb-eufemia/src/extensions/forms/types.ts +++ b/packages/dnb-eufemia/src/extensions/forms/types.ts @@ -600,7 +600,10 @@ export type OnCommit = ( { clearData, preventCommit, - }: { clearData: () => void; preventCommit?: () => void } + }: { + clearData: () => void + preventCommit?: () => void + } ) => | EventReturnWithStateObject | void