Skip to content

Commit

Permalink
feat(Form.useData): add data handler to get forms data outside of the…
Browse files Browse the repository at this point in the history
… context
  • Loading branch information
tujoworker committed Jan 12, 2024
1 parent 1e37cf6 commit 915a4b6
Show file tree
Hide file tree
Showing 14 changed files with 336 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Form.Handler
id="unique"
onChange={...}
onSubmit={...}
>
...
</Form.Handler>
)
}
```

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:
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Form.Handler id="unique">...</Form.Handler>
}
```

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 (
<Form.Handler id="unique" data={existingDataBar}>
<Field.String path="/foo" value={existingValue} />
</Form.Handler>
)
}
```

## 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"`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
Expand Down
2 changes: 1 addition & 1 deletion packages/dnb-eufemia/src/components/upload/useUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<useUploadReturn>(id) || {}

const setFiles = (files: UploadFile[]) => {
update({ files })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -21,6 +22,8 @@ import Context, { ContextState } from '../Context'
import structuredClone from '@ungap/structured-clone'

export interface Props<Data extends JsonObject> {
/** 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<Data>
/** Dynamic source data used as both initial data, and updates internal data if changed after mount */
Expand Down Expand Up @@ -65,6 +68,7 @@ function removeListPath(paths: PathList, path: string): PathList {
const isArrayJsonPointer = /^\/\d+(\/|$)/

export default function Provider<Data extends JsonObject>({
id,
defaultData,
data,
schema,
Expand Down Expand Up @@ -111,6 +115,14 @@ export default function Provider<Data extends JsonObject>({
const dataCacheRef = useRef<Partial<Data>>(data)
// - Validator
const ajvSchemaValidatorRef = useRef<ValidateFunction>()
// - 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) {
Expand Down Expand Up @@ -193,11 +205,15 @@ export default function Provider<Data extends JsonObject>({

internalDataRef.current = newData

if (id) {
updateEmitter?.(newData)
}

forceUpdate()

return newData
},
[sessionStorageId]
[id, sessionStorageId, updateEmitter]
)

const handlePathChange = useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
act,
fireEvent,
render,
renderHook,
screen,
waitFor,
} from '@testing-library/react'
Expand Down Expand Up @@ -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(
<DataContext.Provider id="unique">
<Field.String path="/foo" />
</DataContext.Provider>
)

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(
<DataContext.Provider id="unique-a">
<Field.String path="/foo" />
</DataContext.Provider>
)

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(
<DataContext.Provider id="unique-b" data={{ foo: 'changed' }}>
<Field.String path="/foo" />
</DataContext.Provider>
)

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(
<DataContext.Provider id="unique-c">
<Field.String path="/foo" />
</DataContext.Provider>
)

const inputElement = document.querySelector('input')

expect(inputElement).toHaveValue('bar')

rerender(
<DataContext.Provider id="unique-c" data={{ foo: 'changed' }}>
<Field.String path="/foo" />
</DataContext.Provider>
)

expect(inputElement).toHaveValue('changed')
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default function FormHandler<Data extends JsonObject>({
...rest
}: ProviderProps<Data> & Omit<Props, keyof ProviderProps<Data>>) {
const providerProps = {
id: rest.id,
defaultData,
data,
schema,
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
})
})
31 changes: 31 additions & 0 deletions packages/dnb-eufemia/src/extensions/forms/Form/hooks/useData.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useEffect, useRef } from 'react'
import { useEventEmitter } from '../../../../shared/component-helper'

export default function useData<Data>(
id: string,
data: Data = undefined
): Data {
const {
set,
update,
data: emitterData,
} = useEventEmitter<Data>(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
}
1 change: 1 addition & 0 deletions packages/dnb-eufemia/src/extensions/forms/Form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,6 @@ export default function useDataValue<
return undefined
}, [
props.value,
props.capitalize,
inIterate,
itemPath,
dataContext.data,
Expand Down Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 915a4b6

Please sign in to comment.