From afe12783b431474a016e8db1d30a95a6431073a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Fri, 22 Nov 2024 21:26:38 +0100 Subject: [PATCH] feat(Forms): add support for using a function instance as a reference instead of a string based id --- .../extensions/forms/Form/getData/info.mdx | 2 +- .../extensions/forms/Form/setData/info.mdx | 2 +- .../extensions/forms/Form/useData/info.mdx | 4 +- .../forms/Form/useSnapshot/info.mdx | 4 +- .../forms/Form/useValidation/info.mdx | 2 +- .../forms/Wizard/Container/info.mdx | 2 +- .../forms/Wizard/location-hooks/info.mdx | 2 +- .../extensions/forms/Wizard/useStep/info.mdx | 2 +- .../extensions/forms/DataContext/Context.ts | 4 +- .../forms/DataContext/Provider/Provider.tsx | 7 +- .../data-context/__tests__/useData.test.tsx | 168 +++++++++++++++--- .../forms/Form/data-context/clearData.ts | 7 +- .../helpers/__tests__/useSharedState.test.ts | 42 ++++- .../src/shared/helpers/useSharedState.tsx | 32 ++-- 14 files changed, 228 insertions(+), 52 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx index 9abadb531ef..8b87e1c7601 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx @@ -23,7 +23,7 @@ const { getValue, data, filterData, reduceToVisibleFields } = - `filterData` will filter the data based on your own logic. - `reduceToVisibleFields` will reduce the given data set to only contain the visible fields (mounted fields). -You link them together via the `id` (string) property. +You link them together via the `id` (string, function or React Context reference) property. TypeScript support: diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/setData/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/setData/info.mdx index eac652cd94e..be0de023600 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/setData/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/setData/info.mdx @@ -16,7 +16,7 @@ function Component() { } ``` -You link them together via the `id` (string) property. +You link them together via the `id` (string, function or React Context reference) property. Related helpers: diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx index 34c96df04a6..6b817c45941 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx @@ -42,7 +42,7 @@ render( ## Usage -You can use the `Form.useData` 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. +You can use the `Form.useData` hook with or without an `id` (string, function or React Context reference) 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 @@ -66,7 +66,7 @@ function Component() { ### With an `id` property -While in this example, "Component" is outside the `Form.Handler` context, but linked together via the `id` (string) property: +While in this example, "Component" is outside the `Form.Handler` context, but linked together via the `id` (string, function or React Context reference) property: ```jsx import { Form } from '@dnb/eufemia/extensions/forms' 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 b4d6895f3c1..ce18a330789 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 @@ -61,7 +61,7 @@ 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. +You can use the `Form.useSnapshot` hook with or without an `id` (string, function or React Context reference) 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 @@ -85,7 +85,7 @@ function Component() { ### With an `id` property -While in this example, "Component" is outside the `Form.Handler` context, but linked together via the `id` (string) property: +While in this example, "Component" is outside the `Form.Handler` context, but linked together via the `id` (string, function or React Context reference) property: ```jsx import { Form } from '@dnb/eufemia/extensions/forms' diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/info.mdx index 3d74e108e06..936b9614f0c 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/info.mdx @@ -45,7 +45,7 @@ function Component() { } ``` -Or by linking the hook together with the form by using the `id` (string) property: +Or by linking the hook together with the form by using the `id` (string, function or React Context reference) property: ```jsx import { Form } from '@dnb/eufemia/extensions/forms' diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx index 89729d6ded8..9667f6a81db 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx @@ -117,7 +117,7 @@ const MyForm = () => { } ``` -When using the `useStep` hook outside of the `Wizard.Container` context, you need to provide an unique `id` (string): +When using the `useStep` hook outside of the `Wizard.Container` context, you need to provide an unique `id` (string, function or React Context reference): ```tsx import { Form, Wizard } from '@dnb/eufemia/extensions/forms' diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/location-hooks/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/location-hooks/info.mdx index 12512df0d29..e3c1785852b 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/location-hooks/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/location-hooks/info.mdx @@ -106,7 +106,7 @@ function MyForm() { ## Without a router -You connect the hook with the `Wizard.Container` component via an unique `id` (string). The `id` will be used in the URL query string: `url?unique-id-step=1`. +You connect the hook with the `Wizard.Container` component via an unique `id` (string, function or React Context reference). The `id` will be used in the URL query string: `url?unique-id-step=1`. ```jsx import { Form, Wizard } from '@dnb/eufemia/extensions/forms' diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/useStep/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/useStep/info.mdx index 0b743c3b7ac..1894ea29977 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/useStep/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/useStep/info.mdx @@ -26,7 +26,7 @@ function MyForm() { } ``` -You can also connect the hook with the `Wizard.Container` via an `id` (string). This lets you render the hook outside of the context: +You can also connect the hook with the `Wizard.Container` via an `id` (string, function or React Context reference). This lets you render the hook outside of the context: ```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 d78adf557f9..62e684e83bc 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts @@ -7,7 +7,6 @@ import { Path, EventStateObject, EventReturnWithStateObject, - Identifier, FieldProps, ValueProps, OnChange, @@ -15,6 +14,7 @@ import { } from '../types' import { Props as ProviderProps } from './Provider' import { SnapshotName } from '../Form/Snapshot' +import { SharedStateId } from '../../../shared/helpers/useSharedState' export type MountState = { isPreMounted?: boolean @@ -85,7 +85,7 @@ export type FieldConnections = { } export interface ContextState { - id?: Identifier + id?: SharedStateId hasContext: boolean /** The dataset for the form / form wizard */ data: any 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 8d7660d0a9a..229dbfbd236 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -33,7 +33,10 @@ import { debounce } from '../../../../shared/helpers' import FieldPropsProvider from '../../Field/Provider' import useUpdateEffect from '../../../../shared/helpers/useUpdateEffect' import { isAsync } from '../../../../shared/helpers/isAsync' -import { useSharedState } from '../../../../shared/helpers/useSharedState' +import { + SharedStateId, + useSharedState, +} from '../../../../shared/helpers/useSharedState' import SharedContext, { ContextProps } from '../../../../shared/Context' import useTranslation from '../../hooks/useTranslation' import DataContext, { @@ -74,7 +77,7 @@ export interface Props /** * Unique ID to communicate with the hook Form.useData */ - id?: string + id?: SharedStateId /** * Unique ID to connect with a GlobalStatus */ diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/__tests__/useData.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/__tests__/useData.test.tsx index 2987e3c3719..c1f08b2df98 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/__tests__/useData.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/__tests__/useData.test.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { createContext } from 'react' import { renderHook, act, render, fireEvent } from '@testing-library/react' import { makeUniqueId } from '../../../../../shared/component-helper' import { Field, Form, Wizard } from '../../..' @@ -124,6 +124,68 @@ describe('Form.useData', () => { expect(result.current.data).toEqual({ key: 'changed value' }) }) + it('should get data with handler id', () => { + const { result } = renderHook(() => useData(identifier), { + wrapper: ({ children }) => ( + <> + + + + + + {children} + + ), + }) + + expect(result.current.data).toEqual({ + foo: 'foo', + bar: 'bar', + }) + }) + + it('should get data with a function instance as the id', () => { + const myId = () => null + const { result } = renderHook(() => useData(myId), { + wrapper: ({ children }) => ( + <> + + + + + + {children} + + ), + }) + + expect(result.current.data).toEqual({ + foo: 'foo', + bar: 'bar', + }) + }) + + it('should get data with a React Context as the id', () => { + const myId = createContext(null) + const { result } = renderHook(() => useData(myId), { + wrapper: ({ children }) => ( + <> + + + + + + {children} + + ), + }) + + expect(result.current.data).toEqual({ + foo: 'foo', + bar: 'bar', + }) + }) + describe('remove', () => { it('should remove the data', () => { const { result } = renderHook(() => useData(), { @@ -177,6 +239,35 @@ describe('Form.useData', () => { }) expect(result.current.data).not.toHaveProperty('foo') }) + + it('should remove data with handler id', () => { + const { result } = renderHook(() => useData(identifier), { + wrapper: ({ children }) => ( + <> + + + + + + {children} + + ), + }) + + expect(result.current.data).toEqual({ + foo: 'foo', + bar: 'bar', + }) + + act(() => { + result.current.remove('/foo') + }) + + expect(result.current.data).toEqual({ + bar: 'bar', + }) + expect(result.current.data).not.toHaveProperty('foo') + }) }) it('"update" should only re-render when value has changed', () => { @@ -225,40 +316,71 @@ describe('Form.useData', () => { expect(result.current.data).toEqual({ key: 'changed value' }) }) - it('should sync two hooks by using "update"', () => { - const props = { key: 'value' } + describe('update', () => { + it('should sync two hooks by using "update"', () => { + const props = { key: 'value' } - const { result: A } = renderHook(() => useData(identifier)) - const { result: B } = renderHook(() => useData(identifier, props)) + const { result: A } = renderHook(() => useData(identifier)) + const { result: B } = renderHook(() => useData(identifier, props)) - expect(A.current.data).toEqual({ key: 'value' }) - expect(B.current.data).toEqual({ key: 'value' }) + expect(A.current.data).toEqual({ key: 'value' }) + expect(B.current.data).toEqual({ key: 'value' }) - act(() => { - B.current.update('/key', (value) => { - return 'changed ' + value + act(() => { + B.current.update('/key', (value) => { + return 'changed ' + value + }) }) + + expect(A.current.data).toEqual({ key: 'changed value' }) + expect(B.current.data).toEqual({ key: 'changed value' }) }) - expect(A.current.data).toEqual({ key: 'changed value' }) - expect(B.current.data).toEqual({ key: 'changed value' }) - }) + it('should support update without a function', () => { + const props = { key: 'value' } - it('should support update without a function', () => { - const props = { key: 'value' } + const { result: A } = renderHook(() => useData(identifier)) + const { result: B } = renderHook(() => useData(identifier, props)) - const { result: A } = renderHook(() => useData(identifier)) - const { result: B } = renderHook(() => useData(identifier, props)) + expect(A.current.data).toEqual({ key: 'value' }) + expect(B.current.data).toEqual({ key: 'value' }) - expect(A.current.data).toEqual({ key: 'value' }) - expect(B.current.data).toEqual({ key: 'value' }) + act(() => { + B.current.update('/key', 'new value') + }) - act(() => { - B.current.update('/key', 'new value') + expect(A.current.data).toEqual({ key: 'new value' }) + expect(B.current.data).toEqual({ key: 'new value' }) }) - expect(A.current.data).toEqual({ key: 'new value' }) - expect(B.current.data).toEqual({ key: 'new value' }) + it('should update data with handler id', () => { + const { result } = renderHook(() => useData(identifier), { + wrapper: ({ children }) => ( + <> + + + + + + {children} + + ), + }) + + expect(result.current.data).toEqual({ + foo: 'foo', + bar: 'bar', + }) + + act(() => { + result.current.update('/foo', 'updated') + }) + + expect(result.current.data).toEqual({ + foo: 'updated', + bar: 'bar', + }) + }) }) it('should rerender when shared state calls "set"', () => { diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/clearData.ts b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/clearData.ts index 9e79c2047b8..bee7c2a5b13 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/clearData.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/clearData.ts @@ -1,7 +1,10 @@ -import { createSharedState } from '../../../../shared/helpers/useSharedState' +import { + SharedStateId, + createSharedState, +} from '../../../../shared/helpers/useSharedState' import { SharedAttachments } from '../../DataContext/Provider' -export default function clearData(id: string) { +export default function clearData(id: SharedStateId) { const sharedAttachments = createSharedState>( id + '-attachments' ) diff --git a/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts b/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts index 104ee5ffc67..871ba5bddf6 100644 --- a/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts +++ b/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts @@ -1,9 +1,14 @@ import { renderHook, act } from '@testing-library/react' import { makeUniqueId } from '../../component-helper' -import { useSharedState, createSharedState } from '../useSharedState' +import { + useSharedState, + createSharedState, + SharedStateId, +} from '../useSharedState' +import { createContext } from 'react' describe('useSharedState', () => { - let identifier: string + let identifier: SharedStateId beforeEach(() => { identifier = makeUniqueId() @@ -47,6 +52,39 @@ describe('useSharedState', () => { expect(result.current.data).toEqual({ test: 'changed' }) }) + it('should update the shared state with a function instance as id', () => { + const identifier = () => null + const { result } = renderHook(() => + useSharedState(identifier, { test: 'initial' }) + ) + act(() => { + result.current.update({ test: 'updated' }) + }) + expect(result.current.data).toEqual({ test: 'updated' }) + }) + + it('should update the shared state with an async function instance as id', () => { + const identifier = async () => null + const { result } = renderHook(() => + useSharedState(identifier, { test: 'initial' }) + ) + act(() => { + result.current.update({ test: 'updated' }) + }) + expect(result.current.data).toEqual({ test: 'updated' }) + }) + + it('should update the shared state with a React context instance as id', () => { + const identifier = createContext(null) + const { result } = renderHook(() => + useSharedState(identifier, { test: 'initial' }) + ) + act(() => { + result.current.update({ test: 'updated' }) + }) + expect(result.current.data).toEqual({ test: 'updated' }) + }) + it('should unsubscribe from the shared state when the component unmounts', () => { const { result, unmount } = renderHook(() => useSharedState(identifier, { test: 'initial' }) diff --git a/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx b/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx index 4fb9fe76bdf..e80624ce3c4 100644 --- a/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx +++ b/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx @@ -12,7 +12,11 @@ import useMountEffect from './useMountEffect' const useLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect -export type SharedStateId = string +export type SharedStateId = + | string + | (() => void) + | Promise<() => void> + | React.Context /** * Custom hook that provides shared state functionality. @@ -141,7 +145,10 @@ interface SharedStateInstance extends SharedStateReturn { hadInitialData: boolean } -const sharedStates: Record> = {} +const sharedStates: Map< + SharedStateId, + SharedStateInstance +> = new Map() /** * Creates a shared state instance with the specified ID and initial data. @@ -154,13 +161,13 @@ export function createSharedState( id: SharedStateId, initialData?: Data ): SharedStateInstance { - if (!sharedStates[id]) { + if (!sharedStates.get(id)) { let subscribers: Subscriber[] = [] - const get = () => sharedStates[id].data + const get = () => sharedStates.get(id).data const set = (newData: Partial) => { - sharedStates[id].data = { ...newData } + sharedStates.get(id).data = { ...newData } } const update = (newData: Partial) => { @@ -169,7 +176,10 @@ export function createSharedState( } const extend = (newData: Data) => { - sharedStates[id].data = { ...sharedStates[id].data, ...newData } + sharedStates.get(id).data = { + ...sharedStates.get(id).data, + ...newData, + } sync() } @@ -187,7 +197,7 @@ export function createSharedState( subscribers.forEach((subscriber) => subscriber()) } - sharedStates[id] = { + sharedStates.set(id, { data: undefined, get, set, @@ -196,17 +206,17 @@ export function createSharedState( subscribe, unsubscribe, hadInitialData: Boolean(initialData), - } as SharedStateInstance + } as SharedStateInstance) if (initialData) { extend(initialData) } } else if ( - sharedStates[id].data === undefined && + sharedStates.get(id).data === undefined && initialData !== undefined ) { - sharedStates[id].data = { ...initialData } + sharedStates.get(id).data = { ...initialData } } - return sharedStates[id] + return sharedStates.get(id) }