diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot.mdx new file mode 100644 index 00000000000..41f3a26d950 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot.mdx @@ -0,0 +1,23 @@ +--- +title: 'useSnapshot' +description: '`Form.useSnapshot` lets you store data snapshots of your form data, either inside or outside of the form context.' +showTabs: true +tabs: + - title: Info + key: '/info' + - title: Demos + key: '/demos' +breadcrumb: + - text: Forms + href: /uilib/extensions/forms/ + - text: Form + href: /uilib/extensions/forms/Form/ + - text: Form.useSnapshot + href: /uilib/extensions/forms/Form/useSnapshot/ +--- + +import Info from 'Docs/uilib/extensions/forms/Form/useSnapshot/info' +import Demos from 'Docs/uilib/extensions/forms/Form/useSnapshot/demos' + + + 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 new file mode 100644 index 00000000000..e80949d0204 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/Examples.tsx @@ -0,0 +1,100 @@ +import ComponentBox from '../../../../../../shared/tags/ComponentBox' +import { Field, Form, Wizard } from '@dnb/eufemia/src/extensions/forms' + +export const InWizard = () => { + return ( + + {() => { + const MyForm = () => { + const { createSnapshot, revertSnapshot } = + Form.useSnapshot('my-form') + + return ( + + { + if (mode !== 'previous') { + createSnapshot(args.id) + } + }} + onBeforeStepChange={(index, mode, args) => { + if (mode === 'previous') { + revertSnapshot(args.id) + } + }} + > + + Step A + + + + + + Step B + + + + + + ['group-1', 'group-2'].includes(value), + }} + > + Step C + + + + + + Step D + + + + + + + + + + + ) + } + + 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 new file mode 100644 index 00000000000..fc99aa80054 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/demos.mdx @@ -0,0 +1,13 @@ +--- +showTabs: true +--- + +import * as Examples from './Examples' + +## Demos + +### Used in a Wizard + +This example revers 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 new file mode 100644 index 00000000000..2122a43198d --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx @@ -0,0 +1,75 @@ +--- +showTabs: true +--- + +## Description + +The `Form.useSnapshot` hook lets you store data snapshots of your form data, either inside or outside of the form context. + +The hook returns an object with the following properties: + +```tsx +import { Form } from '@dnb/eufemia/extensions/forms' + +function MyComponent() { + const { createSnapshot, revertSnapshot } = Form.useSnapshot() + + return <>MyComponent +} + +render( + + + , +) +``` + +- `createSnapshot` will store the current data as a new snapshot with the given id. +- `revertSnapshot` will revert the data to the snapshot with the given id (required). A reverted snapshot gets deleted from the memory. + +## Usage + +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: + +```jsx +import { Form } from '@dnb/eufemia/extensions/forms' + +function MyForm() { + return ( + + + + ) +} + +function Component() { + const { createSnapshot, revertSnapshot } = Form.useSnapshot() +} +``` + +### With an `id` property + +While in this example, "Component" is outside the `Form.Handler` context, but linked together via the `id` (string) property: + +```jsx +import { Form } from '@dnb/eufemia/extensions/forms' + +function MyForm() { + return ( + <> + ... + + + ) +} + +function Component() { + const { createSnapshot, revertSnapshot } = Form.useSnapshot('unique') +} +``` + +This is beneficial when you need to utilize the form data in other places within your application. diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/index.ts b/packages/dnb-eufemia/src/extensions/forms/Form/index.ts index ae7fd03bd51..302c50df54b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/index.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Form/index.ts @@ -18,6 +18,7 @@ export { default as getData } from './data-context/getData' export { default as clearData } from './data-context/clearData' export { default as useValidation } from './data-context/useValidation' export { default as useTranslation } from '../hooks/useTranslation' +export { default as useSnapshot } from '../hooks/useSnapshot' /** * Can be removed in v11 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 c1572f941cc..2598e7b628c 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 @@ -42,8 +42,9 @@ export const Basic = () => { } export const WizardDynamicStepsActiveWhen = () => { + const { createSnapshot, revertSnapshot } = Form.useSnapshot('my-form') return ( - + { console.log( @@ -53,23 +54,40 @@ export const WizardDynamicStepsActiveWhen = () => { args.id, args.previousIndex ) + if (mode !== 'previous') { + createSnapshot(args.id) + } + }} + onBeforeStepChange={(index, mode, args) => { + console.log( + 'onBeforeStepChange', + index, + mode, + args.id, + args.previousIndex + ) + if (mode === 'previous') { + revertSnapshot(args.id) + } }} > Step A + Step B + @@ -78,20 +96,22 @@ export const WizardDynamicStepsActiveWhen = () => { activeWhen={{ path: '/activeSteps', hasValue: (value: string) => - ['group-1', 'group-2'].includes(value), + ['group-a', 'group-b'].includes(value), }} id="step-c" > Step C + Step D + @@ -102,8 +122,8 @@ export const WizardDynamicStepsActiveWhen = () => { optionsLayout="horizontal" top > - - + + ) 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 new file mode 100644 index 00000000000..103bc1babcc --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useSnapshot.test.ts @@ -0,0 +1,68 @@ +import { renderHook, act } from '@testing-library/react' +import useSnapshot from '../useSnapshot' + +let mockData: any +const mockSet = jest.fn() + +beforeEach(() => { + mockData = { value: 'initial' } + mockSet.mockClear() +}) + +afterEach(() => { + jest.restoreAllMocks() +}) + +jest.mock('../../Form/data-context/useData', () => { + const useDataMock = jest.fn(() => ({ + data: mockData, + set: mockSet, + })) + return useDataMock +}) + +describe('Form.useSnapshot', () => { + it('creates a snapshot and retrieves it correctly', () => { + const { result } = renderHook(useSnapshot) + let snapshotId: string + + act(() => { + snapshotId = result.current.createSnapshot() + }) + + expect(result.current.createSnapshot).toBeDefined() + expect(result.current.revertSnapshot).toBeDefined() + expect(snapshotId).toBeTruthy() + }) + + it('reverts to a snapshot', () => { + const { result } = renderHook(useSnapshot) + act(() => { + const snapshotId = result.current.createSnapshot(undefined, { + value: 'modified', + }) + mockSet.mockClear() + result.current.revertSnapshot(snapshotId) + }) + + expect(mockSet).toHaveBeenCalledWith({ value: 'modified' }) + }) + + it('reverts and deletes the snapshot', () => { + const { result } = renderHook(useSnapshot) + let snapshotId: string + + act(() => { + snapshotId = result.current.createSnapshot() + result.current.revertSnapshot(snapshotId) + }) + + expect(mockSet).toHaveBeenCalledWith(mockData) + + mockSet.mockClear() + act(() => { + result.current.revertSnapshot(snapshotId) + }) + expect(mockSet).not.toHaveBeenCalled() + }) +}) diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useSnapshot.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/useSnapshot.tsx new file mode 100644 index 00000000000..231074f6dce --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useSnapshot.tsx @@ -0,0 +1,43 @@ +import { useCallback, useRef } from 'react' +import { makeUniqueId } from '../../../shared/component-helper' +import { SharedStateId } from '../../../shared/helpers/useSharedState' +import useData from '../Form/data-context/useData' + +export default function useSnapshot(id?: SharedStateId) { + const snapshotsRef = useRef>(new Map()) + + const { data, set } = useData(id) + + const createSnapshot = useCallback( + (id: string = makeUniqueId(), content: any = data): string => { + snapshotsRef.current.set(id, content) + return id + }, + [data] + ) + + const getSnapshot = useCallback((id: string): any => { + return snapshotsRef.current.get(id) + }, []) + + const deleteSnapshot = useCallback((id: string): void => { + snapshotsRef.current.delete(id) + }, []) + + const revertSnapshot = useCallback( + (id: string) => { + const snapshot = getSnapshot(id) + if (snapshot) { + set(snapshot) + } + + deleteSnapshot(id) + }, + [deleteSnapshot, getSnapshot, set] + ) + + return { + createSnapshot, + revertSnapshot, + } +}