Skip to content

Commit

Permalink
feat(Forms): add delegateValidation to Form.Isolation and Iterate.P…
Browse files Browse the repository at this point in the history
…ushContainer to prevent the form from being submitted when there are fields with errors
  • Loading branch information
tujoworker committed Oct 9, 2024
1 parent 248da92 commit 834e0c2
Show file tree
Hide file tree
Showing 10 changed files with 201 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,23 @@ function MyForm() {
render(<MyForm />)
```

## Prevent the form from being submitted

To prevent the [Form.Handler](/uilib/extensions/forms/Form/Handler/) from being submitted when there are fields with errors inside the Isolation, you can use the `delegateValidation` property.

```tsx
import { Form, Field } from '@dnb/eufemia/extensions/forms'

render(
<Form.Handler>
<Form.Isolation delegateValidation>
<Field.String label="Required field" path="/isolated" required />
<Form.Isolation.CommitButton />
</Form.Isolation>
</Form.Handler>,
)
```

## Schema support

You can also use a `schema` to define the properties of the nested fields:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,24 @@ render(
)
```

## Prevent the form from being submitted

To prevent the [Form.Handler](/uilib/extensions/forms/Form/Handler/) from being submitted when there are fields with errors inside the PushContainer, you can use the `delegateValidation` property.

```tsx
import { Form, Field, Iterate } from '@dnb/eufemia/extensions/forms'

render(
<Form.Handler>
<Iterate.Array path="/myList">...</Iterate.Array>

<Iterate.PushContainer path="/myList" delegateValidation>
<Field.Name.Last itemPath="/name" required />
</Iterate.PushContainer>
</Form.Handler>,
)
```

## 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
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export interface ContextState {
fieldConnectionsRef?: React.RefObject<Record<Path, FieldConnections>>
mountedFieldsRef?: React.MutableRefObject<Record<Path, MountState>>
formElementRef?: React.MutableRefObject<HTMLFormElement>
fieldErrorRef?: React.MutableRefObject<Record<Path, Error>>
showAllErrors: boolean
hasVisibleError: boolean
formState: SubmitState
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1334,13 +1334,14 @@ export default function Provider<Data extends JsonObject>(
valuePropsRef,
mountedFieldsRef,
formElementRef,
isEmptyDataRef,
fieldErrorRef,
ajvInstance: ajvRef.current,

/** Additional */
id,
data: internalDataRef.current,
internalDataRef,
isEmptyDataRef,
props,
...rest,
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ import React, {
import pointer, { JsonObject } from '../../utils/json-pointer'
import { extendDeep } from '../../../../shared/component-helper'
import { isAsync } from '../../../../shared/helpers/isAsync'
import useId from '../../../../shared/helpers/useId'
import useDataValue from '../../hooks/useDataValue'
import { Context, ContextState, Provider } from '../../DataContext'
import {
Context as DataContext,
ContextState,
Provider,
} from '../../DataContext'
import SectionContext from '../Section/SectionContext'
import IsolationCommitButton from './IsolationCommitButton'
import {
Expand Down Expand Up @@ -43,6 +48,10 @@ export type IsolationProviderProps<Data> = {
isolatedData: JsonObject,
handlerData: JsonObject
) => unknown
/**
* Propagate errors from the fields to the parent form.
*/
delegateValidation?: boolean
/**
* Used internally by the Form.Isolation component
*/
Expand Down Expand Up @@ -80,6 +89,7 @@ function IsolationProvider<Data extends JsonObject>(
onClear: onClearProp,
transformOnCommit: transformOnCommitProp,
commitHandleRef,
delegateValidation,
data,
defaultData,
} = props
Expand All @@ -88,12 +98,25 @@ function IsolationProvider<Data extends JsonObject>(
const internalDataRef = useRef<Data>()
const localDataRef = useRef<Partial<Data>>({})
const dataContextRef = useRef<ContextState>(null)
const outerContext = useContext(Context)
const outerContext = useContext(DataContext)
const { path: pathSection } = useContext(SectionContext) || {}
const { handlePathChange: handlePathChangeOuter, data: dataOuter } =
outerContext || {}
const { moveValueToPath } = useDataValue()

// const id = useId()
// useEffect(() => {
// if (!delegateValidation) {
// return
// }

// const path = `/${id}`
// outerContext.setMountedFieldState(path, {
// isMounted: true,
// })
// outerContext.setFieldError(path, new Error('Form.Isolation'))
// }, [delegateValidation, id, outerContext])

const onPathChangeHandler = useCallback(
async (path: Path, value: unknown) => {
if (localDataRef.current === clearedData) {
Expand Down Expand Up @@ -225,7 +248,7 @@ function IsolationProvider<Data extends JsonObject>(

return (
<Provider {...providerProps}>
<Context.Consumer>
<DataContext.Consumer>
{(dataContext) => {
dataContextRef.current = dataContext

Expand All @@ -235,11 +258,34 @@ function IsolationProvider<Data extends JsonObject>(

return children
}}
</Context.Consumer>
</DataContext.Consumer>

{delegateValidation && (
<DelegateValidation outerContext={outerContext} />
)}
</Provider>
)
}

function DelegateValidation({ outerContext }) {
const { setMountedFieldState, setFieldError } = outerContext || {}
const dataContext = useContext(DataContext)

const id = useId()
useEffect(() => {
const path = `/${id}`
const errors = dataContext.hasErrors()
if (errors) {
setMountedFieldState?.(path, {
isMounted: true,
})
}
setFieldError?.(path, errors ? new Error('Form.Isolation') : undefined)
}, [dataContext, id, setFieldError, setMountedFieldState])

return null
}

IsolationProvider.CommitButton = IsolationCommitButton
IsolationProvider._supportsSpacingProps = undefined

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export const IsolationProperties: PropertiesTableProps = {
type: 'React.Ref',
status: 'optional',
},
delegateValidation: {
doc: 'Prevent the form from being submitted when there are fields with errors inside the Form.Isolation.',
type: 'boolean',
status: 'optional',
},
...ProviderProperties,
minimumAsyncBehaviorTime: undefined,
asyncSubmitTimeout: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1643,4 +1643,49 @@ describe('Form.Isolation', () => {
expect(synced).toHaveValue('inside changed')
expect(regular).toHaveValue('regular')
})

describe('delegateValidation', () => {
it('should prevent the form from submitting as long as there are errors', async () => {
const onSubmitRequest = jest.fn()
const onSubmit = jest.fn()
const onCommit = jest.fn()

render(
<Form.Handler
onSubmitRequest={onSubmitRequest}
onSubmit={onSubmit}
>
<Form.Isolation onCommit={onCommit} delegateValidation>
<Field.String label="Isolated" path="/isolated" required />
<Form.Isolation.CommitButton />
</Form.Isolation>
</Form.Handler>
)

const input = document.querySelector('input')
const form = document.querySelector('form')
const commitButton = document.querySelector('button')

await userEvent.click(commitButton)
fireEvent.submit(form)

expect(document.querySelector('.dnb-form-status')).toHaveTextContent(
nb.Field.errorRequired
)

expect(onSubmit).toHaveBeenCalledTimes(0)
expect(onSubmitRequest).toHaveBeenCalledTimes(1)
expect(onCommit).toHaveBeenCalledTimes(0)

await userEvent.type(input, 'Tony')
fireEvent.submit(form)

expect(onSubmit).toHaveBeenCalledTimes(1)
expect(onSubmitRequest).toHaveBeenCalledTimes(1)
expect(onCommit).toHaveBeenCalledTimes(0)

await userEvent.click(commitButton)
expect(onCommit).toHaveBeenCalledTimes(1)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export type Props = {
*/
isolatedData?: Record<string, unknown>

/**
* Propagate errors from the fields to the parent form.
*/
delegateValidation?: boolean

/**
* A custom toolbar to be shown below the container.
*/
Expand All @@ -76,6 +81,7 @@ function PushContainer(props: AllProps) {
data: dataProp,
defaultData: defaultDataProp,
isolatedData,
delegateValidation,
path,
title,
children,
Expand Down Expand Up @@ -145,6 +151,7 @@ function PushContainer(props: AllProps) {
data={data}
defaultData={defaultData}
emptyData={emptyData}
delegateValidation={delegateValidation}
commitHandleRef={commitHandleRef}
transformOnCommit={({ pushContainerItems }) => {
return moveValueToPath(path, [...entries, ...pushContainerItems])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export const PushContainerProperties: PropertiesTableProps = {
type: 'object',
status: 'optional',
},
delegateValidation: {
doc: 'Propagate errors from the fields to the parent form.',
type: 'boolean',
status: 'optional',
},
openButton: {
doc: 'The button to open container.',
type: 'React.Node',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useContext } from 'react'
import { render, waitFor } from '@testing-library/react'
import { fireEvent, render, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Field, Form, Iterate } from '../../..'
import { Div } from '../../../../../elements'
Expand Down Expand Up @@ -44,6 +44,56 @@ describe('PushContainer', () => {
)
})

describe('delegateValidation', () => {
it('should prevent the form from submitting as long as there are errors', async () => {
const onSubmitRequest = jest.fn()
const onSubmit = jest.fn()
const onCommit = jest.fn()

render(
<Form.Handler
onSubmitRequest={onSubmitRequest}
onSubmit={onSubmit}
>
<Iterate.Array path="/entries">...</Iterate.Array>

<Iterate.PushContainer
path="/entries"
delegateValidation
onCommit={onCommit}
>
<Field.String itemPath="/name" required />
</Iterate.PushContainer>
</Form.Handler>
)

const input = document.querySelector('input')
const form = document.querySelector('form')
const commitButton = document.querySelector('button')

await userEvent.click(commitButton)
fireEvent.submit(form)

expect(document.querySelector('.dnb-form-status')).toHaveTextContent(
nb.Field.errorRequired
)

expect(onSubmit).toHaveBeenCalledTimes(0)
expect(onSubmitRequest).toHaveBeenCalledTimes(1)
expect(onCommit).toHaveBeenCalledTimes(0)

await userEvent.type(input, 'Tony')
fireEvent.submit(form)

expect(onSubmit).toHaveBeenCalledTimes(1)
expect(onSubmitRequest).toHaveBeenCalledTimes(1)
expect(onCommit).toHaveBeenCalledTimes(0)

await userEvent.click(commitButton)
expect(onCommit).toHaveBeenCalledTimes(1)
})
})

it('should show view container after adding a new entry', async () => {
render(
<Form.Handler>
Expand Down

0 comments on commit 834e0c2

Please sign in to comment.