diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/Examples.tsx index 11814ed6c06..5ccf8c67833 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/Examples.tsx @@ -1,5 +1,12 @@ +import { Button, Card } from '@dnb/eufemia/src' import ComponentBox from '../../../../../../shared/tags/ComponentBox' -import { Field, Form, Wizard } from '@dnb/eufemia/src/extensions/forms' +import { + Field, + Form, + Tools, + Wizard, +} from '@dnb/eufemia/src/extensions/forms' +import React from 'react' export const InWizard = () => { return ( @@ -10,87 +17,100 @@ export const InWizard = () => { Form.useSnapshot('my-form') return ( - + { if (mode === 'previous') { - revertSnapshot(args.id) + revertSnapshot(args.id, 'my-snapshot-slice') } else { - createSnapshot(args.previousStep.id) + createSnapshot( + args.previousStep.id, + 'my-snapshot-slice', + ) } }} > - - Step A - + + + + + - - Step B - + + + + + + ) + } - - ['group-1', 'group-2'].includes(value), - }} - > - Step C - - - + return + }} + + ) +} - - Step D - - - - +export const UndoRedo = () => { + return ( + + {() => { + const MyComponent = () => { + const { createSnapshot, applySnapshot } = Form.useSnapshot() + const pointerRef = React.useRef(0) - - - - - + React.useEffect(() => { + createSnapshot(pointerRef.current, 'my-snapshot-slice') + }, [createSnapshot]) + + const changeHandler = React.useCallback(() => { + pointerRef.current += 1 + createSnapshot(pointerRef.current, 'my-snapshot-slice') + }, [createSnapshot]) + const undoHandler = React.useCallback(() => { + pointerRef.current -= 1 + applySnapshot(pointerRef.current, 'my-snapshot-slice') + }, [applySnapshot]) + const redoHandler = React.useCallback(() => { + pointerRef.current += 1 + applySnapshot(pointerRef.current, 'my-snapshot-slice') + }, [applySnapshot]) + + return ( + <> + + + + + + + + + + + + + + ) } - return + return ( + + + + ) }} ) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/demos.mdx index fc99aa80054..365dcc720c1 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/demos.mdx @@ -6,8 +6,12 @@ import * as Examples from './Examples' ## Demos +### Undo / Redo + + + ### Used in a Wizard -This example revers the form data to its previous state when the user navigates back to a previous step. +This example reverts the form data to its previous state when the user navigates back to a previous step. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx index 2122a43198d..49a77f1a529 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx @@ -12,7 +12,8 @@ The hook returns an object with the following properties: import { Form } from '@dnb/eufemia/extensions/forms' function MyComponent() { - const { createSnapshot, revertSnapshot } = Form.useSnapshot() + const { createSnapshot, applySnapshot, revertSnapshot } = + Form.useSnapshot() return <>MyComponent } @@ -25,15 +26,46 @@ render( ``` - `createSnapshot` will store the current data as a new snapshot with the given id. +- `applySnapshot` will revert the data to the snapshot with the given id (required). - `revertSnapshot` will revert the data to the snapshot with the given id (required). A reverted snapshot gets deleted from the memory. -## Usage +## Partial data snapshot + +In order to create and revert a snapshot for a specific part of the form data, you can use the `Form.Snapshot` component. + +```tsx +import { Form, Field } from '@dnb/eufemia/extensions/forms' + +function MyForm() { + return ( + + + + + + + + + ) +} +``` + +When calling the `createSnapshot` or `revertSnapshot` functions, you can pass inn your snapshot name as the second parameter. This will make sure that the snapshot is only applied to the given part of the form data. + +```tsx +createSnapshot('my-snapshot-id', 'my-snapshot-name') +revertSnapshot('my-snapshot-id', 'my-snapshot-name') +``` + +You can check out examples in the demo section. + +## Usage of the `Form.useSnapshot` hook You can use the `Form.useSnapshot` hook with or without an `id` (string) property, which is optional and can be used to link the data to a specific [Form.Handler](/uilib/extensions/forms/Form/Handler/) component. ### Without an `id` property -Here "Component" is rendered inside the `Form.Handler` component and does not need an `id` property to access the form data: +Here "Component" is rendered inside the `Form.Handler` component and does not need an `id` property to access the snapshot: ```jsx import { Form } from '@dnb/eufemia/extensions/forms' diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts index 72fb14144c7..34cb6ecfd5a 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts @@ -15,6 +15,7 @@ import { OnSubmitParams, } from '../types' import { Props as ProviderProps } from './Provider' +import { SnapshotName } from '../Form/Snapshot' export type MountState = { isPreMounted?: boolean @@ -155,11 +156,15 @@ export interface ContextState { params?: { remove?: boolean } ) => void setFieldConnection?: (path: Path, connections: FieldConnections) => void + forceUpdate?: () => void isEmptyDataRef?: React.MutableRefObject fieldPropsRef?: React.MutableRefObject> valuePropsRef?: React.MutableRefObject> fieldConnectionsRef?: React.RefObject> mountedFieldsRef?: React.MutableRefObject> + snapshotsRef?: React.MutableRefObject< + Map> + > formElementRef?: React.MutableRefObject showAllErrors: boolean hasVisibleError: boolean 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 325927e8339..f4fdd0e9ce0 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -57,7 +57,7 @@ import structuredClone from '@ungap/structured-clone' const useLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect -export type SharedAttachments = { +export type SharedAttachments = { visibleDataHandler?: VisibleDataHandler filterDataHandler?: FilterDataHandler hasErrors?: ContextState['hasErrors'] @@ -65,8 +65,8 @@ export type SharedAttachments = { setShowAllErrors?: ContextState['setShowAllErrors'] setSubmitState?: ContextState['setSubmitState'] rerenderUseDataHook?: () => void - fieldConnectionsRef?: ContextState['fieldConnectionsRef'] clearData?: () => void + fieldConnectionsRef?: ContextState['fieldConnectionsRef'] } export interface Props @@ -249,6 +249,9 @@ export default function Provider( // - Paths const mountedFieldsRef: ContextState['mountedFieldsRef'] = useRef({}) + // - Snapshots + const snapshotsRef: ContextState['snapshotsRef'] = useRef(new Map()) + // - Errors from provider validation (the whole data set) const hasVisibleErrorRef = useRef>({}) const errorsRef = useRef | undefined>() @@ -598,10 +601,13 @@ export default function Provider( }, []) // - Shared state - const sharedData = useSharedState void }>(id) + const sharedData = useSharedState(id) const sharedAttachments = useSharedState>( id + '-attachments' ) + const sharedDataContext = useSharedState( + id + '-data-context' + ) const setSharedData = sharedData.set const extendSharedData = sharedData.extend @@ -634,7 +640,7 @@ export default function Provider( ) { return { ...internalDataRef.current, - ...sharedData.data, + ...(sharedData.data || {}), } } @@ -667,7 +673,7 @@ export default function Provider( return { ...internalDataRef.current, - ...sharedData.data, + ...(sharedData.data || {}), } } @@ -704,12 +710,6 @@ export default function Provider( }) // Delay so the field validation error message are not shown }, [emptyData, id, onClear, setSharedData]) - useEffect(() => { - if (sharedData.data?.clearForm) { - onClear?.() - } - }, [onClear, sharedData.data?.clearForm]) - useLayoutEffect(() => { // Set the shared state, if initialData was given if (id && initialData && !sharedData.data) { @@ -732,8 +732,8 @@ export default function Provider( hasFieldError, setShowAllErrors, setSubmitState, - fieldConnectionsRef, clearData, + fieldConnectionsRef, }) if (filterSubmitData) { rerenderUseDataHook?.() @@ -1283,68 +1283,73 @@ export default function Provider( ? true : undefined + const contextValue: ContextState = { + /** Method */ + handlePathChange, + handlePathChangeUnvalidated, + handleSubmit, + setMountedFieldState, + handleSubmitCall, + setFormState, + setSubmitState, + setShowAllErrors, + setVisibleError, + setFieldEventListener, + setFieldState, + setFieldError, + setFieldConnection, + setFieldProps, + setValueProps, + hasErrors, + hasFieldError, + hasFieldState, + validateData, + updateDataValue, + setData, + clearData, + visibleDataHandler, + filterDataHandler, + getSubmitData, + getSubmitOptions, + addOnChangeHandler, + setHandleSubmit, + scrollToTop, + forceUpdate, + + /** State handling */ + schema, + disabled, + required, + formState, + submitState, + contextErrorMessages, + hasContext: true, + errors: errorsRef.current, + showAllErrors: showAllErrorsRef.current, + hasVisibleError: Object.keys(hasVisibleErrorRef.current).length > 0, + fieldConnectionsRef, + fieldPropsRef, + valuePropsRef, + mountedFieldsRef, + snapshotsRef, + formElementRef, + ajvInstance: ajvRef.current, + + /** Additional */ + id, + data: internalDataRef.current, + internalDataRef, + isEmptyDataRef, + props, + ...rest, + } + + if (id) { + sharedDataContext.set(contextValue) + } + return ( - 0, - fieldConnectionsRef, - fieldPropsRef, - valuePropsRef, - mountedFieldsRef, - formElementRef, - ajvInstance: ajvRef.current, - - /** Additional */ - id, - data: internalDataRef.current, - internalDataRef, - isEmptyDataRef, - props, - ...rest, - }} - > + new Map()) + const mountedFieldsRef: SnapshotMap = useRef(map) + const { snapshotsRef } = useContext(DataContext) || {} + + const setMountedField: SnapshotContextState['setMountedField'] = + useCallback((path, state) => { + mountedFieldsRef.current.set(path, state) + }, []) + + useEffect(() => { + if (snapshotsRef) { + snapshotsRef.current.set(name, mountedFieldsRef.current) + } + }, [snapshotsRef, name]) + + const contextValue = { name, setMountedField } + + return ( + + {children} + + ) +} + +SnapshotProvider._supportsSpacingProps = undefined + +export default SnapshotProvider diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/SnapshotContext.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/SnapshotContext.tsx new file mode 100644 index 00000000000..0103f40c091 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/SnapshotContext.tsx @@ -0,0 +1,17 @@ +import { createContext } from 'react' +import { Path } from '../../types' +import { SnapshotName } from './Snapshot' + +export type SnapshotMap = React.MutableRefObject> + +export type SnapshotContextState = { + name: SnapshotName + setMountedField: ( + path: Path, + { isMounted }: { isMounted: boolean } + ) => void +} + +const SnapshotContext = createContext(null) + +export default SnapshotContext diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/SnapshotDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/SnapshotDocs.ts new file mode 100644 index 00000000000..c58ee0ea315 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/SnapshotDocs.ts @@ -0,0 +1,11 @@ +import { PropertiesTableProps } from '../../../../shared/types' + +export const SnapshotProperties: PropertiesTableProps = { + name: { + doc: 'A unique name for the sliced snapshot area.', + type: 'string', + status: 'optional', + }, +} + +export const SnapshotEvents: PropertiesTableProps = {} diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/__tests__/Snapshot.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/__tests__/Snapshot.test.tsx new file mode 100644 index 00000000000..26bf4df7652 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/__tests__/Snapshot.test.tsx @@ -0,0 +1,96 @@ +import React, { useCallback, useEffect, useRef } from 'react' +import { render, screen } from '@testing-library/react' +import { Field, Form } from '../../..' +import userEvent from '@testing-library/user-event' + +describe('Form.Snapshot', () => { + it('should handle sliced snapshots', async () => { + const MockComponent = () => { + const { createSnapshot, applySnapshot } = Form.useSnapshot() + const pointerRef = useRef(0) + + useEffect(() => { + createSnapshot(pointerRef.current, 'my-snapshot-slice') + }, [createSnapshot]) + + const changeHandler = useCallback(() => { + pointerRef.current += 1 + createSnapshot(pointerRef.current, 'my-snapshot-slice') + }, [createSnapshot]) + + const undoHandler = useCallback(() => { + pointerRef.current -= 1 + applySnapshot(pointerRef.current, 'my-snapshot-slice') + }, [applySnapshot]) + + const redoHandler = useCallback(() => { + pointerRef.current += 1 + applySnapshot(pointerRef.current, 'my-snapshot-slice') + }, [applySnapshot]) + + return ( + <> + + + + + + + + + + ) + } + + render( + + + + ) + + const willBeRevertedInput = screen.getByLabelText( + 'Will be reverted' + ) as HTMLInputElement + const willStayInput = screen.getByLabelText( + 'Will stay' + ) as HTMLInputElement + const undoButton = screen.getByText('Undo') + const redoButton = screen.getByText('Redo') + + expect(willBeRevertedInput.value).toBe('') + expect(willStayInput.value).toBe('') + + await userEvent.type(willBeRevertedInput, 'Hello World') + + expect(willBeRevertedInput.value).toBe('Hello World') + expect(willStayInput.value).toBe('') + + await userEvent.click(undoButton) + await userEvent.click(undoButton) + await userEvent.click(undoButton) + + expect(willBeRevertedInput.value).toBe('Hello Wo') + + await userEvent.type(willStayInput, 'Stay') + + await userEvent.click(redoButton) + await userEvent.click(redoButton) + + expect(willBeRevertedInput.value).toBe('Hello Worl') + expect(willStayInput).toHaveValue('Stay') + + await userEvent.click(redoButton) + + expect(willBeRevertedInput.value).toBe('Hello World') + expect(willStayInput).toHaveValue('Stay') + + await userEvent.click(undoButton) + + expect(willBeRevertedInput.value).toBe('Hello Worl') + expect(willStayInput).toHaveValue('Stay') + }) +}) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/index.ts b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/index.ts new file mode 100644 index 00000000000..10c983829f4 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/index.ts @@ -0,0 +1,2 @@ +export { default } from './Snapshot' +export * from './Snapshot' diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/index.ts b/packages/dnb-eufemia/src/extensions/forms/Form/index.ts index 302c50df54b..1b59f78a1b3 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/index.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Form/index.ts @@ -12,6 +12,7 @@ export { default as SubHeading } from './SubHeading' export { default as Visibility } from './Visibility' export { default as Section } from './Section' export { default as Isolation } from './Isolation' +export { default as Snapshot } from './Snapshot' export { default as useData } from './data-context/useData' export { default as setData } from './data-context/setData' export { default as getData } from './data-context/getData' diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx index b802d706dea..3f3fc8d4571 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx @@ -33,6 +33,7 @@ import { useSharedState, } from '../../../../shared/helpers/useSharedState' import useHandleLayoutEffect from './useHandleLayoutEffect' +import useStepAnimation from './useStepAnimation' import { ComponentProps } from '../../types' import useVisibility from '../../Form/Visibility/useVisibility' @@ -130,6 +131,12 @@ function WizardContainer(props: Props) { const errorOnStepRef = useRef>({}) const stepElementRef = useRef() const preventNextStepRef = useRef(false) + const stepsRef = useRef({}) + const tmpStepsRef = useRef({}) + const updateTitlesRef = useRef<() => void>() + const prerenderFieldPropsRef = useRef< + Record React.ReactElement> + >({}) // - Handle shared state const sharedStateRef = @@ -182,7 +189,16 @@ function WizardContainer(props: Props) { ) const { setFocus, scrollToTop, isInteractionRef } = - useHandleLayoutEffect({ activeIndexRef, stepElementRef }) + useHandleLayoutEffect({ + stepElementRef, + }) + + const executeLayoutAnimationRef = useRef<() => void>() + useStepAnimation({ + activeIndexRef, + stepElementRef, + executeLayoutAnimationRef, + }) const handleLayoutEffect = useCallback(() => { if (!omitFocusManagement) { @@ -311,13 +327,6 @@ function WizardContainer(props: Props) { ) dataContext.setHandleSubmit?.(handleSubmit) - const stepsRef = useRef({}) - const tmpStepsRef = useRef({}) - const updateTitlesRef = useRef<() => void>() - const prerenderFieldPropsRef = useRef< - Record React.ReactElement> - >({}) - const { check } = useVisibility() const activeIndex = activeIndexRef.current @@ -361,16 +370,21 @@ function WizardContainer(props: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [stepsRef.current]) - useLayoutEffect(() => { + const stepsLengthDidChange = useCallback(() => { const count = Object.keys(stepsRef.current).length const tmpCount = Object.keys(tmpStepsRef.current).length - if (count !== 0 && tmpCount !== 0 && count !== tmpCount) { - // - Call onStepChange when step gets replaced or added (activeWhen) + return count !== 0 && tmpCount !== 0 && count !== tmpCount + }, []) + + // - Call onStepChange when step gets replaced or added (e.g. via activeWhen) + useLayoutEffect(() => { + if (stepsLengthDidChange()) { callOnStepChange(activeIndexRef.current, 'stepListModified') + executeLayoutAnimationRef.current?.() } tmpStepsRef.current = stepsRef.current // eslint-disable-next-line react-hooks/exhaustive-deps - }, [stepsRef.current]) + }, [stepsRef.current, callOnStepChange, stepsLengthDidChange]) if (!hasContext) { warn('You may wrap Wizard.Container in Form.Handler') diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useHandleLayoutEffect.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useHandleLayoutEffect.tsx index bdca8565a81..59529150359 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useHandleLayoutEffect.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useHandleLayoutEffect.tsx @@ -1,10 +1,6 @@ import { useCallback, useEffect, useRef } from 'react' -import useStepAnimation from './useStepAnimation' -export default function useHandleLayoutEffect({ - activeIndexRef, - stepElementRef, -}) { +export default function useHandleLayoutEffect({ stepElementRef }) { const isInteractionRef = useRef(false) useEffect(() => { @@ -16,8 +12,6 @@ export default function useHandleLayoutEffect({ return () => clearTimeout(timeout) }) - useStepAnimation({ activeIndexRef, stepElementRef }) - const action = useCallback((fn: () => void) => { // Wait for the next render cycle window.requestAnimationFrame(() => diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useStepAnimation.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useStepAnimation.tsx index d9cc274903e..455fc4ee4f4 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useStepAnimation.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useStepAnimation.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react' +import React, { useCallback, useEffect, useRef } from 'react' // SSR warning fix: https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85 const useLayoutEffect = @@ -7,6 +7,7 @@ const useLayoutEffect = export default function useStepAnimation({ activeIndexRef, stepElementRef, + executeLayoutAnimationRef, }) { const activeIndex = activeIndexRef.current const indexRef = useRef(activeIndex) @@ -15,13 +16,7 @@ export default function useStepAnimation({ indexRef.current = activeIndex }, [activeIndex]) - useLayoutEffect(() => { - // Use layout effect to compare the active step before we update the cached indexRef - // This way we don't have an animation on the first render, but only on a step change. - if (activeIndex === indexRef.current) { - return // stop here - } - + const executeLayoutAnimation = useCallback(() => { // Wait until "stepElementRef.current = currentElementRef.current" is set // So we actually get the correct elements when useStep is called, // as it rerenders the children @@ -39,5 +34,16 @@ export default function useStepAnimation({ // } }) - }, [activeIndex, stepElementRef]) + }, [stepElementRef]) + executeLayoutAnimationRef.current = executeLayoutAnimation + + useLayoutEffect(() => { + // Use layout effect to compare the active step before we update the cached indexRef + // This way we don't have an animation on the first render, but only on a step change. + if (activeIndex === indexRef.current) { + return // stop here + } + + executeLayoutAnimation() + }, [activeIndex, executeLayoutAnimation]) } diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/stories/Wizard.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/stories/Wizard.stories.tsx index 923cbdbfbf3..73f9c1cb82c 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/stories/Wizard.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/stories/Wizard.stories.tsx @@ -56,9 +56,9 @@ export const WizardDynamicStepsActiveWhen = () => { ) if (mode === 'previous') { - revertSnapshot(args.id) + revertSnapshot(args.id, 'my-snapshot-slice') } else { - createSnapshot(args.previousStep.id) + createSnapshot(args.previousStep.id, 'my-snapshot-slice') } }} > @@ -68,7 +68,12 @@ export const WizardDynamicStepsActiveWhen = () => { id="step-a" > Step A - + + + + + + @@ -78,7 +83,8 @@ export const WizardDynamicStepsActiveWhen = () => { id="step-b" > Step B - + + diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useSnapshot.test.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useSnapshot.test.ts index 103bc1babcc..d5e500fd5b7 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useSnapshot.test.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useSnapshot.test.ts @@ -48,6 +48,25 @@ describe('Form.useSnapshot', () => { expect(mockSet).toHaveBeenCalledWith({ value: 'modified' }) }) + it('applies a snapshot without deleting it', () => { + const { result } = renderHook(useSnapshot) + let snapshotId: string + + act(() => { + snapshotId = result.current.createSnapshot() + result.current.applySnapshot(snapshotId) + }) + + expect(mockSet).toHaveBeenCalledWith(mockData) + + mockSet.mockClear() + act(() => { + result.current.applySnapshot(snapshotId) + }) + + expect(mockSet).toHaveBeenCalledWith(mockData) + }) + it('reverts and deletes the snapshot', () => { const { result } = renderHook(useSnapshot) let snapshotId: string @@ -63,6 +82,7 @@ describe('Form.useSnapshot', () => { act(() => { result.current.revertSnapshot(snapshotId) }) + expect(mockSet).not.toHaveBeenCalled() }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useDataContext.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataContext.tsx new file mode 100644 index 00000000000..a8116150377 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataContext.tsx @@ -0,0 +1,33 @@ +import { useCallback, useContext } from 'react' +import { + SharedStateId, + useSharedState, +} from '../../../shared/helpers/useSharedState' +import DataContext, { ContextState } from '../DataContext/Context' + +export default function useDataContext(id: SharedStateId = undefined): { + dataContext?: ContextState + getContext: () => ContextState +} { + const sharedDataContext = useSharedState( + id + '-data-context' + ) + + const dataContext = useContext(DataContext) + const getContext = useCallback(() => { + // If no id is provided, use the context data + if (!id) { + if (!dataContext.hasContext) { + throw new Error( + 'useDataContext needs to run inside DataContext (Form.Handler) or have a valid id' + ) + } else { + return dataContext + } + } + + return sharedDataContext.get() + }, [dataContext, id, sharedDataContext]) + + return { getContext, dataContext } +} diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts index 45ea173d7f6..a42a58233e9 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts @@ -34,6 +34,7 @@ import SectionContext from '../Form/Section/SectionContext' import FieldBoundaryContext from '../DataContext/FieldBoundary/FieldBoundaryContext' import VisibilityContext from '../Form/Visibility/VisibilityContext' import WizardContext from '../Wizard/Context' +import SnapshotContext from '../Form/Snapshot/SnapshotContext' import useProcessManager from './useProcessManager' import usePath from './usePath' import { @@ -153,6 +154,8 @@ export default function useFieldProps( const sectionContext = useContext(SectionContext) const fieldBoundaryContext = useContext(FieldBoundaryContext) const wizardContext = useContext(WizardContext) + const { setMountedField: setMountedFieldSnapshot } = + useContext(SnapshotContext) || {} const { isVisible } = useContext(VisibilityContext) || {} const translation = useTranslation() @@ -1553,6 +1556,7 @@ export default function useFieldProps( isMounted: true, isPreMounted: true, }) + setMountedFieldSnapshot?.(identifier, { isMounted: true }) // Unmount procedure. return () => { @@ -1560,8 +1564,13 @@ export default function useFieldProps( isMounted: false, isPreMounted: false, }) + setMountedFieldSnapshot?.(identifier, { isMounted: false }) } - }, [identifier, setMountedFieldStateDataContext]) + }, [ + identifier, + setMountedFieldSnapshot, + setMountedFieldStateDataContext, + ]) useEffect(() => { return () => { diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useSnapshot.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/useSnapshot.tsx index 231074f6dce..7c46bea7818 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useSnapshot.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useSnapshot.tsx @@ -1,43 +1,97 @@ -import { useCallback, useRef } from 'react' +import { useCallback, useRef, useState } from 'react' import { makeUniqueId } from '../../../shared/component-helper' +import pointer from '../utils/json-pointer' import { SharedStateId } from '../../../shared/helpers/useSharedState' -import useData from '../Form/data-context/useData' +import useDataContext from './useDataContext' +import { SnapshotId, SnapshotName } from '../Form/Snapshot' +import { useData } from '../Form' export default function useSnapshot(id?: SharedStateId) { - const snapshotsRef = useRef>(new Map()) + const [map] = useState(() => new Map()) + const internalSnapshotsRef = useRef>(map) - const { data, set } = useData(id) + const { getContext } = useDataContext(id) + const { set: setData, update: updateData } = useData(id) const createSnapshot = useCallback( - (id: string = makeUniqueId(), content: any = data): string => { - snapshotsRef.current.set(id, content) + ( + id: SnapshotId = makeUniqueId(), + name: SnapshotName = null, + content: unknown = null + ): SnapshotId => { + const { internalDataRef, snapshotsRef } = getContext() + if (!content) { + const snapshotWithPaths = snapshotsRef?.current?.get?.(name) + if (snapshotWithPaths) { + const collectedData = new Map() + snapshotWithPaths.forEach((isMounted, path) => { + if (isMounted && pointer.has(internalDataRef.current, path)) { + collectedData.set( + path, + pointer.get(internalDataRef.current, path) + ) + } + }) + content = collectedData + } else { + content = internalDataRef.current + } + } + internalSnapshotsRef.current.set( + combineIdWithName(id, name), + content + ) return id }, - [data] + [getContext] ) - const getSnapshot = useCallback((id: string): any => { - return snapshotsRef.current.get(id) - }, []) + const getSnapshot = useCallback( + (id: SnapshotId, name: SnapshotName = null): unknown => { + return internalSnapshotsRef.current.get(combineIdWithName(id, name)) + }, + [] + ) - const deleteSnapshot = useCallback((id: string): void => { - snapshotsRef.current.delete(id) - }, []) + const deleteSnapshot = useCallback( + (id: SnapshotId, name: SnapshotName = null): void => { + internalSnapshotsRef.current.delete(combineIdWithName(id, name)) + }, + [] + ) - const revertSnapshot = useCallback( - (id: string) => { - const snapshot = getSnapshot(id) - if (snapshot) { - set(snapshot) + const applySnapshot = useCallback( + (id: SnapshotId, name: SnapshotName = null) => { + const context = getContext() + const snapshot = getSnapshot(id, name) + + if (snapshot instanceof Map) { + snapshot.forEach((value, path) => { + updateData(path, value) + }) + } else if (snapshot) { + setData(snapshot) } + context.forceUpdate() + }, + [getContext, getSnapshot, setData, updateData] + ) - deleteSnapshot(id) + const revertSnapshot = useCallback( + (id: SnapshotId, name: SnapshotName = null) => { + applySnapshot(id, name) + deleteSnapshot(id, name) }, - [deleteSnapshot, getSnapshot, set] + [applySnapshot, deleteSnapshot] ) return { createSnapshot, revertSnapshot, + applySnapshot, } } + +function combineIdWithName(id: SnapshotId, name: SnapshotName = null) { + return name ? `${id}-${name}` : id +}