Skip to content

Commit

Permalink
feat(zui): Add onValidation callback to Form props (#352)
Browse files Browse the repository at this point in the history
  • Loading branch information
charlescatta authored Jul 9, 2024
1 parent f5656d5 commit 5c5edb9
Show file tree
Hide file tree
Showing 7 changed files with 165 additions and 20 deletions.
68 changes: 60 additions & 8 deletions zui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,21 +300,73 @@ z.never()

Zui extends Zod by adding additional methods for customizing the UI of your schema

### .title()
### `.title(text: string)`

### .placeholder()
Saves the title to display in the UI, if not specified, a title will be generated from the key

### .displayAs()
### `.placeholder(text: string)`

### .hidden()
Saves the placeholder to display in the UI's field, if not specified, no placeholder will be displayed.

### .disabled()

### .toJsonSchema()
### `.displayAs<ComponentDefinition>({ id: string, params: object })`

### .toTypescriptTypings()
Specifies the component to use for displaying the field, if not specified, the default component will be used.
The type of `params` comes from the component definition.

You must polyfill `process` to call this function in the browser

### `.hidden(condition?: boolean | (currentValue) => boolean | object)`

Hides/shows the component, the condition is optional, if `.hidden()` is called without a condition, the component will be hidden by default.
It can also be a function that receives the current value of the field and returns a boolean.
In the case of objects and arrays, a partial object can be passed to hide/show specific fields.
example:

```ts
z.object({
name: z.string()
age: z.number()
}).hidden(formData => {
return {
age: formData.name?.length < 1 // the age field will be hidden if the name field is empty
}
})
```

### .disabled(condition?: boolean | (currentValue) => boolean)

Disables/enables the component, the condition is optional, if `.disabled()` is called without a condition, the component will be disabled by default.
It can also be a function that receives the current value of the field and returns a boolean.
In the case of objects and arrays, a partial object can be passed to hide/show specific fields.
example:

```ts
z.object({
name: z.string()
age: z.number()
}).hidden(formData => {
return {
age: formData.name?.length < 1 // the age field will be hidden if the name field is empty
}
})
```

### .toJsonSchema(options?: ToJsonSchemaOptions)

Converts the schema to a JSON schema, by default it targets 'openApi3'

options can be passed to customize the output:
```ts
{
target: "openApi3" | "jsonSchema7" | undefined, // defaults to openApi3
$schemaUrl: string | false | undefined // if not false, will default to the appropriate schema url for the target
unionStrategy: "oneOf" | "anyOf" | undefined // defaults to anyOf
}
```

### .toTypescript()

## .toTypescriptAsync()

### Zod.fromJsonSchema()

Expand Down
2 changes: 1 addition & 1 deletion zui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bpinternal/zui",
"version": "0.9.2",
"version": "0.9.3",
"description": "An extension of Zod for working nicely with UIs and JSON Schemas",
"type": "module",
"source": "./src/index.ts",
Expand Down
2 changes: 2 additions & 0 deletions zui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type {
JSONSchema,
JSONSchemaOfType,
MergeUIComponentDefinitions,
FormValidation,
FormError,
} from './ui/types'
export type { BoundaryFallbackComponent } from './ui/ErrorBoundary'
export { ZuiForm, type ZuiFormProps } from './ui'
Expand Down
14 changes: 11 additions & 3 deletions zui/src/ui/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import React, { useState } from 'react'
import { useEffect } from 'react'
import React, { useState, useEffect } from 'react'
import { BoundaryFallbackComponent, ErrorBoundary } from './ErrorBoundary'
import { FormDataProvider, deepMerge, getDefaultValues } from './hooks/useFormData'
import { DefaultComponentDefinitions, JSONSchema, UIComponentDefinitions, ZuiComponentMap } from './types'
import {
FormValidation,
DefaultComponentDefinitions,
JSONSchema,
UIComponentDefinitions,
ZuiComponentMap,
} from './types'
import { FormElementRenderer } from './ElementRenderer'

export type ZuiFormProps<UI extends UIComponentDefinitions = DefaultComponentDefinitions> = {
Expand All @@ -13,6 +18,7 @@ export type ZuiFormProps<UI extends UIComponentDefinitions = DefaultComponentDef
disableValidation?: boolean
fallback?: BoundaryFallbackComponent
dataTransform?: (data: any) => any
onValidation?: (validation: FormValidation) => void
}

export const ZuiForm = <UI extends UIComponentDefinitions = DefaultComponentDefinitions>({
Expand All @@ -23,6 +29,7 @@ export const ZuiForm = <UI extends UIComponentDefinitions = DefaultComponentDefi
disableValidation,
fallback,
dataTransform,
onValidation,
}: ZuiFormProps<UI>): JSX.Element | null => {
const [formData, setFormData] = useState<object>(value)

Expand All @@ -42,6 +49,7 @@ export const ZuiForm = <UI extends UIComponentDefinitions = DefaultComponentDefi
formSchema={schema}
disableValidation={disableValidation || false}
dataTransform={dataTransform}
onValidation={onValidation}
>
<ErrorBoundary fallback={fallback} fieldSchema={schema} path={[]}>
<FormElementRenderer
Expand Down
22 changes: 14 additions & 8 deletions zui/src/ui/hooks/useFormData.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React, { PropsWithChildren, createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { ArraySchema, FormError, JSONSchema, Path } from '../types'
import { ArraySchema, FormError, FormValidation, JSONSchema, Path } from '../types'
import { jsonSchemaToZui } from '../../transforms/json-schema-to-zui'
import { zuiKey } from '../constants'
import { Maskable, ZodIssue } from '../../z'
import { Maskable } from '../../z'
import { pathMatches } from '../utils'

export type FormDataContextProps = {
Expand Down Expand Up @@ -36,10 +36,7 @@ export type FormDataContextProps = {
/**
* Validation state of the form
*/
validation: {
formValid: boolean | null
formErrors: ZodIssue[] | null
}
validation: FormValidation

disableValidation?: boolean

Expand All @@ -48,6 +45,8 @@ export type FormDataContextProps = {
* useful for cases where the underlying form data does not match the schema
*/
dataTransform?: (formData: any) => any

onValidation?: (validation: FormValidation) => void
}

export type FormDataProviderProps = Omit<
Expand Down Expand Up @@ -109,7 +108,7 @@ export const useFormData = (fieldSchema: JSONSchema, path: Path) => {

const data = useMemo(() => getPathData(formContext.formData, path), [formContext.formData, path])

const validation = useMemo(() => {
const validation: FormValidation = useMemo(() => {
if (formContext.validation.formValid === null) {
return { formValid: null, formErrors: null }
}
Expand Down Expand Up @@ -280,14 +279,15 @@ export const FormDataProvider: React.FC<PropsWithChildren<FormDataProviderProps>
formData,
formSchema,
disableValidation,
onValidation,
dataTransform,
}) => {
const [hiddenState, setHiddenState] = useState({})
const [disabledState, setDisabledState] = useState({})

const transformedData = dataTransform ? dataTransform(formData) : formData

const validation = useMemo(() => {
const validation: FormValidation = useMemo(() => {
if (disableValidation) {
return { formValid: null, formErrors: null }
}
Expand All @@ -310,6 +310,12 @@ export const FormDataProvider: React.FC<PropsWithChildren<FormDataProviderProps>
}
}, [JSON.stringify({ transformedData })])

useEffect(() => {
if (onValidation) {
onValidation(validation)
}
}, [validation])

return (
<FormDataContext.Provider
value={{
Expand Down
14 changes: 14 additions & 0 deletions zui/src/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,3 +333,17 @@ export type MergeUIComponentDefinitions<T extends UIComponentDefinitions, U exte

export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T
export type Path = (string | number)[]

export type FormValidation =
| {
formValid: false
formErrors: FormError[]
}
| {
formValid: true
formErrors: []
}
| {
formValid: null
formErrors: null
}
63 changes: 63 additions & 0 deletions zui/src/ui/ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,69 @@ describe('UI', () => {
expect(onChangeMock).toHaveBeenCalledWith({ students: [{ name: 'Jane', age: 20 }] })
})

it('calls onValidation with form validation', () => {
const schema = zui.object({
name: zui.string().max(3),
age: zui.number().min(8),
})

const spy = vi.fn()

const rendered = render(
<ZuiFormWithState
schema={schema.toJsonSchema() as ObjectSchema}
components={testComponentImplementation}
onValidation={spy}
/>,
)

const nameInput = rendered.getByTestId('string:name:input') as HTMLInputElement
const ageInput = rendered.getByTestId('number:age:input') as HTMLInputElement

fireEvent.change(nameInput, { target: { value: 'Joh' } })
expect(spy.mock.calls.every((call) => call.formValid === false)).toStrictEqual(false)
expect(spy.mock.lastCall[0].formErrors).toHaveLength(1)

fireEvent.change(ageInput, { target: { value: '5' } })

expect(spy.mock.calls.every((call) => call.formValid === false)).toStrictEqual(false)
expect(spy.mock.lastCall[0].formErrors).toHaveLength(1)

fireEvent.change(ageInput, { target: { value: '10' } })

expect(spy.mock.lastCall[0].formValid).toStrictEqual(true)
expect(spy.mock.lastCall[0].formErrors).toHaveLength(0)
})

it('returns null formValidation when disableValidation is true', () => {
const schema = zui.object({
name: zui.string().max(3),
age: zui.number().min(8),
})

const spy = vi.fn()

const rendered = render(
<ZuiFormWithState
schema={schema.toJsonSchema() as ObjectSchema}
components={testComponentImplementation}
onValidation={spy}
disableValidation
/>,
)

const nameInput = rendered.getByTestId('string:name:input') as HTMLInputElement
const ageInput = rendered.getByTestId('number:age:input') as HTMLInputElement

fireEvent.change(nameInput, { target: { value: 'John' } })
fireEvent.change(ageInput, { target: { value: '5' } })

spy.mock.calls.forEach((call) => {
expect(call[0].formValid).toBeNull()
expect(call[0].formErrors).toBeNull()
})
})

it('it renders custom zui components with correct params as input', () => {
const schema = zui.object({
firstName: zui.string(),
Expand Down

0 comments on commit 5c5edb9

Please sign in to comment.