diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms.mdx index 724f31d547c..b5c58865f99 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms.mdx @@ -66,6 +66,27 @@ render( ) ``` +Or together with the `Form.getData` hook: + +```jsx +import { Form } from '@dnb/eufemia/extensions/forms' +function Component() { + const data = Form.useData('unique', existingData) + + return ( + + ... + + ) +} +``` + +You decide where you want to provide the initial `data`. It can be done via the `Form.Handler` component, or via the `Form.useData` Hook – or even in each Field, with the value property. + ## Philosophy Eufemia Forms is: @@ -89,6 +110,7 @@ In summary: - Ready to use data driven form components. - All functionality in all components can be controlled and overridden via props. - State management using the declarative [JSON Pointer](https://datatracker.ietf.org/doc/html/draft-ietf-appsawg-json-pointer-03) directive (i.e `path="/firstName"`). +- State can be handled outside of the provider context [Form.Handler](/uilib/extensions/forms/extended-features/Form/Handler). - Simple validation (like `minLength` on text fields) as well as [Ajv JSON schema validator](https://ajv.js.org/) support on both single fields and the whole data set. - Building blocks for [creating custom field components](/uilib/extensions/forms/create-component). - Static [value components](/uilib/extensions/forms/extended-features/Value/) for displaying data with proper formatting. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/extended-features/Form/Handler/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/extended-features/Form/Handler/info.mdx index 618c060fb41..6bcf3691861 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/extended-features/Form/Handler/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/extended-features/Form/Handler/info.mdx @@ -22,6 +22,34 @@ render( ) ``` +## Form.getData + +State can be handled outside of the form. This is useful if you want to use the form data in other components: + +```jsx +import { Form } from '@dnb/eufemia/extensions/forms' +function Component() { + const data = Form.useData('unique') + + return ... +} +``` + +You decide where you want to provide the initial `data`. It can be done via the `Form.Handler` component, or via the `Form.useData` Hook – or even in each Field, with the value property: + +```jsx +import { Form, Field } from '@dnb/eufemia/extensions/forms' +function Component() { + const data = Form.useData('unique', existingDataFoo) + + return ( + + + + ) +} +``` + ## Browser autofill You can set `autoComplete` on the `Form.Handler` – each [Field.String](/uilib/extensions/forms/base-fields/String/)-field will then get `autoComplete="on"`: diff --git a/packages/dnb-eufemia/src/components/tabs/TabsContentWrapper.js b/packages/dnb-eufemia/src/components/tabs/TabsContentWrapper.js index 721d4d4f422..abcd0c09348 100644 --- a/packages/dnb-eufemia/src/components/tabs/TabsContentWrapper.js +++ b/packages/dnb-eufemia/src/components/tabs/TabsContentWrapper.js @@ -46,7 +46,7 @@ export default class ContentWrapper extends React.PureComponent { componentDidMount() { if (this.props.id && this._eventEmitter) { this._eventEmitter.listen((params) => { - if (this._eventEmitter && params.key !== this.state.key) { + if (this._eventEmitter && params?.key !== this.state.key) { this.setState(params) } }) diff --git a/packages/dnb-eufemia/src/components/upload/useUpload.ts b/packages/dnb-eufemia/src/components/upload/useUpload.ts index a8166c9ce0f..234cf2a87d5 100644 --- a/packages/dnb-eufemia/src/components/upload/useUpload.ts +++ b/packages/dnb-eufemia/src/components/upload/useUpload.ts @@ -13,7 +13,7 @@ export type useUploadReturn = { * Use together with Upload with the same id to manage the files from outside the component. */ function useUpload(id: string): useUploadReturn { - const { data, update } = useEventEmitter(id) + const { data, update } = useEventEmitter(id) || {} const setFiles = (files: UploadFile[]) => { update({ files }) 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 0632a4f0276..bee8d6d436d 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -10,6 +10,7 @@ import { JSONSchema7 } from 'json-schema' import { ValidateFunction } from 'ajv' import ajv, { ajvErrorsToFormErrors } from '../../utils/ajv' import { FormError } from '../../types' +import { useEventEmitter } from '../../../../shared/helpers/useEventEmitter' import useMountEffect from '../../hooks/useMountEffect' import useUpdateEffect from '../../hooks/useUpdateEffect' import Context, { ContextState } from '../Context' @@ -21,6 +22,8 @@ import Context, { ContextState } from '../Context' import structuredClone from '@ungap/structured-clone' export interface Props { + /** Unique ID to communicate with the hook useData */ + id?: string /** Default source data, only used if no other source is available, and not leading to updates if changed after mount */ defaultData?: Partial /** Dynamic source data used as both initial data, and updates internal data if changed after mount */ @@ -65,6 +68,7 @@ function removeListPath(paths: PathList, path: string): PathList { const isArrayJsonPointer = /^\/\d+(\/|$)/ export default function Provider({ + id, defaultData, data, schema, @@ -111,6 +115,14 @@ export default function Provider({ const dataCacheRef = useRef>(data) // - Validator const ajvSchemaValidatorRef = useRef() + // - Emitter data + const { data: emitterData, update: updateEmitter } = useEventEmitter( + id, + data + ) + if (emitterData && !(data ?? defaultData)) { + internalDataRef.current = emitterData as Data + } const validateData = useCallback(() => { if (!ajvSchemaValidatorRef.current) { @@ -193,11 +205,15 @@ export default function Provider({ internalDataRef.current = newData + if (id) { + updateEmitter?.(newData) + } + forceUpdate() return newData }, - [sessionStorageId] + [id, sessionStorageId, updateEmitter] ) const handlePathChange = useCallback( diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx index 242915e919a..07b81322838 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx @@ -3,6 +3,7 @@ import { act, fireEvent, render, + renderHook, screen, waitFor, } from '@testing-library/react' @@ -820,4 +821,75 @@ describe('DataContext.Provider', () => { expect(screen.queryByRole('alert')).toBeInTheDocument() }) }) + + describe('useData', () => { + it('should set Provider data', () => { + renderHook(() => Form.useData('unique', { foo: 'bar' })) + + render( + + + + ) + + const inputElement = document.querySelector('input') + expect(inputElement).toHaveValue('bar') + }) + + it('should update Provider data on hook rerender', () => { + const { rerender } = renderHook((props = { foo: 'bar' }) => { + return Form.useData('unique-a', props) + }) + + render( + + + + ) + + const inputElement = document.querySelector('input') + + expect(inputElement).toHaveValue('bar') + + rerender({ foo: 'bar-changed' }) + + expect(inputElement).toHaveValue('bar-changed') + }) + + it('should only set data when Provider has no data given', () => { + renderHook(() => Form.useData('unique-b', { foo: 'bar' })) + + render( + + + + ) + + const inputElement = document.querySelector('input') + + expect(inputElement).toHaveValue('changed') + }) + + it('should initially set data when Provider has no data', () => { + renderHook(() => Form.useData('unique-c', { foo: 'bar' })) + + const { rerender } = render( + + + + ) + + const inputElement = document.querySelector('input') + + expect(inputElement).toHaveValue('bar') + + rerender( + + + + ) + + expect(inputElement).toHaveValue('changed') + }) + }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx index 2749b958049..3d4bbb8a33a 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx @@ -31,6 +31,7 @@ export default function FormHandler({ ...rest }: ProviderProps & Omit>) { const providerProps = { + id: rest.id, defaultData, data, schema, diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/hooks/__tests__/useData.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/hooks/__tests__/useData.test.tsx new file mode 100644 index 00000000000..2b19b8e844f --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/hooks/__tests__/useData.test.tsx @@ -0,0 +1,107 @@ +import { renderHook } from '@testing-library/react' +import useData from '../useData' +import { useEventEmitter } from '../../../../../shared/helpers/useEventEmitter' + +const emitter = useEventEmitter as jest.Mock + +jest.mock('../../../../../shared/helpers/useEventEmitter', () => { + return { + useEventEmitter: jest.fn(), + } +}) + +describe('Form.useData', () => { + it('should return undefined by default', () => { + const { result } = renderHook(() => useData('id')) + expect(result.current).toEqual(undefined) + }) + + it('should return initialData if data is not present', () => { + const set = jest.fn() + const update = jest.fn() + + emitter.mockReturnValue({ + data: {}, + set, + update, + }) + + const { result } = renderHook(() => useData('id', { key: 'value' })) + + expect(result.current).toEqual({ key: 'value' }) + }) + + it('should return data if data is present', () => { + const set = jest.fn() + const update = jest.fn() + + emitter.mockReturnValue({ + data: { key: 'existingValue' }, + set, + update, + }) + + const { result } = renderHook(() => useData('id', { key: 'value' })) + + expect(result.current).toEqual({ key: 'existingValue' }) + }) + + it('should call "set" with initialData on mount if data is not present', () => { + const set = jest.fn() + const update = jest.fn() + + emitter.mockReturnValue({ + data: {}, + set, + update, + }) + + renderHook(() => useData('id', { key: 'value' })) + + expect(set).toHaveBeenCalledTimes(1) + expect(set).toHaveBeenLastCalledWith({ key: 'value' }) + expect(update).not.toHaveBeenCalled() + }) + + it('should call "update" with initialData rerender', () => { + const set = jest.fn() + const update = jest.fn() + + emitter.mockReturnValue({ + data: {}, + set, + update, + }) + + const { rerender } = renderHook((props = { key: 'value' }) => + useData('id', props) + ) + + expect(set).toHaveBeenCalledTimes(1) + expect(set).toHaveBeenLastCalledWith({ key: 'value' }) + expect(update).not.toHaveBeenCalled() + + rerender({ key: 'changed' }) + + expect(set).toHaveBeenCalledTimes(2) + expect(set).toHaveBeenLastCalledWith({ key: 'changed' }) + expect(update).toHaveBeenCalledTimes(1) + expect(update).toHaveBeenLastCalledWith({ key: 'changed' }) + }) + + it('should not call "update" or "set" with initialData on mount if data is present', () => { + const set = jest.fn() + const update = jest.fn() + + emitter.mockReturnValue({ + data: { key: 'existingValue' }, + set, + update, + }) + + renderHook(() => useData('id', { key: 'value' })) + + expect(set).not.toHaveBeenCalled() + expect(update).not.toHaveBeenCalled() + }) +}) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/hooks/useData.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/hooks/useData.tsx new file mode 100644 index 00000000000..fb6304c3614 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/hooks/useData.tsx @@ -0,0 +1,31 @@ +import { useEffect, useRef } from 'react' +import { useEventEmitter } from '../../../../shared/component-helper' + +export default function useData( + id: string, + data: Data = undefined +): Data { + const { + set, + update, + data: emitterData, + } = useEventEmitter(id) || {} + const dataRef = useRef(data) + + useEffect(() => { + if (data && data !== dataRef.current) { + dataRef.current = data + update?.(data) + } + }, [data, update]) + + if (data) { + const hasExistingData = Object.keys(emitterData || {}).length > 0 + if (!hasExistingData) { + set?.(data) + return data + } + } + + return emitterData +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/index.ts b/packages/dnb-eufemia/src/extensions/forms/Form/index.ts index a706062ed18..44895516761 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/index.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Form/index.ts @@ -5,3 +5,4 @@ export { default as ButtonRow } from './ButtonRow' export { default as MainHeading } from './MainHeading' export { default as SubHeading } from './SubHeading' export { default as Visibility } from './Visibility' +export { default as useData } from './hooks/useData' diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts index ef24e7f5a45..f2f718b37fc 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataValue.ts @@ -155,7 +155,6 @@ export default function useDataValue< return undefined }, [ props.value, - props.capitalize, inIterate, itemPath, dataContext.data, @@ -369,7 +368,7 @@ export default function useDataValue< }, [dataContext.showAllErrors, showError]) useEffect(() => { - if (path && props.value) { + if (path && typeof props.value !== 'undefined') { const hasValue = pointer.has(dataContext.data, path) const value = hasValue ? pointer.get(dataContext.data, path) diff --git a/packages/dnb-eufemia/src/shared/helpers/EventEmitter.ts b/packages/dnb-eufemia/src/shared/helpers/EventEmitter.ts index 7a928682adc..9247cb4cda2 100644 --- a/packages/dnb-eufemia/src/shared/helpers/EventEmitter.ts +++ b/packages/dnb-eufemia/src/shared/helpers/EventEmitter.ts @@ -53,7 +53,7 @@ class EventEmitter { scope.__EEE__[id] = { instances: [], count: 0, - data: {}, + data: undefined, } } scope.__EEE__[id].count = scope.__EEE__[id].count + 1 @@ -92,7 +92,7 @@ class EventEmitter { unlisten(fn?: EventEmitterListener | undefined) { for (let i = 0, l = this.listeners.length; i < l; i++) { if (!fn || (fn && fn === this.listeners[i])) { - this.listeners[i] = null + delete this.listeners[i] } } } diff --git a/packages/dnb-eufemia/src/shared/helpers/__tests__/useEventEmitter.test.tsx b/packages/dnb-eufemia/src/shared/helpers/__tests__/useEventEmitter.test.tsx index 3356c2db354..ce221d151ad 100644 --- a/packages/dnb-eufemia/src/shared/helpers/__tests__/useEventEmitter.test.tsx +++ b/packages/dnb-eufemia/src/shared/helpers/__tests__/useEventEmitter.test.tsx @@ -11,7 +11,7 @@ describe('useEventEmitter', () => { return useEventEmitter('unique-id') }) - expect(result.current.data).toEqual({}) + expect(result.current.data).toEqual(undefined) }) it('has "update" function', () => { @@ -27,7 +27,7 @@ describe('useEventEmitter', () => { return useEventEmitter() }) - expect(result.current.data).toBe(null) + expect(result.current.data).toBe(undefined) expect(result.current.update).toBe(undefined) }) @@ -36,7 +36,7 @@ describe('useEventEmitter', () => { return useEventEmitter('unique-id') }) - expect(result.current.data).toEqual({}) + expect(result.current.data).toEqual(undefined) act(() => { result.current.update({ foo: 'bar' }) @@ -50,7 +50,7 @@ describe('useEventEmitter', () => { return useEventEmitter('unique-id') }) - expect(result.current.data).toEqual({}) + expect(result.current.data).toEqual(undefined) unmount() @@ -58,7 +58,23 @@ describe('useEventEmitter', () => { result.current.update({ foo: 'bar' }) }) - expect(result.current.data).toEqual({}) + expect(result.current.data).toEqual(undefined) + }) + + it('will remove listeners on unmount', () => { + const { unmount } = renderHook(() => { + return useEventEmitter('unique-id') + }) + + expect( + window.__EEE__['unique-id'].instances[0].listeners.filter(Boolean) + ).toHaveLength(1) + + unmount() + + expect( + window.__EEE__['unique-id'].instances[0].listeners.filter(Boolean) + ).toHaveLength(0) }) it('will sync data between two hooks', () => { @@ -69,8 +85,8 @@ describe('useEventEmitter', () => { return useEventEmitter('unique-id') }) - expect(A.current.data).toEqual({}) - expect(B.current.data).toEqual({}) + expect(A.current.data).toEqual(undefined) + expect(B.current.data).toEqual(undefined) act(() => { A.current.update({ foo: 'bar' }) diff --git a/packages/dnb-eufemia/src/shared/helpers/useEventEmitter.tsx b/packages/dnb-eufemia/src/shared/helpers/useEventEmitter.tsx index 793f8db72bf..cb59f1839cc 100644 --- a/packages/dnb-eufemia/src/shared/helpers/useEventEmitter.tsx +++ b/packages/dnb-eufemia/src/shared/helpers/useEventEmitter.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import { useMemo, useEffect, useReducer } from 'react' import EventEmitter, { EventEmitterData, EventEmitterId, @@ -10,26 +10,41 @@ import EventEmitter, { * @param {string} id unique id, same as used in the "lined place" * @returns React Hook { data, update } */ -export const useEventEmitter = (id: EventEmitterId = null) => { - const [, updateState] = React.useState(null) - const forceUpdate = React.useCallback(() => updateState({}), []) +export function useEventEmitter( + id: EventEmitterId = null, + initialData: Data = undefined +) { + const [, forceUpdate] = useReducer(() => ({}), {}) - React.useEffect(() => () => emitter?.unlisten(forceUpdate), []) // eslint-disable-line react-hooks/exhaustive-deps - - const [emitter] = React.useState(() => { + const emitter = useMemo(() => { if (id) { const emitter = EventEmitter.createInstance(id) emitter.listen(forceUpdate) return emitter } - }) + return { + set: undefined, + get: undefined, + update: undefined, + unlisten: undefined, + } + }, [forceUpdate, id]) - if (!emitter) { - return { data: null } - } + const { set, get, update } = emitter + const data: Data = get?.() + + useEffect(() => { + if (initialData) { + if (!data) { + update?.(initialData) + } + } + }, [data, initialData, update]) - const { get, update } = emitter - const data: EventEmitterData = get() + useEffect( + () => () => emitter?.unlisten?.(forceUpdate), + [emitter, forceUpdate] + ) - return { data, update } + return { data, set, update } }