From 792952de8aed52b934ecd975868a33006950765d Mon Sep 17 00:00:00 2001 From: m6io <147613092+m6io@users.noreply.github.com> Date: Sun, 20 Apr 2025 13:19:51 -0400 Subject: [PATCH] feat: decoupling templates from schema implementations (0.0.0-alpha.24) --- packages/core/package.json | 2 +- packages/json-schema/package.json | 4 +- .../json-schema/src/components/Templates.tsx | 1299 ------ packages/json-schema/src/components/index.ts | 1 - packages/json-schema/src/components/types.ts | 220 +- packages/json-schema/src/index.ts | 17 +- packages/yup/package.json | 4 +- packages/zod/package.json | 4 +- pnpm-lock.yaml | 3709 ++--------------- website/package.json | 6 +- .../templates/json-schema/BaseFormRoot.tsx | 86 + .../json-schema/common/AjvInstance.tsx | 9 + .../common/ComplexWrapperStyle.tsx | 7 + .../json-schema/common/ErrorsList.tsx | 19 + .../common/ReadonlyComplexTemplate.tsx | 33 + .../common/ReadonlyPrimitiveTemplate.tsx | 21 + .../json-schema/common/WrapperStyle.ts | 6 + .../json-schema/hooks/useWindowSize.ts | 32 + .../array/MultiSelectCheckboxTemplate.tsx | 101 + .../templates/array/MultiSelectTemplate.tsx | 92 + .../templates/array/SimpleArrayTemplate.tsx | 142 + .../templates/array/TupleArrayTemplate.tsx | 126 + .../templates/boolean/CheckboxTemplate.tsx | 63 + .../templates/boolean/RadioTemplate.tsx | 73 + .../templates/json-schema/templates/index.tsx | 211 + .../templates/object/SimpleObjectTemplate.tsx | 88 + .../templates/string/TextareaTemplate.tsx | 69 + .../templates/union/InputTemplate.tsx | 78 + .../union/MultipleChoiceTemplate.tsx | 123 + .../templates/union/SelectTemplate.tsx | 95 + website/src/examples/JsonSchema.tsx | 33 +- 31 files changed, 1908 insertions(+), 4865 deletions(-) delete mode 100644 packages/json-schema/src/components/Templates.tsx create mode 100644 website/src/components/templates/json-schema/BaseFormRoot.tsx create mode 100644 website/src/components/templates/json-schema/common/AjvInstance.tsx create mode 100644 website/src/components/templates/json-schema/common/ComplexWrapperStyle.tsx create mode 100644 website/src/components/templates/json-schema/common/ErrorsList.tsx create mode 100644 website/src/components/templates/json-schema/common/ReadonlyComplexTemplate.tsx create mode 100644 website/src/components/templates/json-schema/common/ReadonlyPrimitiveTemplate.tsx create mode 100644 website/src/components/templates/json-schema/common/WrapperStyle.ts create mode 100644 website/src/components/templates/json-schema/hooks/useWindowSize.ts create mode 100644 website/src/components/templates/json-schema/templates/array/MultiSelectCheckboxTemplate.tsx create mode 100644 website/src/components/templates/json-schema/templates/array/MultiSelectTemplate.tsx create mode 100644 website/src/components/templates/json-schema/templates/array/SimpleArrayTemplate.tsx create mode 100644 website/src/components/templates/json-schema/templates/array/TupleArrayTemplate.tsx create mode 100644 website/src/components/templates/json-schema/templates/boolean/CheckboxTemplate.tsx create mode 100644 website/src/components/templates/json-schema/templates/boolean/RadioTemplate.tsx create mode 100644 website/src/components/templates/json-schema/templates/index.tsx create mode 100644 website/src/components/templates/json-schema/templates/object/SimpleObjectTemplate.tsx create mode 100644 website/src/components/templates/json-schema/templates/string/TextareaTemplate.tsx create mode 100644 website/src/components/templates/json-schema/templates/union/InputTemplate.tsx create mode 100644 website/src/components/templates/json-schema/templates/union/MultipleChoiceTemplate.tsx create mode 100644 website/src/components/templates/json-schema/templates/union/SelectTemplate.tsx diff --git a/packages/core/package.json b/packages/core/package.json index e95c504..c05dd2d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,7 +2,7 @@ "name": "@react-formgen/core", "description": "A headless, type-safe, customizable, and super simple React form generator.", "private": false, - "version": "0.0.0-alpha.23", + "version": "0.0.0-alpha.24", "license": "MIT", "author": "m6io", "repository": { diff --git a/packages/json-schema/package.json b/packages/json-schema/package.json index 9595999..4c1fd55 100644 --- a/packages/json-schema/package.json +++ b/packages/json-schema/package.json @@ -2,7 +2,7 @@ "name": "@react-formgen/json-schema", "description": "A headless, type-safe, customizable, and super simple React form generator.", "private": false, - "version": "0.0.0-alpha.23", + "version": "0.0.0-alpha.24", "license": "MIT", "author": "m6io", "repository": { @@ -35,7 +35,7 @@ "analyze": "vite build --mode analyze" }, "dependencies": { - "@react-formgen/core": "0.0.0-alpha.23" + "@react-formgen/core": "0.0.0-alpha.24" }, "peerDependencies": { "ajv": "^8.16.0", diff --git a/packages/json-schema/src/components/Templates.tsx b/packages/json-schema/src/components/Templates.tsx deleted file mode 100644 index 6f6220a..0000000 --- a/packages/json-schema/src/components/Templates.tsx +++ /dev/null @@ -1,1299 +0,0 @@ -import React from "react"; -import Ajv, { ErrorObject } from "ajv"; -import addFormats from "ajv-formats"; -import { - StringSchema, - ArraySchema, - NumberSchema, - BooleanSchema, - ObjectSchema, - Templates, - FormRootProps, - FormgenJSONSchema7, -} from "./types"; -import { - useFormDataAtPath, - useErrorsAtPath, - useFormContext, - useArrayTemplate, - FormState, - useRenderTemplate, -} from ".."; -import { generateInitialData, resolveSchema } from "../utils"; -import { useIsRequired } from "../hooks/useIsRequired"; - -export function useWindowSize(): { - width: number | null; - height: number | null; -} { - const [size, setSize] = React.useState<{ - width: number | null; - height: number | null; - }>({ - width: null, - height: null, - }); - - React.useLayoutEffect(() => { - const handleResize = () => { - setSize({ - width: window.innerWidth, - height: window.innerHeight, - }); - }; - - handleResize(); - window.addEventListener("resize", handleResize); - - return () => { - window.removeEventListener("resize", handleResize); - }; - }, []); - - return size; -} - -export const ReadonlyPrimitiveTemplate: React.FC<{ - title?: string; - value?: string | number | boolean; - description?: string; -}> = ({ title, value, description }) => { - return ( -
- {title && } -
{value ?? "N/A"}
- {description && {description}} -
- ); -}; - -/** - * ReadonlyComplexTemplate - * Renders a readonly view of complex data (objects or arrays) with a title and description. - * @param {Object} props - The props for the component. - * @param {string} [props.title] - The title of the field. - * @param {string} [props.description] - The description of the field. - * @param {React.ReactNode} props.children - The child components to render. - * @returns {JSX.Element} - The readonly complex component. - */ -export const ReadonlyComplexTemplate: React.FC<{ - title?: string; - description?: string; - children: React.ReactNode; -}> = ({ title, description, children }) => { - return ( -
- {title && {title}} - {description && ( -

{description}

- )} -
{children}
-
- ); -}; - -/** - * ErrorsList - * Displays a list of validation errors for a given path. - * @param {Object} props - The props for the component. - * @param {ErrorObject[]} props.errorsAtPath - The list of error objects at the path. - * @returns {JSX.Element} - The errors list component. - */ -export const ErrorsList: React.FC<{ errorsAtPath: ErrorObject[] }> = ({ - errorsAtPath, -}) => { - return errorsAtPath.map((error, index) => ( -
- {error.message} -
- )); -}; - -const WrapperStyle: React.CSSProperties = { - display: "flex", - flexDirection: "column", -}; - -const ComplexWrapperStyle: React.CSSProperties = { - display: "flex", - flexWrap: "wrap", - gap: "1rem", -}; - -/** - * StringTemplate - * Handles switching between input, select, and date fields based on schema metadata. - * @param {Object} props - The props for the component. - * @param {StringSchema} props.schema - The schema for the string property. - * @param {string[]} props.path - The path to the string property in the form data. - * @returns {JSX.Element} - The string template component. - */ -export const StringTemplate: React.FC<{ - schema: StringSchema; - path: string[]; -}> = ({ schema, path }) => { - const stringMatchers = [ - { - matcher: (schema: StringSchema) => schema.enum || schema.oneOf, - render: () => , - // Alternative render method - // render: () => ( - // - // ), - }, - { - matcher: (schema: StringSchema) => - schema.format && ["date", "date-time"].includes(schema.format), - render: () => ( - - ), - }, - { - matcher: (schema: StringSchema) => schema.format === "email", - render: () => ( - - ), - }, - { - matcher: (schema: StringSchema) => schema.format === "uri", - render: () => ( - - ), - }, - { - matcher: () => true, - render: () => , - }, - ]; - - for (const { matcher, render } of stringMatchers) { - if (matcher(schema)) { - return render(); - } - } - return ; -}; - -/** - * InputTemplate - * Renders a text input field for string and number schemas. - * @param {Object} props - The props for the component. - * @param {StringSchema | NumberSchema} props.schema - The schema for the input field. - * @param {string[]} props.path - The path to the input field in the form data. - * @param {string} [props.htmlType] - The HTML input type (default: "text"). - * @returns {JSX.Element} - The input field component. - */ -export const InputTemplate: React.FC<{ - schema: StringSchema | NumberSchema; - path: string[]; - htmlType?: string; -}> = ({ schema, path, htmlType = "text" }) => { - const [valueAtPath, setValueAtPath] = useFormDataAtPath(path); - const errorsAtPath = useErrorsAtPath(path); - const readonly = useFormContext((state: FormState) => state.readonly); - const size = useWindowSize(); - const required = useIsRequired(path); - - if (readonly) { - return ( - - ); - } - - return ( -
640 ? "min-content" : "100%", - ...WrapperStyle, - }} - > - {schema.title && ( - - )} - setValueAtPath(e.target.value ? e.target.value : null)} - placeholder={schema.title || ""} - style={{ - width: size.width && size.width > 640 ? "12rem" : "100%", - padding: "8px", - border: "1px solid #d1d5db", - borderRadius: "0.375rem", - boxSizing: "border-box", - }} - /> - {schema.description && {schema.description}} - {errorsAtPath && } -
- ); -}; - -// Memoized MultipleChoiceOptionTemplate to prevent re-renders unless its props change -const MultipleChoiceOptionTemplate: React.FC<{ - value: string; - title: string; - checked: boolean; - onChange: (event: React.ChangeEvent) => void; - disabled?: boolean; -}> = React.memo(({ value, title, checked, onChange, disabled }) => { - return ( - - ); -}); - -/** - * MultipleChoiceTemplate - * Renders a multiple-choice field as radio buttons for string schemas with enum or oneOf options. - * @param {Object} props - The props for the component. - * @param {StringSchema} props.schema - The schema for the multiple-choice field. - * @param {string[]} props.path - The path to the field in the form data. - * @returns {JSX.Element} - The multiple-choice field component. - */ -export const MultipleChoiceTemplate: React.FC<{ - schema: StringSchema; - path: string[]; -}> = ({ schema, path }) => { - const [valueAtPath, setValueAtPath] = useFormDataAtPath(path); - const errorsAtPath = useErrorsAtPath(path); - const readonly = useFormContext((state: FormState) => state.readonly); - const required = useIsRequired(path); - - const handleChange = React.useCallback( - (event: React.ChangeEvent) => { - setValueAtPath(event.target.value); - }, - [setValueAtPath] - ); - - // Memoize the options to prevent unnecessary recalculations - const opts: { value: string; title: string }[] = React.useMemo(() => { - const options: { value: string; title: string }[] = []; - if (schema.enum) { - schema.enum.forEach((opt) => options.push({ value: opt, title: opt })); - } else if (schema.oneOf) { - schema.oneOf.forEach((opt) => - options.push({ value: opt.const, title: opt.title ?? opt.const }) - ); - } - return options; - }, [schema.enum, schema.oneOf]); - - // Memoize the option rendering to avoid recreating the function on every render - const renderOption = React.useCallback( - (option: { value: string; title: string }, index: number) => { - return ( - - ); - }, - [valueAtPath, handleChange, schema.readOnly] - ); - - if (readonly) { - const selectedOption = - schema.enum?.find((opt) => opt === valueAtPath) || - schema.oneOf?.find((opt) => opt.const === valueAtPath)?.title || - valueAtPath; - return ( - - ); - } - - return ( -
- {schema.title && ( - - )} -
{opts.map((option, index) => renderOption(option, index))}
- {schema.description && {schema.description}} - {errorsAtPath && } -
- ); -}; - -/** - * TextareaTemplate - * Renders a textarea input for multi-line string input. - * @param {Object} props - The props for the component. - * @param {StringSchema} props.schema - The schema for the textarea field. - * @param {string[]} props.path - The path to the textarea field in the form data. - * @returns {JSX.Element} - The textarea field component. - */ -export const TextareaTemplate: React.FC<{ - schema: StringSchema; - path: string[]; -}> = ({ schema, path }) => { - const [valueAtPath, setValueAtPath] = useFormDataAtPath(path); - const errorsAtPath = useErrorsAtPath(path); - const readonly = useFormContext((state: FormState) => state.readonly); - const required = useIsRequired(path); - - if (readonly) { - return ( - - ); - } - - const handleChange = (event: React.ChangeEvent) => { - setValueAtPath(event.target.value); - }; - - return ( -
- {schema.title && ( - - )} -