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 }
}