From 1a535ed571ff9a6cf68ad4cb393179722f390e66 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/getting-started.mdx | 10 +- .../extensions/forms/DataContext/Context.ts | 4 +- .../forms/DataContext/Provider/Provider.tsx | 12 +- .../DataContext/Provider/ProviderDocs.ts | 2 +- .../Form/Element/__tests__/Element.test.tsx | 14 ++ .../data-context/__tests__/useData.test.tsx | 189 +++++++++++++++--- .../forms/Form/data-context/clearData.ts | 10 +- .../forms/Form/data-context/getData.tsx | 3 +- .../forms/Form/data-context/useData.tsx | 3 +- .../forms/Form/data-context/useValidation.tsx | 5 +- .../extensions/forms/hooks/useDataContext.tsx | 3 +- .../helpers/__tests__/useSharedState.test.ts | 117 ++++++++++- .../src/shared/helpers/useSharedState.tsx | 71 ++++--- 21 files changed, 386 insertions(+), 77 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..cd937ae186b 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, object or React Context as the 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..5363b5580b6 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, object or React Context as the 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..c46da0a4235 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, object or React Context as the 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, object or React Context as the 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..07ea4ecbf50 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, object or React Context as the 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, object or React Context as the 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..963606e8572 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, object or React Context as the 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..1161682d231 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, object or React Context as the 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..40e692bffe9 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, object or React Context as the 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..25e58b9851b 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, object or React Context as the reference). This lets you render the hook outside of the context: ```jsx import { Form } from '@dnb/eufemia/extensions/forms' diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx index 8378af5c301..bcd12191b4f 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx @@ -128,9 +128,11 @@ Here is an example of how to use these methods: ```jsx import { Form } from '@dnb/eufemia/extensions/forms' +const myFormId = 'unique-id' // or a function, object or React Context reference + function MyForm() { return ( - + ) @@ -145,15 +147,15 @@ function MyComponent() { data, filterData, reduceToVisibleFields, - } = Form.useData() // optionally provide an id (unique-id) + } = Form.useData() // optionally provide an id or reference } // You can also use the setData: -Form.setData('unique-id', { companyName: 'DNB' }) +Form.setData(myFormId, { companyName: 'DNB' }) // ... and the getData – method when ever you need to: const { getValue, data, filterData, reduceToVisibleFields } = - Form.getData('unique-id') + Form.getData(myFormId) ``` - `getValue` will return the value of the given path. 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..4817b0db407 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,11 @@ 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, + createReferenceKey, + useSharedState, +} from '../../../../shared/helpers/useSharedState' import SharedContext, { ContextProps } from '../../../../shared/Context' import useTranslation from '../../hooks/useTranslation' import DataContext, { @@ -74,7 +78,7 @@ export interface Props /** * Unique ID to communicate with the hook Form.useData */ - id?: string + id?: SharedStateId /** * Unique ID to connect with a GlobalStatus */ @@ -618,10 +622,10 @@ export default function Provider( // - Shared state const sharedData = useSharedState(id) const sharedAttachments = useSharedState>( - id + '-attachments' + createReferenceKey(id, 'attachments') ) const sharedDataContext = useSharedState( - id + '-data-context' + createReferenceKey(id, 'data-context') ) const setSharedData = sharedData.set diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/ProviderDocs.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/ProviderDocs.ts index 564b61c28f7..dece5c43721 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/ProviderDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/ProviderDocs.ts @@ -13,7 +13,7 @@ export const ProviderProperties: PropertiesTableProps = { }, id: { doc: 'Unique id for connecting Form.Handler and helper tools such as Form.useData.', - type: 'string', + type: ['string', 'Function', 'Object', 'React.Context'], status: 'optional', }, schema: { diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Element/__tests__/Element.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Element/__tests__/Element.test.tsx index 9d87326d7c1..49c25c84137 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Element/__tests__/Element.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Element/__tests__/Element.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ import React from 'react' import { fireEvent, render } from '@testing-library/react' import { Form, DataContext, Field } from '../../..' @@ -151,4 +152,17 @@ describe('Form.Element', () => { expect(attributes).toEqual(['class', 'aria-label']) expect(formElement.getAttribute('aria-label')).toBe('Aria Label') }) + + it('should ensure that only a string can be set as the id', () => { + const myId = () => null + render( + // @ts-expect-error + + Submit + + ) + + const formElement = document.querySelector('form') + expect(formElement).not.toHaveAttribute('id') + }) }) 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..65bf6dfcf09 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,89 @@ describe('Form.useData', () => { expect(result.current.data).toEqual({ key: 'changed value' }) }) + it('should get data with a string as the 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 reference 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 an object reference as the id', () => { + const myId = {} + 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 +260,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 +337,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..4fd934c85e5 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,9 +1,13 @@ -import { createSharedState } from '../../../../shared/helpers/useSharedState' +import { + SharedStateId, + createReferenceKey, + 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' + createReferenceKey(id, 'attachments') ) sharedAttachments.data.clearData?.() } diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/getData.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/getData.tsx index 3265f595264..f1bca9147b1 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/getData.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/getData.tsx @@ -1,6 +1,7 @@ import pointer from '../../utils/json-pointer' import { SharedStateId, + createReferenceKey, createSharedState, } from '../../../../shared/helpers/useSharedState' import { SharedAttachments } from '../../DataContext/Provider' @@ -23,7 +24,7 @@ export default function getData( ): SetDataReturn { const sharedState = createSharedState(id) const sharedAttachments = createSharedState>( - id + '-attachments' + createReferenceKey(id, 'attachments') ) const data = sharedState.get() as Data diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx index ba8ed93217c..8e386c358b5 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx @@ -8,6 +8,7 @@ import { import pointer, { JsonObject } from '../../utils/json-pointer' import { SharedStateId, + createReferenceKey, useSharedState, } from '../../../../shared/helpers/useSharedState' import useMountEffect from '../../../../shared/helpers/useMountEffect' @@ -89,7 +90,7 @@ export default function useData( ) sharedAttachmentsRef.current = useSharedState>( - id + '-attachments', + createReferenceKey(id, 'attachments'), { rerenderUseDataHook: forceUpdate } ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useValidation.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useValidation.tsx index 6b75302953a..e6b00106e04 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useValidation.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useValidation.tsx @@ -1,6 +1,7 @@ import { useCallback, useContext, useMemo } from 'react' import { SharedStateId, + createReferenceKey, useSharedState, } from '../../../../shared/helpers/useSharedState' import DataContext, { ContextState } from '../../DataContext/Context' @@ -19,7 +20,7 @@ export default function useValidation( ): UseDataReturn { const { data } = useSharedState< UseDataReturn & SharedAttachments - >(id + '-attachments') + >(createReferenceKey(id, 'attachments')) const fallback = useCallback(() => false, []) @@ -62,7 +63,7 @@ type UseConnectionsSharedState = { function useConnections(id: SharedStateId = undefined) { const { get } = useSharedState( - id + '-attachments' + createReferenceKey(id, 'attachments') ) const dataContext = useContext(DataContext) diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useDataContext.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataContext.tsx index a8116150377..9c2f020983a 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useDataContext.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataContext.tsx @@ -1,6 +1,7 @@ import { useCallback, useContext } from 'react' import { SharedStateId, + createReferenceKey, useSharedState, } from '../../../shared/helpers/useSharedState' import DataContext, { ContextState } from '../DataContext/Context' @@ -10,7 +11,7 @@ export default function useDataContext(id: SharedStateId = undefined): { getContext: () => ContextState } { const sharedDataContext = useSharedState( - id + '-data-context' + createReferenceKey(id, 'data-context') ) const dataContext = useContext(DataContext) 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..5fe8b8e566a 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,15 @@ import { renderHook, act } from '@testing-library/react' import { makeUniqueId } from '../../component-helper' -import { useSharedState, createSharedState } from '../useSharedState' +import { + useSharedState, + createSharedState, + SharedStateId, + createReferenceKey, +} from '../useSharedState' +import { createContext } from 'react' describe('useSharedState', () => { - let identifier: string + let identifier: SharedStateId beforeEach(() => { identifier = makeUniqueId() @@ -47,6 +53,50 @@ describe('useSharedState', () => { expect(result.current.data).toEqual({ test: 'changed' }) }) + it('should update the shared state with a function reference 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 reference 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 an object reference as id', () => { + const identifier = {} + 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 reference 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' }) @@ -171,3 +221,66 @@ describe('useSharedState', () => { expect(resultB.current.data).toEqual({ foo: 'baz' }) }) }) + +describe('createReferenceKey', () => { + it('should return the same object for the same references', () => { + const ref1 = {} + const ref2 = () => null + + const key1 = createReferenceKey(ref1, ref2) + const key2 = createReferenceKey(ref1, ref2) + + expect(key1).toBe(key2) + }) + + it('should return the same object for the same string references', () => { + const ref1 = {} + const ref2 = 'unique' + + const key1 = createReferenceKey(ref1, ref2) + const key2 = createReferenceKey(ref1, ref2) + + expect(key1).toBe(key2) + }) + + it('should return different objects for different references', () => { + const ref1 = {} + const ref2 = {} + const ref3 = {} + + const key1 = createReferenceKey(ref1, ref2) + const key2 = createReferenceKey(ref1, ref3) + + expect(key1).not.toBe(key2) + }) + + it('should return different objects for different first references', () => { + const ref1 = {} + const ref2 = {} + const ref3 = {} + + const key1 = createReferenceKey(ref1, ref2) + const key2 = createReferenceKey(ref3, ref2) + + expect(key1).not.toBe(key2) + }) + + it('should cache the combined reference', () => { + const ref1 = {} + const ref2 = () => null + + const key1 = createReferenceKey(ref1, ref2) + const key2 = createReferenceKey(ref1, ref2) + + expect(key1).toBe(key2) + }) + + it('should create a new reference if it does not exist', () => { + const ref1 = {} + const ref2 = {} + + const key1 = createReferenceKey(ref1, ref2) + + expect(key1).toBeDefined() + }) +}) diff --git a/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx b/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx index 4fb9fe76bdf..6d837bceb3f 100644 --- a/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx +++ b/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx @@ -12,20 +12,22 @@ 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 + | Record /** * Custom hook that provides shared state functionality. - * - * @template Data - The type of data stored in the shared state. - * @param {SharedStateId} id - The identifier for the shared state. - * @param {Data} initialData - The initial data for the shared state. - * @param {Function} onChange - Optional callback function to be called when the shared state is set from another instance/component. - * @returns {Object} - An object containing the shared state data, update function, extend function, and set function. */ export function useSharedState( - id: SharedStateId, + /** The identifier for the shared state. */ + id: SharedStateId | undefined, + /** The initial data for the shared state. */ initialData: Data = undefined, + /** Optional callback function to be called when the shared state is set from another instance/component. */ onChange = null ) { const [, forceUpdate] = useReducer(() => ({}), {}) @@ -53,7 +55,7 @@ export function useSharedState( }, [id, initialData]) const sharedAttachment = useMemo(() => { if (id) { - return createSharedState(id + '-oc', { onChange }) + return createSharedState(createReferenceKey(id, 'oc'), { onChange }) } }, [id, onChange]) @@ -141,26 +143,27 @@ 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. - * @template Data The type of data stored in the shared state. - * @param id The ID of the shared state. - * @param initialData The initial data for the shared state. - * @returns The created shared state instance. */ export function createSharedState( + /** The identifier for the shared state. */ id: SharedStateId, + /** The initial data for the shared state. */ 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 +172,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 +193,7 @@ export function createSharedState( subscribers.forEach((subscriber) => subscriber()) } - sharedStates[id] = { + sharedStates.set(id, { data: undefined, get, set, @@ -196,17 +202,36 @@ 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.get(id) +} + +/** + * Creates a reference key for the shared state. + * You can pass any JavaScript instance as the reference. + */ +export function createReferenceKey(ref1, ref2) { + if (!cache.has(ref1)) { + cache.set(ref1, new Map()) + } + + const innerMap = cache.get(ref1) + + if (!innerMap.has(ref2)) { + innerMap.set(ref2, {}) } - return sharedStates[id] + return innerMap.get(ref2) } +const cache = new Map()