Skip to content

Commit

Permalink
feat(zui): Add formData middlewares, duplicate state because (#338)
Browse files Browse the repository at this point in the history
  • Loading branch information
charlescatta authored Jun 26, 2024
1 parent ccb017a commit 7308095
Show file tree
Hide file tree
Showing 7 changed files with 108 additions and 35 deletions.
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.8.10",
"version": "0.8.11",
"description": "An extension of Zod for working nicely with UIs and JSON Schemas",
"type": "module",
"source": "./src/index.ts",
Expand Down
2 changes: 1 addition & 1 deletion zui/src/ui/ElementRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FC, useMemo } from 'react'
import React, { FC, useMemo } from 'react'
import {
ArraySchema,
BaseType,
Expand Down
18 changes: 14 additions & 4 deletions zui/src/ui/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React, { useState } from 'react'
import { useEffect } from 'react'
import { BoundaryFallbackComponent, ErrorBoundary } from './ErrorBoundary'
import { FormDataProvider, deepMerge, getDefaultValues } from './hooks/useFormData'
Expand All @@ -11,6 +12,7 @@ export type ZuiFormProps<UI extends UIComponentDefinitions = DefaultComponentDef
onChange: (value: any) => void
disableValidation?: boolean
fallback?: BoundaryFallbackComponent
dataTransform?: (data: any) => any
}

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

useEffect(() => {
onChange(formData)
}, [formData])

useEffect(() => {
const defaults = getDefaultValues(schema)
onChange(deepMerge(defaults, value))
}, [JSON.stringify(schema)])
setFormData((prev) => deepMerge(defaults, prev))
}, [JSON.stringify(schema), setFormData])

