Skip to content

Commit

Permalink
feat(Forms): add isolatedData to Iterate.PushContainer
Browse files Browse the repository at this point in the history
  • Loading branch information
tujoworker committed Oct 7, 2024
1 parent 59cf6c5 commit f88d702
Show file tree
Hide file tree
Showing 10 changed files with 338 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ hideInMenu: true

## Description

`Iterate.PushContainer` enables users to create a new item in the array. It can be used instead of the [PushButton](/uilib/extensions/forms/Iterate/PushButton/).
`Iterate.PushContainer` enables users to create a new item in the array. It can be used instead of the [PushButton](/uilib/extensions/forms/Iterate/PushButton/), but with fields in the container.

It allows the user to fill in the fields without storing them in the data context.

Fields inside the container must have an `itemPath` defined.
Good to know:

You can place it below the [Array](/uilib/extensions/forms/Iterate/Array/) component like this:
- Fields inside the container must have an `itemPath` defined, instead of a `path`.
- You can provide `data` or `defaultData` to prefill the fields.
- The `path` you define needs to point to an array an existing [Iterate.Array](/uilib/extensions/forms/Iterate/Array/) path.

## Usage

You may place it below the [Iterate.Array](/uilib/extensions/forms/Iterate/Array/) component like this:

```tsx
import { Iterate, Field } from '@dnb/eufemia/extensions/forms'
Expand All @@ -27,8 +33,6 @@ render(
)
```

Technically it uses the [EditContainer](/uilib/extensions/forms/Iterate/EditContainer/) and the [Form.Isolation](/uilib/extensions/forms/Form/Isolation/) under the hood.

## Show a button to create a new item

