From 74477e90d83691c195d62f4b66aa33388d1a0678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Wed, 9 Oct 2024 15:06:47 +0200 Subject: [PATCH] feat(Forms): add `Form.useSnapshot` hook to handle snapshots of data --- .../extensions/forms/Form/useSnapshot.mdx | 23 +++++ .../forms/Form/useSnapshot/Examples.tsx | 97 +++++++++++++++++++ .../forms/Form/useSnapshot/demos.mdx | 13 +++ .../forms/Form/useSnapshot/info.mdx | 75 ++++++++++++++ .../extensions/forms/Wizard/Step/info.mdx | 6 +- .../src/extensions/forms/Form/index.ts | 1 + .../Wizard/Container/WizardContainer.tsx | 48 +++++---- .../Wizard/Container/WizardContainerDocs.ts | 2 +- .../__tests__/WizardContainer.test.tsx | 38 ++++---- .../forms/Wizard/Context/WizardContext.ts | 14 ++- .../forms/Wizard/stories/Wizard.stories.tsx | 27 ++++-- .../forms/hooks/__tests__/useSnapshot.test.ts | 68 +++++++++++++ .../extensions/forms/hooks/useSnapshot.tsx | 43 ++++++++ 13 files changed, 401 insertions(+), 54 deletions(-) create mode 100644 packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot.mdx create mode 100644 packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/Examples.tsx create mode 100644 packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/demos.mdx create mode 100644 packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx create mode 100644 packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useSnapshot.test.ts create mode 100644 packages/dnb-eufemia/src/extensions/forms/hooks/useSnapshot.tsx 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..11814ed6c06 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/Examples.tsx @@ -0,0 +1,97 @@ +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') { + revertSnapshot(args.id) + } else { + createSnapshot(args.previousStep.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-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Step/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Step/info.mdx index 743af8450e1..915005f7a1b 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Step/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Step/info.mdx @@ -57,7 +57,11 @@ To keep track of the current step, you can provide each step with an `id` proper ```tsx { - const { id, previousIndex, preventNavigation } = args + const { + id, + preventNavigation, + previousStep: { index }, + } = args }} > { - const previousIndex = activeIndexRef.current - const options = { - previousIndex, - preventNavigation, - } - const id = stepsRef.current[index]?.id - if (id) { - Object.assign(options, { id }) - } + const getStepChangeOptions: (index: StepIndex) => OnStepChangeOptions = + useCallback( + (index) => { + const previousIndex = activeIndexRef.current + const options = { + preventNavigation, + previousStep: { index: previousIndex }, + } - return options - }, - [preventNavigation] - ) + const id = stepsRef.current[index]?.id + if (id) { + const previousId = stepsRef.current[previousIndex]?.id + Object.assign(options, { id }) + Object.assign(options.previousStep, { id: previousId }) + } + + return options + }, + [preventNavigation] + ) const callOnStepChange = useCallback( async (index: StepIndex, mode: OnStepsChangeMode) => { @@ -213,11 +218,14 @@ function WizardContainer(props: Props) { ) } - const result = - skipStepChangeCall || - (skipStepChangeCallBeforeMounted && !isInteractionRef.current) - ? undefined - : await callOnStepChange(index, mode) + let result = undefined + + if ( + !skipStepChangeCall && + !(skipStepChangeCallBeforeMounted && !isInteractionRef.current) + ) { + result = await callOnStepChange(index, mode) + } // Hide async indicator setFormState('abort') diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts index a3b2998ac7c..8626c03e9df 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts @@ -50,7 +50,7 @@ export const WizardContainerProperties: PropertiesTableProps = { export const WizardContainerEvents: PropertiesTableProps = { onStepChange: { - doc: 'Will be called when the user navigate to a different step, with step `index` (number) as the first argument and a string with `previous` or `next` (or `stepListModified` when a step gets replaced) as the second argument, and an object containing `previousIndex`, `id` (if set on the Wizard.Step), `preventNavigation` function as the third parameter. \n\nWhen an async function is provided, it will show an indicator on the submit button during the form submission. All form elements will be disabled during the submit. The indicator will be shown for minimum 1 second. Related Form.Handler props: `minimumAsyncBehaviorTime` and `asyncSubmitTimeout`.', + doc: 'Will be called when the user navigate to a different step, with step `index` as the first argument and `previous` or `next` (string) as the second argument, and an options object containing `preventNavigation` function as the third parameter. When an async function is provided, it will show an indicator on the submit button during the form submission. All form elements will be disabled during the submit. The indicator will be shown for minimum 1 second. Related Form.Handler props: `minimumAsyncBehaviorTime` and `asyncSubmitTimeout`.', type: 'function', status: 'optional', }, diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx index b6be9bf8f35..74a06cb1e1e 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx @@ -127,7 +127,7 @@ describe('Wizard.Container', () => { }) }) - it('should have previousIndex in "onStepChange" when navigating back and forth', async () => { + it('should have previousStep in "onStepChange" when navigating back and forth', async () => { const onStepChange = jest.fn() render( @@ -149,7 +149,7 @@ describe('Wizard.Container', () => { expect(onStepChange).toHaveBeenCalledTimes(1) expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', { preventNavigation: expect.any(Function), - previousIndex: 0, + previousStep: { index: 0 }, }) await userEvent.click(previousButton()) @@ -157,7 +157,7 @@ describe('Wizard.Container', () => { expect(onStepChange).toHaveBeenCalledTimes(2) expect(onStepChange).toHaveBeenLastCalledWith(0, 'previous', { preventNavigation: expect.any(Function), - previousIndex: 1, + previousStep: { index: 1 }, }) await userEvent.click(nextButton()) @@ -165,7 +165,7 @@ describe('Wizard.Container', () => { expect(onStepChange).toHaveBeenCalledTimes(3) expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', { preventNavigation: expect.any(Function), - previousIndex: 0, + previousStep: { index: 0 }, }) }) @@ -195,7 +195,7 @@ describe('Wizard.Container', () => { expect(onStepChange).toHaveBeenCalledTimes(1) expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', { id: 'step-2', - previousIndex: 0, + previousStep: { index: 0, id: 'step-1' }, preventNavigation: expect.any(Function), }) @@ -204,7 +204,7 @@ describe('Wizard.Container', () => { expect(onStepChange).toHaveBeenCalledTimes(2) expect(onStepChange).toHaveBeenLastCalledWith(0, 'previous', { id: 'step-1', - previousIndex: 1, + previousStep: { index: 1, id: 'step-2' }, preventNavigation: expect.any(Function), }) @@ -213,7 +213,7 @@ describe('Wizard.Container', () => { expect(onStepChange).toHaveBeenCalledTimes(3) expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', { id: 'step-2', - previousIndex: 0, + previousStep: { index: 0, id: 'step-1' }, preventNavigation: expect.any(Function), }) }) @@ -1029,7 +1029,7 @@ describe('Wizard.Container', () => { 'stepListModified', { id: 'step-1', - previousIndex: 0, + previousStep: { index: 0, id: 'step-1' }, preventNavigation: expect.any(Function), } ) @@ -1043,7 +1043,7 @@ describe('Wizard.Container', () => { 'stepListModified', { id: 'step-2', - previousIndex: 0, + previousStep: { index: 0, id: 'step-2' }, preventNavigation: expect.any(Function), } ) @@ -1057,7 +1057,7 @@ describe('Wizard.Container', () => { 'stepListModified', { id: 'step-1', - previousIndex: 0, + previousStep: { index: 0, id: 'step-1' }, preventNavigation: expect.any(Function), } ) @@ -1068,7 +1068,7 @@ describe('Wizard.Container', () => { expect(onStepChange).toHaveBeenCalledTimes(4) expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', { id: 'step-3', - previousIndex: 0, + previousStep: { index: 0, id: 'step-1' }, preventNavigation: expect.any(Function), }) @@ -1081,7 +1081,7 @@ describe('Wizard.Container', () => { 'stepListModified', { id: 'step-2', - previousIndex: 0, + previousStep: { index: 0, id: 'step-2' }, preventNavigation: expect.any(Function), } ) @@ -1095,7 +1095,7 @@ describe('Wizard.Container', () => { 'stepListModified', { id: 'step-1', - previousIndex: 0, + previousStep: { index: 0, id: 'step-1' }, preventNavigation: expect.any(Function), } ) @@ -1106,7 +1106,7 @@ describe('Wizard.Container', () => { expect(onStepChange).toHaveBeenCalledTimes(7) expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', { id: 'step-3', - previousIndex: 0, + previousStep: { index: 0, id: 'step-1' }, preventNavigation: expect.any(Function), }) @@ -1116,7 +1116,7 @@ describe('Wizard.Container', () => { expect(onStepChange).toHaveBeenCalledTimes(8) expect(onStepChange).toHaveBeenLastCalledWith(0, 'previous', { id: 'step-1', - previousIndex: 1, + previousStep: { index: 1, id: 'step-3' }, preventNavigation: expect.any(Function), }) }) @@ -1244,7 +1244,7 @@ describe('Wizard.Container', () => { expect(onStepChange).toHaveBeenCalledTimes(1) expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', { id: 'step-2', - previousIndex: 0, + previousStep: { index: 0, id: 'step-1' }, preventNavigation: expect.any(Function), }) @@ -1253,7 +1253,7 @@ describe('Wizard.Container', () => { expect(onStepChange).toHaveBeenCalledTimes(2) expect(onStepChange).toHaveBeenLastCalledWith(0, 'previous', { id: 'step-1', - previousIndex: 1, + previousStep: { index: 1, id: 'step-2' }, preventNavigation: expect.any(Function), }) @@ -1262,7 +1262,7 @@ describe('Wizard.Container', () => { expect(onStepChange).toHaveBeenCalledTimes(3) expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', { id: 'step-2', - previousIndex: 0, + previousStep: { index: 0, id: 'step-1' }, preventNavigation: expect.any(Function), }) }) @@ -1976,7 +1976,7 @@ describe('Wizard.Container', () => { expect(output()).toHaveTextContent('Step 1') expect(onStepChange).toHaveBeenCalledTimes(1) expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', { - previousIndex: 0, + previousStep: { index: 0 }, preventNavigation: expect.any(Function), }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Context/WizardContext.ts b/packages/dnb-eufemia/src/extensions/forms/Wizard/Context/WizardContext.ts index 005d32903a6..c63e6582732 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Context/WizardContext.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Context/WizardContext.ts @@ -3,14 +3,18 @@ import { EventReturnWithStateObject } from '../../types' import { VisibleWhen } from '../../Form/Visibility' export type OnStepsChangeMode = 'previous' | 'next' | 'stepListModified' +export type OnStepChangeOptions = { + preventNavigation: (shouldPrevent?: boolean) => void + id?: string + previousStep: { + index: StepIndex + id?: string + } +} export type OnStepChange = ( index: StepIndex, mode: OnStepsChangeMode, - options: { - id?: string - previousIndex: StepIndex - preventNavigation: (shouldPrevent?: boolean) => void - } + options: OnStepChangeOptions ) => | EventReturnWithStateObject | void 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..923cbdbfbf3 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( @@ -51,25 +52,33 @@ export const WizardDynamicStepsActiveWhen = () => { index, mode, args.id, - args.previousIndex + args.previousStep ) + + if (mode === 'previous') { + revertSnapshot(args.id) + } else { + createSnapshot(args.previousStep.id) + } }} > Step A + Step B + @@ -78,20 +87,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 +113,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, + } +}