From ccb017abcb3ae9032c514563416056edaed8865e Mon Sep 17 00:00:00 2001 From: Charles Catta Date: Tue, 25 Jun 2024 10:10:42 -0400 Subject: [PATCH] fix(zui): Fix state management updating setter functions (#337) --- zui/package.json | 2 +- zui/src/ui/ElementRenderer.tsx | 208 ++++++++++++++++ zui/src/ui/Form.tsx | 48 ++++ zui/src/ui/hooks/useFormData.tsx | 77 +++--- zui/src/ui/index.tsx | 316 +------------------------ zui/src/ui/ui.test.tsx | 10 +- zui/src/ui/{titleutils.ts => utils.ts} | 64 +++++ 7 files changed, 371 insertions(+), 354 deletions(-) create mode 100644 zui/src/ui/ElementRenderer.tsx create mode 100644 zui/src/ui/Form.tsx rename zui/src/ui/{titleutils.ts => utils.ts} (69%) diff --git a/zui/package.json b/zui/package.json index c2fa8943..7bc05a50 100644 --- a/zui/package.json +++ b/zui/package.json @@ -1,6 +1,6 @@ { "name": "@bpinternal/zui", - "version": "0.8.9", + "version": "0.8.10", "description": "An extension of Zod for working nicely with UIs and JSON Schemas", "type": "module", "source": "./src/index.ts", diff --git a/zui/src/ui/ElementRenderer.tsx b/zui/src/ui/ElementRenderer.tsx new file mode 100644 index 00000000..cb219d7e --- /dev/null +++ b/zui/src/ui/ElementRenderer.tsx @@ -0,0 +1,208 @@ +import { FC, useMemo } from 'react' +import { + ArraySchema, + BaseType, + JSONSchema, + ObjectSchema, + PrimitiveSchema, + ZuiComponentMap, + ZuiReactArrayChildProps, + ZuiReactComponent, + ZuiReactComponentBaseProps, + ZuiReactComponentProps, + ZuiReactControlComponentProps, +} from './types' +import { BoundaryFallbackComponent, ErrorBoundary } from './ErrorBoundary' +import { getPathData, useFormData } from './hooks/useFormData' +import { formatTitle, resolveComponent } from './utils' +import { useDiscriminator } from './hooks/useDiscriminator' +import { zuiKey } from './constants' + +type FormRendererProps = { + components: ZuiComponentMap + fieldSchema: JSONSchema + path: string[] + required: boolean + fallback?: BoundaryFallbackComponent +} & ZuiReactArrayChildProps + +export const FormElementRenderer: FC = ({ + components, + fieldSchema, + path, + required, + fallback, + ...childProps +}) => { + const { formData, disabled, hidden, handlePropertyChange, addArrayItem, removeArrayItem, formErrors, formValid } = + useFormData(fieldSchema, path) + const data = useMemo(() => getPathData(formData, path), [formData, path]) + const componentMeta = useMemo(() => resolveComponent(components, fieldSchema), [fieldSchema, components]) + const { discriminator, discriminatedSchema, discriminatorValue } = useDiscriminator(fieldSchema, path) + + if (!componentMeta) { + return null + } + + if (hidden === true) { + return null + } + + const { Component: _component, type } = componentMeta + + const baseProps: Omit, 'data' | 'isArrayChild'> = { + type, + componentID: componentMeta.id, + scope: path.join('.'), + context: { + path, + readonly: false, + formData, + formErrors, + formValid, + updateForm: handlePropertyChange, + }, + onChange: (data: any) => handlePropertyChange(path, data), + disabled, + errors: formErrors?.filter((e) => e.path === path) || [], + label: fieldSchema[zuiKey]?.title || formatTitle(path[path.length - 1]?.toString() || ''), + params: componentMeta.params, + schema: fieldSchema, + zuiProps: fieldSchema[zuiKey], + } + + if (fieldSchema.type === 'array' && type === 'array') { + const Component = _component as any as ZuiReactComponent<'array', string, any> + const schema = baseProps.schema as ArraySchema + const dataArray = Array.isArray(data) ? data : typeof data === 'object' ? data : [] + const props: Omit, 'children'> = { + ...baseProps, + type, + schema, + data: dataArray, + addItem: (data) => addArrayItem(path, data), + removeItem: (index) => removeArrayItem(path, index), + ...childProps, + } + + // Tuple + if (Array.isArray(fieldSchema.items)) { + return null + } + + return ( + + {Array.isArray(props.data) + ? props.data.map((_, index) => { + const childPath = [...path, index.toString()] + return ( + + removeArrayItem(path, index)} + fallback={fallback} + /> + + ) + }) + : []} + + ) + } + + if (fieldSchema.type === 'object' && type === 'object' && fieldSchema.properties) { + const Component = _component as any as ZuiReactComponent<'object', string, any> + const props: Omit, 'children'> = { + ...baseProps, + type, + schema: baseProps.schema as any as ObjectSchema, + data: data || {}, + ...childProps, + } + return ( + + {Object.entries(fieldSchema.properties).map(([fieldName, childSchema]) => { + const childPath = [...path, fieldName] + return ( + + + + ) + })} + + ) + } + + if (type === 'discriminatedUnion') { + const Component = _component as any as ZuiReactComponent<'discriminatedUnion', string, any> + + const props: Omit, 'children'> = { + ...baseProps, + type, + schema: baseProps.schema as any as ObjectSchema, + data: data || {}, + discriminatorKey: discriminator?.key || null, + discriminatorLabel: formatTitle(discriminator?.key || 'Unknown'), + discriminatorOptions: discriminator?.values || null, + discriminatorValue, + setDiscriminator: (disc: string) => { + if (!discriminator?.key) { + console.warn('No discriminator key found, cannot set discriminator') + return + } + handlePropertyChange(path, { [discriminator.key]: disc }) + }, + ...childProps, + } + + return ( + + {discriminatedSchema && ( + + + + )} + + ) + } + const Component = _component as any as ZuiReactComponent + + const props: ZuiReactControlComponentProps<'boolean' | 'number' | 'string', string, any> = { + ...baseProps, + type: type as any as 'boolean' | 'number' | 'string', + schema: baseProps.schema as any as PrimitiveSchema, + config: {}, + required, + data, + description: fieldSchema.description, + ...childProps, + } + + return +} diff --git a/zui/src/ui/Form.tsx b/zui/src/ui/Form.tsx new file mode 100644 index 00000000..bb4a82cd --- /dev/null +++ b/zui/src/ui/Form.tsx @@ -0,0 +1,48 @@ +import { useEffect } from 'react' +import { BoundaryFallbackComponent, ErrorBoundary } from './ErrorBoundary' +import { FormDataProvider, deepMerge, getDefaultValues } from './hooks/useFormData' +import { DefaultComponentDefinitions, JSONSchema, UIComponentDefinitions, ZuiComponentMap } from './types' +import { FormElementRenderer } from './ElementRenderer' + +export type ZuiFormProps = { + schema: JSONSchema + components: ZuiComponentMap + value: any + onChange: (value: any) => void + disableValidation?: boolean + fallback?: BoundaryFallbackComponent +} + +export const ZuiForm = ({ + schema, + components, + onChange, + value, + disableValidation, + fallback, +}: ZuiFormProps): JSX.Element | null => { + useEffect(() => { + const defaults = getDefaultValues(schema) + onChange(deepMerge(defaults, value)) + }, [JSON.stringify(schema)]) + + return ( + + + + + + ) +} diff --git a/zui/src/ui/hooks/useFormData.tsx b/zui/src/ui/hooks/useFormData.tsx index 24959dd3..1d6540a9 100644 --- a/zui/src/ui/hooks/useFormData.tsx +++ b/zui/src/ui/hooks/useFormData.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren, createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import React from 'react' -import { JSONSchema } from '../types' +import { ArraySchema, JSONSchema } from '../types' import { jsonSchemaToZui } from '../../transforms/json-schema-to-zui' import { zuiKey } from '../constants' import { Maskable } from '../../z' @@ -8,9 +8,9 @@ import { Maskable } from '../../z' export type FormDataContextProps = { formData: any formSchema: JSONSchema | any - setFormData: (data: any) => void - setHiddenState: (data: any) => void - setDisabledState: (data: any) => void + setFormData: (callback: (formData: any) => void) => void + setHiddenState: (callback: (hiddenState: any) => void) => void + setDisabledState: (callback: (disabledState: any) => void) => void hiddenState: object disabledState: object disableValidation: boolean @@ -67,23 +67,23 @@ const parseMaskableField = (key: 'hidden' | 'disabled', fieldSchema: JSONSchema, } export const useFormData = (fieldSchema: JSONSchema, path: string[]) => { - const context = useContext(FormDataContext) - if (context === undefined) { + const formContext = useContext(FormDataContext) + if (formContext === undefined) { throw new Error('useFormData must be used within a FormDataProvider') } - const data = useMemo(() => getPathData(context.formData, path), [context.formData, path]) + const data = useMemo(() => getPathData(formContext.formData, path), [formContext.formData, path]) const validation = useMemo(() => { - if (context.disableValidation) { + if (formContext.disableValidation) { return { formValid: null, formErrors: null } } - if (!context.formSchema) { + if (!formContext.formSchema) { return { formValid: null, formErrors: null } } - const validation = jsonSchemaToZui(context.formSchema).safeParse(context.formData) + const validation = jsonSchemaToZui(formContext.formSchema).safeParse(formContext.formData) if (!validation.success) { return { @@ -95,53 +95,60 @@ export const useFormData = (fieldSchema: JSONSchema, path: string[]) => { formValid: true, formErrors: [], } - }, [context.formData]) + }, [formContext.formData]) const hiddenMask = useMemo(() => parseMaskableField('hidden', fieldSchema, data), [fieldSchema, data]) const disabledMask = useMemo(() => parseMaskableField('disabled', fieldSchema, data), [fieldSchema, data]) useEffect(() => { - context.setHiddenState(setObjectPath(context.hiddenState, path, hiddenMask || {})) - context.setDisabledState(setObjectPath(context.disabledState, path, disabledMask || {})) - }, [hiddenMask, disabledMask]) + formContext.setHiddenState((hiddenState) => setObjectPath(hiddenState, path, hiddenMask || {})) + formContext.setDisabledState((disabledState) => setObjectPath(disabledState, path, disabledMask || {})) + }, [JSON.stringify({ fieldSchema, data })]) const { disabled, hidden } = useMemo(() => { - const hidden = hiddenMask === true || getPathData(context.hiddenState, path) - const disabled = disabledMask === true || getPathData(context.disabledState, path) + const hidden = hiddenMask === true || getPathData(formContext.hiddenState, path) + const disabled = disabledMask === true || getPathData(formContext.disabledState, path) return { hidden: hidden === true, disabled: disabled === true } - }, [context.hiddenState, context.disabledState, hiddenMask, disabledMask, path]) + }, [formContext.hiddenState, formContext.disabledState, hiddenMask, disabledMask, path]) const handlePropertyChange = useCallback( (path: string[], data: any) => { - context.setFormData(setObjectPath(context.formData, path, data)) + formContext.setFormData((formData) => setObjectPath(formData, path, data)) }, - [context.formData], + [formContext.setFormData], ) - const addArrayItem = useCallback( - (path: string[], data: any) => { - const currentData = getPathData(context.formData, path) || [] - context.setFormData( - setObjectPath( - context.formData, - path, - Array.isArray(currentData) ? [...currentData, data] : [...currentData, data], - ), - ) + (path: string[], data: any = undefined) => { + const defaultData = getDefaultValues((fieldSchema as ArraySchema).items) + + formContext.setFormData((formData) => { + const currentData = getPathData(formData, path) || [] + if (data === undefined) { + data = defaultData + } + return setObjectPath(formData, path, Array.isArray(currentData) ? [...currentData, data] : [data]) + }) }, - [context.formData], + [formContext.setFormData], ) const removeArrayItem = useCallback( (path: string[], index: number) => { - const currentData = getPathData(context.formData, path) - currentData.splice(index, 1) - context.setFormData(setObjectPath(context.formData, path, currentData)) + formContext.setFormData((formData) => { + const currentData = getPathData(formData, path) || [] + + if (!Array.isArray(currentData)) { + return formData + } + + currentData.splice(index, 1) + return setObjectPath(formData, path, currentData) + }) }, - [context.formData], + [formContext.setFormData], ) - return { ...context, data, disabled, hidden, handlePropertyChange, addArrayItem, removeArrayItem, ...validation } + return { ...formContext, data, disabled, hidden, handlePropertyChange, addArrayItem, removeArrayItem, ...validation } } export function setObjectPath(obj: any, path: string[], data: any): any { diff --git a/zui/src/ui/index.tsx b/zui/src/ui/index.tsx index 1008f1da..3f603e0b 100644 --- a/zui/src/ui/index.tsx +++ b/zui/src/ui/index.tsx @@ -1,314 +1,2 @@ -import { - BaseType, - UIComponentDefinitions, - ZuiComponentMap, - JSONSchema, - ZuiReactComponent, - ZuiReactComponentBaseProps, - ObjectSchema, - ArraySchema, - ZuiReactComponentProps, - ZuiReactControlComponentProps, - PrimitiveSchema, - ZuiReactArrayChildProps, - DefaultComponentDefinitions, -} from './types' -import { zuiKey } from './constants' -import React, { type FC, useMemo } from 'react' -import { FormDataProvider, deepMerge, getDefaultValues, useFormData } from './hooks/useFormData' -import { getPathData } from './hooks/useFormData' -import { formatTitle } from './titleutils' -import { BoundaryFallbackComponent, ErrorBoundary } from './ErrorBoundary' -import { useDiscriminator, resolveDiscriminator } from './hooks/useDiscriminator' - -type ComponentMeta = { - type: Type - Component: ZuiReactComponent - id: string - params: any -} - -export const getSchemaType = (schema: JSONSchema): BaseType => { - if (schema.anyOf?.length) { - const discriminator = resolveDiscriminator(schema.anyOf) - return discriminator ? 'discriminatedUnion' : 'object' - } - if (schema.type === 'integer') { - return 'number' - } - - return schema.type -} - -const resolveComponent = ( - components: ZuiComponentMap | undefined, - fieldSchema: JSONSchema, -): ComponentMeta | null => { - const type = getSchemaType(fieldSchema) - const uiDefinition = fieldSchema[zuiKey]?.displayAs || null - - if (!uiDefinition || !Array.isArray(uiDefinition) || uiDefinition.length < 2) { - const defaultComponent = components?.[type]?.default - - if (!defaultComponent) { - return null - } - - return { - Component: defaultComponent as ZuiReactComponent, - type: type as Type, - id: 'default', - params: {}, - } - } - - const componentID: string = uiDefinition[0] - - const Component = components?.[type]?.[componentID] || null - - if (!Component) { - console.warn(`Component ${type}.${componentID} not found`) - return null - } - - const params = uiDefinition[1] || {} - - return { - Component: Component as ZuiReactComponent, - type: type as Type, - id: componentID, - params, - } -} - -export type ZuiFormProps = { - schema: JSONSchema - components: ZuiComponentMap - value: any - onChange: (value: any) => void - disableValidation?: boolean - fallback?: BoundaryFallbackComponent -} - -export const ZuiForm = ({ - schema, - components, - onChange, - value, - disableValidation, - fallback, -}: ZuiFormProps): JSX.Element | null => { - const actualData = useMemo(() => { - return deepMerge(getDefaultValues(schema), value) - }, [value, schema]) - - return ( - - - - - - ) -} - -type FormRendererProps = { - components: ZuiComponentMap - fieldSchema: JSONSchema - path: string[] - required: boolean - fallback?: BoundaryFallbackComponent -} & ZuiReactArrayChildProps - -const FormElementRenderer: FC = ({ - components, - fieldSchema, - path, - required, - fallback, - ...childProps -}) => { - const { formData, disabled, hidden, handlePropertyChange, addArrayItem, removeArrayItem, formErrors, formValid } = - useFormData(fieldSchema, path) - const data = useMemo(() => getPathData(formData, path), [formData, path]) - const componentMeta = useMemo(() => resolveComponent(components, fieldSchema), [fieldSchema, components]) - const { discriminator, discriminatedSchema, discriminatorValue } = useDiscriminator(fieldSchema, path) - - if (!componentMeta) { - return null - } - - if (hidden === true) { - return null - } - - const { Component: _component, type } = componentMeta - - const baseProps: Omit, 'data' | 'isArrayChild'> = { - type, - componentID: componentMeta.id, - scope: path.join('.'), - context: { - path, - readonly: false, - formData, - formErrors, - formValid, - updateForm: handlePropertyChange, - }, - onChange: (data: any) => handlePropertyChange(path, data), - disabled, - errors: formErrors?.filter((e) => e.path === path) || [], - label: fieldSchema[zuiKey]?.title || formatTitle(path[path.length - 1]?.toString() || ''), - params: componentMeta.params, - schema: fieldSchema, - zuiProps: fieldSchema[zuiKey], - } - - if (fieldSchema.type === 'array' && type === 'array') { - const Component = _component as any as ZuiReactComponent<'array', string, any> - const schema = baseProps.schema as ArraySchema - const dataArray = Array.isArray(data) ? data : typeof data === 'object' ? data : [] - const props: Omit, 'children'> = { - ...baseProps, - type, - schema, - data: dataArray, - addItem: (data = undefined) => - addArrayItem(path, typeof data === 'undefined' ? getDefaultValues(schema.items) : data), - removeItem: (index) => removeArrayItem(path, index), - ...childProps, - } - - // Tuple - if (Array.isArray(fieldSchema.items)) { - return null - } - - return ( - - {Array.isArray(props.data) - ? props.data.map((_, index) => { - const childPath = [...path, index.toString()] - return ( - - removeArrayItem(path, index)} - fallback={fallback} - /> - - ) - }) - : []} - - ) - } - - if (fieldSchema.type === 'object' && type === 'object' && fieldSchema.properties) { - const Component = _component as any as ZuiReactComponent<'object', string, any> - const props: Omit, 'children'> = { - ...baseProps, - type, - schema: baseProps.schema as any as ObjectSchema, - data: data || {}, - ...childProps, - } - return ( - - {Object.entries(fieldSchema.properties).map(([fieldName, childSchema]) => { - const childPath = [...path, fieldName] - return ( - - - - ) - })} - - ) - } - - if (type === 'discriminatedUnion') { - const Component = _component as any as ZuiReactComponent<'discriminatedUnion', string, any> - - const props: Omit, 'children'> = { - ...baseProps, - type, - schema: baseProps.schema as any as ObjectSchema, - data: data || {}, - discriminatorKey: discriminator?.key || null, - discriminatorLabel: formatTitle(discriminator?.key || 'Unknown'), - discriminatorOptions: discriminator?.values || null, - discriminatorValue, - setDiscriminator: (disc: string) => { - if (!discriminator?.key) { - console.warn('No discriminator key found, cannot set discriminator') - return - } - handlePropertyChange(path, { [discriminator.key]: disc }) - }, - ...childProps, - } - - return ( - - {discriminatedSchema && ( - - - - )} - - ) - } - const Component = _component as any as ZuiReactComponent - - const props: ZuiReactControlComponentProps<'boolean' | 'number' | 'string', string, any> = { - ...baseProps, - type: type as any as 'boolean' | 'number' | 'string', - schema: baseProps.schema as any as PrimitiveSchema, - config: {}, - required, - data, - description: fieldSchema.description, - ...childProps, - } - - return -} +export { ZuiForm, type ZuiFormProps } from './Form' +export { getSchemaType } from './utils' diff --git a/zui/src/ui/ui.test.tsx b/zui/src/ui/ui.test.tsx index 62bef113..556352fd 100644 --- a/zui/src/ui/ui.test.tsx +++ b/zui/src/ui/ui.test.tsx @@ -180,6 +180,7 @@ describe('UI', () => { fireEvent.click(addBtn) const element = rendered.queryByTestId('string:favoriteColors.0:input') + expect(element).toBeTruthy() expect(rendered.queryByTestId('string:favoriteColors.0')?.getAttribute('data-ischild')).toBe('true') @@ -337,8 +338,10 @@ describe('UI', () => { const input = rendered.getByTestId('string:students.0.name:input') fireEvent.change(input, { target: { value: 'Jane' } }) - expect(onChangeMock).toHaveBeenCalledTimes(1) - expect(onChangeMock).toHaveBeenCalledWith({ students: [{ name: 'Jane', age: 20 }] }) + expect(onChangeMock).toHaveBeenCalledTimes(2) + + // check initial value + expect(onChangeMock).toHaveBeenCalledWith({ students: [{ name: 'John', age: 20 }] }) }) it('it renders custom zui components with correct params as input', () => { @@ -611,7 +614,6 @@ describe('UI', () => { expect(ageInput.value).toBe('20') expect(favoriteFoods).toHaveLength(2) expect(creditCardInput.value).toBe('1234') - console.log(nameInput.value, ageInput.value) }) it("Doesn't override initialData with default values", () => { @@ -853,7 +855,7 @@ const testComponentImplementation: ZuiComponentMap - {childrens.map((child, index) => ( diff --git a/zui/src/ui/titleutils.ts b/zui/src/ui/utils.ts similarity index 69% rename from zui/src/ui/titleutils.ts rename to zui/src/ui/utils.ts index 297e1a0b..556d8dc5 100644 --- a/zui/src/ui/titleutils.ts +++ b/zui/src/ui/utils.ts @@ -1,3 +1,67 @@ +import { zuiKey } from './constants' +import { resolveDiscriminator } from './hooks/useDiscriminator' +import { BaseType, JSONSchema, ZuiComponentMap, ZuiReactComponent } from './types' + +type ComponentMeta = { + type: Type + Component: ZuiReactComponent + id: string + params: any +} + +export const getSchemaType = (schema: JSONSchema): BaseType => { + if (schema.anyOf?.length) { + const discriminator = resolveDiscriminator(schema.anyOf) + return discriminator ? 'discriminatedUnion' : 'object' + } + if (schema.type === 'integer') { + return 'number' + } + + return schema.type +} + +export const resolveComponent = ( + components: ZuiComponentMap | undefined, + fieldSchema: JSONSchema, +): ComponentMeta | null => { + const type = getSchemaType(fieldSchema) + const uiDefinition = fieldSchema[zuiKey]?.displayAs || null + + if (!uiDefinition || !Array.isArray(uiDefinition) || uiDefinition.length < 2) { + const defaultComponent = components?.[type]?.default + + if (!defaultComponent) { + return null + } + + return { + Component: defaultComponent as ZuiReactComponent, + type: type as Type, + id: 'default', + params: {}, + } + } + + const componentID: string = uiDefinition[0] + + const Component = components?.[type]?.[componentID] || null + + if (!Component) { + console.warn(`Component ${type}.${componentID} not found`) + return null + } + + const params = uiDefinition[1] || {} + + return { + Component: Component as ZuiReactComponent, + type: type as Type, + id: componentID, + params, + } +} + export function formatTitle(title: string, separator?: RegExp): string { if (!separator) separator = new RegExp('/s|-|_| ', 'g') return decamelize(title).split(separator).map(capitalize).map(handleSpecialWords).reduce(combine)