return (
<FormDataProvider
formData={value}
setFormData={onChange}
formData={formData}
setFormData={setFormData}
formSchema={schema}
disableValidation={disableValidation || false}
dataTransform={dataTransform}
>
<ErrorBoundary fallback={fallback} fieldSchema={schema} path={[]}>
<FormElementRenderer
Expand Down
File renamed without changes.
111 changes: 85 additions & 26 deletions zui/src/ui/hooks/useFormData.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,57 @@
import { PropsWithChildren, createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import React from 'react'
import React, { PropsWithChildren, createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { ArraySchema, JSONSchema } from '../types'
import { jsonSchemaToZui } from '../../transforms/json-schema-to-zui'
import { zuiKey } from '../constants'
import { Maskable } from '../../z'
import { Maskable, ZodIssue } from '../../z'

export type FormDataContextProps = {
/**
* The current form data
* */
formData: any
formSchema: JSONSchema | any
/**
* Function to update the form data, takes a callback that receives the form data with the new values
* */
setFormData: (callback: (formData: any) => void) => void
setHiddenState: (callback: (hiddenState: any) => void) => void
setDisabledState: (callback: (disabledState: any) => void) => void

/**
* hiddenState is an object that contains the hidden state of the form fields
*/
hiddenState: object
/**
* Function to update the hidden state, takes a callback that receives the hidden state with the new values
* */
setHiddenState: (callback: (hiddenState: any) => void) => void
/**
* disabledState is an object that contains the disabled state of the form fields
*/
disabledState: object
disableValidation: boolean
/**
* Function to update the disabled state, takes a callback that receives the disabled state with the new values
* */
setDisabledState: (callback: (disabledState: any) => void) => void

/**
* Validation state of the form
*/
validation: {
formValid: boolean | null
formErrors: ZodIssue[] | null
}

disableValidation?: boolean

/**
* Function to transform the form data before validation and computation of hidden/disabled states
* useful for cases where the underlying form data does not match the schema
*/
dataTransform?: (formData: any) => any
}

export type FormDataProviderProps = Omit<
FormDataContextProps,
'setHiddenState' | 'setDisabledState' | 'hiddenState' | 'disabledState'
'setHiddenState' | 'setDisabledState' | 'hiddenState' | 'disabledState' | 'validation'
>

export const FormDataContext = createContext<FormDataContextProps>({
Expand All @@ -34,7 +68,7 @@ export const FormDataContext = createContext<FormDataContextProps>({
},
hiddenState: {},
disabledState: {},
disableValidation: false,
validation: { formValid: null, formErrors: null },
})

const parseMaskableField = (key: 'hidden' | 'disabled', fieldSchema: JSONSchema, data: any): Maskable => {
Expand Down Expand Up @@ -75,30 +109,28 @@ export const useFormData = (fieldSchema: JSONSchema, path: string[]) => {
const data = useMemo(() => getPathData(formContext.formData, path), [formContext.formData, path])

const validation = useMemo(() => {
if (formContext.disableValidation) {
return { formValid: null, formErrors: null }
}

if (!formContext.formSchema) {
if (formContext.validation.formValid === null) {
return { formValid: null, formErrors: null }
}

const validation = jsonSchemaToZui(formContext.formSchema).safeParse(formContext.formData)

if (!validation.success) {
if (formContext.validation.formValid === false) {
return {
formValid: false,
formErrors: validation.error.issues,
formErrors: formContext.validation.formErrors?.filter((issue) => issue.path === path) || null,
}
}
return {
formValid: true,
formErrors: [],
}
}, [formContext.formData])
return { formValid: true, formErrors: [] }
}, [formContext.validation.formValid, formContext.validation.formErrors, path])

const transformedData = formContext.dataTransform ? formContext.dataTransform(data) : data

const hiddenMask = useMemo(() => parseMaskableField('hidden', fieldSchema, data), [fieldSchema, data])
const disabledMask = useMemo(() => parseMaskableField('disabled', fieldSchema, data), [fieldSchema, data])
const hiddenMask = useMemo(
() => parseMaskableField('hidden', fieldSchema, transformedData),
[fieldSchema, transformedData],
)
const disabledMask = useMemo(
() => parseMaskableField('disabled', fieldSchema, transformedData),
[fieldSchema, transformedData],
)

useEffect(() => {
formContext.setHiddenState((hiddenState) => setObjectPath(hiddenState, path, hiddenMask || {}))
Expand Down Expand Up @@ -240,21 +272,48 @@ export const FormDataProvider: React.FC<PropsWithChildren<FormDataProviderProps>
formData,
formSchema,
disableValidation,
dataTransform,
}) => {
const [hiddenState, setHiddenState] = useState({})
const [disabledState, setDisabledState] = useState({})

const transformedData = dataTransform ? dataTransform(formData) : formData

const validation = useMemo(() => {
if (disableValidation) {
return { formValid: null, formErrors: null }
}

if (!formSchema) {
return { formValid: null, formErrors: null }
}

const validation = jsonSchemaToZui(formSchema).safeParse(transformedData)

if (!validation.success) {
return {
formValid: false,
formErrors: validation.error.issues,
}
}
return {
formValid: true,
formErrors: [],
}
}, [JSON.stringify({ transformedData })])

return (
<FormDataContext.Provider
value={{
formData,
setFormData,
formSchema,
disableValidation,
validation,
hiddenState,
setHiddenState,
disabledState,
setDisabledState,
dataTransform,
}}
>
{children}
Expand Down
File renamed without changes.
10 changes: 7 additions & 3 deletions zui/src/ui/ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -335,13 +335,16 @@ describe('UI', () => {
/>,
)

// check initial value
expect(onChangeMock).toHaveBeenCalledWith({ students: [{ name: 'John', age: 20 }] })

const input = rendered.getByTestId('string:students.0.name:input')
fireEvent.change(input, { target: { value: 'Jane' } })

expect(onChangeMock).toHaveBeenCalledTimes(2)
expect(onChangeMock).toHaveBeenCalledTimes(3) // 1 for initial value, 2 for change

// check initial value
expect(onChangeMock).toHaveBeenCalledWith({ students: [{ name: 'John', age: 20 }] })
// check value after change
expect(onChangeMock).toHaveBeenCalledWith({ students: [{ name: 'Jane', age: 20 }] })
})

it('it renders custom zui components with correct params as input', () => {
Expand Down Expand Up @@ -718,6 +721,7 @@ describe('utils', () => {
})
})
})

export const testComponentDefinitions = {
string: {
customstringcomponent: {
Expand Down

0 comments on commit 7308095

Please sign in to comment.