By default, it keeps the form open after a new item has been created. You can change this behavior by using the `openButton` and `showOpenButtonWhen` properties.
Expand Down Expand Up @@ -84,3 +88,9 @@ render(
</Form.Handler>,
)
```

## Technical details

Under the hood, it uses the [Form.Isolation](/uilib/extensions/forms/Form/Isolation/) component to isolate the data from the rest of the form. It also uses the the [EditContainer](/uilib/extensions/forms/Iterate/EditContainer/) inside the [Iterate.Array](/uilib/extensions/forms/Iterate/Array/) component to render the fields.

All fields inside the container will be stored in the data context at this path: `/pushContainerItems/0`.
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ hideInMenu: true

import TranslationsTable from 'dnb-design-system-portal/src/shared/parts/TranslationsTable'
import PropertiesTable from 'dnb-design-system-portal/src/shared/parts/PropertiesTable'
import { PushContainerProperties } from '@dnb/eufemia/src/extensions/forms/Iterate/PushContainer/PushContainerDocs'
import {
PushContainerProperties,
PushContainerEvents,
} from '@dnb/eufemia/src/extensions/forms/Iterate/PushContainer/PushContainerDocs'

## Properties

<PropertiesTable props={PushContainerProperties} />

## Events

<PropertiesTable props={PushContainerEvents} />

## Translations

<TranslationsTable localeKey={['IteratePushContainer']} />
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export interface ContextState {
params?: { remove?: boolean }
) => void
setFieldConnection?: (path: Path, connections: FieldConnections) => void
isEmptyDataRef?: React.MutableRefObject<boolean>
fieldPropsRef?: React.MutableRefObject<Record<string, FieldProps>>
valuePropsRef?: React.MutableRefObject<Record<string, ValueProps>>
fieldConnectionsRef?: React.RefObject<Record<Path, FieldConnections>>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ export default function Provider<Data extends JsonObject>(
// eslint-disable-next-line react-hooks/exhaustive-deps -- Avoid triggering code that should only run initially
}, [])
const internalDataRef = useRef<Data>(initialData)
const isEmptyDataRef = useRef(false)

// - Validator
const ajvValidatorRef = useRef<ValidateFunction>()
Expand Down Expand Up @@ -658,8 +659,10 @@ export default function Provider<Data extends JsonObject>(
) {
cacheRef.current.shared = sharedData.data

if (internalDataRef.current === clearedData) {
return clearedData as Data
if (isEmptyDataRef.current) {
return (
Array.isArray(internalDataRef.current) ? [] : clearedData
) as Data
}

return {
Expand All @@ -683,14 +686,22 @@ export default function Provider<Data extends JsonObject>(
: internalData

const clearData = useCallback(() => {
internalDataRef.current = (emptyData ?? clearedData) as Data
isEmptyDataRef.current = true
internalDataRef.current = ((typeof emptyData === 'function'
? emptyData(internalDataRef.current)
: emptyData) ??
(Array.isArray(internalDataRef.current) ? [] : clearedData)) as Data

if (id) {
setSharedData?.(internalDataRef.current)
}

forceUpdate()
onClear?.()

requestAnimationFrame?.(() => {
isEmptyDataRef.current = false
}) // Delay so the field validation error message are not shown
}, [emptyData, id, onClear, setSharedData])

useEffect(() => {
Expand Down Expand Up @@ -1329,6 +1340,7 @@ export default function Provider<Data extends JsonObject>(
id,
data: internalDataRef.current,
internalDataRef,
isEmptyDataRef,
props,
...rest,
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import EditContainer, { CancelButton, DoneButton } from '../EditContainer'
import IterateArray, { ContainerMode } from '../Array'
import OpenButton from './OpenButton'
import { Flex, HeightAnimation } from '../../../../components'
import { Path } from '../../types'
import { OnCommit, Path } from '../../types'
import { SpacingProps } from '../../../../shared/types'
import { useArrayLimit, useSwitchContainerMode } from '../hooks'
import Toolbar from '../Toolbar'
Expand Down Expand Up @@ -39,20 +39,30 @@ export type Props = {
showOpenButtonWhen?: (list: unknown[]) => boolean

/**
* Prefilled data to add to the fields.
* Prefilled data to add to the fields. The data will be put into this path: "/pushContainerItems/0".
*/
data?: unknown | Record<string, unknown>

/**
* Prefilled data to add to the fields.
* Prefilled data to add to the fields. The data will be put into this path: "/pushContainerItems/0".
*/
defaultData?: unknown | Record<string, unknown>

/**
* Provide additional data that will be put into the root of the isolated data context (parallel to "/pushContainerItems/0").
*/
isolatedData?: Record<string, unknown>

/**
* A custom toolbar to be shown below the container.
*/
toolbar?: React.ReactNode

/**
* Will be called when the user clicks on the "Done" button.
*/
onCommit?: OnCommit

/**
* The container contents.
*/
Expand All @@ -65,11 +75,13 @@ function PushContainer(props: AllProps) {
const {
data: dataProp,
defaultData: defaultDataProp,
isolatedData,
path,
title,
children,
openButton,
showOpenButtonWhen,
onCommit,
...rest
} = props

Expand Down Expand Up @@ -101,23 +113,44 @@ function PushContainer(props: AllProps) {
if (defaultDataProp) {
return // don't return a fallback, because we want to use the defaultData
}
return { newItems: [dataProp ?? clearedData] }
}, [dataProp, defaultDataProp])
return {
...isolatedData,
pushContainerItems: [dataProp ?? clearedData],
}
}, [dataProp, defaultDataProp, isolatedData])

const defaultData = useMemo(() => {
return { newItems: [defaultDataProp ?? clearedData] }
}, [defaultDataProp])
return {
...(!dataProp ? isolatedData : null),
pushContainerItems: [defaultDataProp ?? clearedData],
}
}, [dataProp, defaultDataProp, isolatedData])

const emptyData = useCallback(
(data: { pushContainerItems: unknown[] }) => {
const firstItem = data.pushContainerItems?.[0]
if (firstItem === null || typeof firstItem !== 'object') {
return {
...isolatedData,
pushContainerItems: [null],
}
}
return defaultData
},
[defaultData, isolatedData]
)

return (
<Isolation
data={data}
defaultData={defaultData}
emptyData={defaultData}
emptyData={emptyData}
commitHandleRef={commitHandleRef}
transformOnCommit={({ newItems }) => {
return moveValueToPath(path, [...entries, ...newItems])
transformOnCommit={({ pushContainerItems }) => {
return moveValueToPath(path, [...entries, ...pushContainerItems])
}}
onCommit={(data, { clearData, preventCommit }) => {
onCommit={(data, options) => {
const { clearData, preventCommit } = options
if (hasReachedLimit) {
preventCommit()
setShowStatus(true)
Expand All @@ -126,11 +159,12 @@ function PushContainer(props: AllProps) {
switchContainerModeRef.current?.('view')
clearData()
}
onCommit?.(data, options)
}}
>
<PushContainerContext.Provider value={newItemContextProps}>
<IterateArray
path="/newItems"
path="/pushContainerItems"
containerMode={showOpenButton ? 'view' : 'edit'}
>
<NewContainer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PropertiesTableProps } from '../../../../shared/types'
import { IsolationEvents } from '../../Form/Isolation/IsolationDocs'

export const PushContainerProperties: PropertiesTableProps = {
path: {
Expand All @@ -12,15 +13,20 @@ export const PushContainerProperties: PropertiesTableProps = {
status: 'optional',
},
data: {
doc: 'Prefilled data to be used by fields. Use `defaultData` when possible.',
doc: 'Prefilled data to be used by fields. The data will be put into this path: `/pushContainerItems/0`. Use `defaultData` when possible.',
type: ['object', 'array'],
status: 'optional',
},
defaultData: {
doc: 'Prefilled data to be used by fields.',
doc: 'Prefilled data to be used by fields. The data will be put into this path: `/pushContainerItems/0`',
type: ['object', 'array'],
status: 'optional',
},
isolatedData: {
doc: 'Provide additional data that will be put into the root of the isolated data context (parallel to `/pushContainerItems/0`).',
type: 'object',
status: 'optional',
},
openButton: {
doc: 'The button to open container.',
type: 'React.Node',
Expand Down Expand Up @@ -53,4 +59,6 @@ export const PushContainerProperties: PropertiesTableProps = {
},
}

export const PushContainerEvents: PropertiesTableProps = {}
export const PushContainerEvents: PropertiesTableProps = {
onCommit: IsolationEvents.onCommit,
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React from 'react'
import React, { useContext } from 'react'
import { render, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Field, Form, Iterate } from '../../..'
import nbNO from '../../../constants/locales/nb-NO'
import { Div } from '../../../../../elements'
import DataContext from '../../../DataContext/Context'

import nbNO from '../../../constants/locales/nb-NO'
const nb = nbNO['nb-NO']

describe('PushContainer', () => {
Expand Down Expand Up @@ -548,7 +549,7 @@ describe('PushContainer', () => {
)
})

it('should keep the defaultValue after clearing', async () => {
it('should not show error message after clearing', async () => {
const onChange = jest.fn()

render(
Expand Down Expand Up @@ -643,6 +644,68 @@ describe('PushContainer', () => {
expect(document.querySelector('.dnb-form-status')).toBeNull()
})
})

it('should keep the defaultValue after clearing', async () => {
const onChange = jest.fn()
const onCommit = jest.fn()

let internalContext = null
const CollectInternalData = () => {
internalContext = useContext(DataContext)
return null
}

render(
<Form.Handler onChange={onChange}>
<Iterate.PushContainer path="/" onCommit={onCommit}>
<Field.String itemPath="/" defaultValue="default value" />
<CollectInternalData />
</Iterate.PushContainer>
</Form.Handler>
)

expect(internalContext).toMatchObject({
data: {
pushContainerItems: ['default value'],
},
})

const input = document.querySelector('input')

await userEvent.type(input, ' changed')

const button = document.querySelector('button')

await userEvent.click(button)
expect(internalContext.internalDataRef.current).toEqual({
pushContainerItems: ['default value'],
})
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenLastCalledWith(
['default value changed'],
expect.anything()
)
expect(onCommit).toHaveBeenCalledTimes(1)
expect(onCommit).toHaveBeenLastCalledWith(
['default value changed'],
expect.anything()
)

await userEvent.click(button)
expect(internalContext.internalDataRef.current).toEqual({
pushContainerItems: ['default value'],
})
expect(onChange).toHaveBeenCalledTimes(2)
expect(onChange).toHaveBeenLastCalledWith(
['default value changed', 'default value'],
expect.anything()
)
expect(onCommit).toHaveBeenCalledTimes(2)
expect(onCommit).toHaveBeenLastCalledWith(
['default value changed', 'default value'],
expect.anything()
)
})
})

it('should support initial data as a string', async () => {
Expand Down
Loading

0 comments on commit f88d702

Please sign in to comment.