fix schema form

This commit is contained in:
dswbx
2025-02-26 08:22:05 +01:00
parent de854eec3a
commit d4a6a9326f
8 changed files with 139 additions and 46 deletions

View File

@@ -0,0 +1,53 @@
import React, { Component, type ErrorInfo, type ReactNode } from "react";
interface ErrorBoundaryProps {
children: ReactNode;
fallback?:
| (({ error, resetError }: { error: Error; resetError: () => void }) => ReactNode)
| ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error?: Error | undefined;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: undefined };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
resetError = () => {
this.setState({ hasError: false, error: undefined });
};
override render() {
if (this.state.hasError) {
return this.props.fallback ? (
typeof this.props.fallback === "function" ? (
this.props.fallback({ error: this.state.error!, resetError: this.resetError })
) : (
this.props.fallback
)
) : (
<div>
<h2>Something went wrong.</h2>
<button onClick={this.resetError}>Try Again</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -10,7 +10,6 @@ import { getLabel, getMultiSchemaMatched } from "./utils";
export type AnyOfFieldRootProps = { export type AnyOfFieldRootProps = {
path?: string; path?: string;
schema?: JsonSchema;
children: ReactNode; children: ReactNode;
}; };
@@ -34,14 +33,14 @@ export const useAnyOfContext = () => {
const selectedAtom = atom<number | null>(null); const selectedAtom = atom<number | null>(null);
const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) => { const Root = ({ path = "", children }: AnyOfFieldRootProps) => {
const { const {
setValue, setValue,
lib, lib,
pointer, pointer,
value: { matchedIndex, schemas }, value: { matchedIndex, schemas },
schema schema
} = useDerivedFieldContext(path, _schema, (ctx) => { } = useDerivedFieldContext(path, (ctx) => {
const [matchedIndex, schemas = []] = getMultiSchemaMatched(ctx.schema, ctx.value); const [matchedIndex, schemas = []] = getMultiSchemaMatched(ctx.schema, ctx.value);
return { matchedIndex, schemas }; return { matchedIndex, schemas };
}); });
@@ -115,7 +114,7 @@ const Select = () => {
}; };
// @todo: add local validation for AnyOf fields // @todo: add local validation for AnyOf fields
const Field = ({ name, label, schema, ...props }: Partial<FormFieldProps>) => { const Field = ({ name, label, ...props }: Partial<FormFieldProps>) => {
const { selected, selectedSchema, path, errors } = useAnyOfContext(); const { selected, selectedSchema, path, errors } = useAnyOfContext();
if (selected === null) return null; if (selected === null) return null;
return ( return (

View File

@@ -10,12 +10,8 @@ import { FieldWrapper } from "./FieldWrapper";
import { useDerivedFieldContext, useFormValue } from "./Form"; import { useDerivedFieldContext, useFormValue } from "./Form";
import { coerce, getMultiSchema, getMultiSchemaMatched, isEqual, suffixPath } from "./utils"; import { coerce, getMultiSchema, getMultiSchemaMatched, isEqual, suffixPath } from "./utils";
export const ArrayField = ({ export const ArrayField = ({ path = "" }: { path?: string }) => {
path = "", const { setValue, pointer, required, schema, ...ctx } = useDerivedFieldContext(path);
schema: _schema
}: { path?: string; schema?: JsonSchema }) => {
const { setValue, pointer, required, ...ctx } = useDerivedFieldContext(path, _schema);
const schema = _schema ?? ctx.schema;
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`; if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
// if unique items with enum // if unique items with enum
@@ -55,7 +51,7 @@ export const ArrayField = ({
}; };
const ArrayItem = memo(({ path, index, schema }: any) => { const ArrayItem = memo(({ path, index, schema }: any) => {
const { value, ...ctx } = useDerivedFieldContext(path, schema, (ctx) => { const { value, ...ctx } = useDerivedFieldContext(path, (ctx) => {
return ctx.value?.[index]; return ctx.value?.[index];
}); });
const itemPath = suffixPath(path, index); const itemPath = suffixPath(path, index);
@@ -107,7 +103,7 @@ const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
setValue, setValue,
value: { currentIndex }, value: { currentIndex },
...ctx ...ctx
} = useDerivedFieldContext(path, schema, (ctx) => { } = useDerivedFieldContext(path, (ctx) => {
return { currentIndex: ctx.value?.length ?? 0 }; return { currentIndex: ctx.value?.length ?? 0 };
}); });
const itemsMultiSchema = getMultiSchema(schema.items); const itemsMultiSchema = getMultiSchema(schema.items);

View File

@@ -1,5 +1,6 @@
import type { JsonSchema } from "json-schema-library"; import type { JsonSchema } from "json-schema-library";
import type { ChangeEvent, ComponentPropsWithoutRef } from "react"; import type { ChangeEvent, ComponentPropsWithoutRef } from "react";
import ErrorBoundary from "ui/components/display/ErrorBoundary";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
import { ArrayField } from "./ArrayField"; import { ArrayField } from "./ArrayField";
@@ -10,11 +11,28 @@ import { coerce, isType, isTypeSchema } from "./utils";
export type FieldProps = { export type FieldProps = {
onChange?: (e: ChangeEvent<any>) => void; onChange?: (e: ChangeEvent<any>) => void;
} & Omit<FieldwrapperProps, "children">; placeholder?: string;
} & Omit<FieldwrapperProps, "children" | "schema">;
export const Field = ({ name, schema: _schema, onChange, ...props }: FieldProps) => { export const Field = (props: FieldProps) => {
const { path, setValue, required, ...ctx } = useDerivedFieldContext(name, _schema); return (
const schema = _schema ?? ctx.schema; <ErrorBoundary fallback={fieldErrorBoundary(props)}>
<FieldImpl {...props} />
</ErrorBoundary>
);
};
const fieldErrorBoundary =
({ name }: FieldProps) =>
({ error }: { error: Error }) => (
<Pre>
Field "{name}" error: {error.message}
</Pre>
);
const FieldImpl = ({ name, onChange, placeholder, ...props }: FieldProps) => {
const { path, setValue, required, schema, ...ctx } = useDerivedFieldContext(name);
//console.log("Field", { name, path, schema });
if (!isTypeSchema(schema)) if (!isTypeSchema(schema))
return ( return (
<Pre> <Pre>
@@ -23,11 +41,11 @@ export const Field = ({ name, schema: _schema, onChange, ...props }: FieldProps)
); );
if (isType(schema.type, "object")) { if (isType(schema.type, "object")) {
return <ObjectField path={name} schema={schema} />; return <ObjectField path={name} />;
} }
if (isType(schema.type, "array")) { if (isType(schema.type, "array")) {
return <ArrayField path={name} schema={schema} />; return <ArrayField path={name} />;
} }
const disabled = schema.readOnly ?? "const" in schema ?? false; const disabled = schema.readOnly ?? "const" in schema ?? false;
@@ -48,6 +66,7 @@ export const Field = ({ name, schema: _schema, onChange, ...props }: FieldProps)
name={name} name={name}
required={required} required={required}
disabled={disabled} disabled={disabled}
placeholder={placeholder}
onChange={onChange ?? handleChange} onChange={onChange ?? handleChange}
/> />
</FieldWrapper> </FieldWrapper>
@@ -69,7 +88,9 @@ export const FieldComponent = ({
const props = { const props = {
..._props, ..._props,
// allow override // allow override
value: typeof _props.value !== "undefined" ? _props.value : value value: typeof _props.value !== "undefined" ? _props.value : value,
placeholder:
(_props.placeholder ?? typeof schema.default !== "undefined") ? String(schema.default) : ""
}; };
if (schema.enum) { if (schema.enum) {

View File

@@ -298,7 +298,6 @@ type SelectorFn<Ctx = any, Refined = any> = (state: Ctx) => Refined;
export function useDerivedFieldContext<Data = any, Reduced = undefined>( export function useDerivedFieldContext<Data = any, Reduced = undefined>(
path, path,
_schema?: LibJsonSchema,
deriveFn?: SelectorFn< deriveFn?: SelectorFn<
FormContext<Data> & { FormContext<Data> & {
pointer: string; pointer: string;
@@ -314,8 +313,7 @@ export function useDerivedFieldContext<Data = any, Reduced = undefined>(
required: boolean; required: boolean;
path: string; path: string;
} { } {
const { _formStateAtom, root, lib, ...ctx } = useFormContext(); const { _formStateAtom, root, lib, schema, ...ctx } = useFormContext();
const schema = _schema ?? ctx.schema;
const selected = selectAtom( const selected = selectAtom(
_formStateAtom, _formStateAtom,
useCallback( useCallback(

View File

@@ -7,21 +7,14 @@ import { useDerivedFieldContext } from "./Form";
export type ObjectFieldProps = { export type ObjectFieldProps = {
path?: string; path?: string;
schema?: Exclude<JSONSchema, boolean>;
label?: string | false; label?: string | false;
wrapperProps?: Partial<FieldwrapperProps>; wrapperProps?: Partial<FieldwrapperProps>;
}; };
export const ObjectField = ({ export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: ObjectFieldProps) => {
path = "", const { schema } = useDerivedFieldContext(path);
schema: _schema,
label: _label,
wrapperProps = {}
}: ObjectFieldProps) => {
const ctx = useDerivedFieldContext(path, _schema);
const schema = _schema ?? ctx.schema;
if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`; if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`;
const properties = schema.properties ?? {}; const properties = Object.entries(schema.properties ?? {}) as [string, JSONSchema][];
return ( return (
<FieldWrapper <FieldWrapper
@@ -31,17 +24,20 @@ export const ObjectField = ({
errorPlacement="top" errorPlacement="top"
{...wrapperProps} {...wrapperProps}
> >
{Object.keys(properties).map((prop) => { {properties.length === 0 ? (
const schema = properties[prop]; <i className="opacity-50">No properties</i>
const name = [path, prop].filter(Boolean).join("."); ) : (
if (typeof schema === "undefined" || typeof schema === "boolean") return; properties.map(([prop, schema]) => {
const name = [path, prop].filter(Boolean).join(".");
if (typeof schema === "undefined" || typeof schema === "boolean") return;
if (schema.anyOf || schema.oneOf) { if (schema.anyOf || schema.oneOf) {
return <AnyOfField key={name} path={name} />; return <AnyOfField key={name} path={name} />;
} }
return <Field key={name} name={name} />; return <Field key={name} name={name} />;
})} })
)}
</FieldWrapper> </FieldWrapper>
); );
}; };

View File

@@ -11,7 +11,8 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
); );
// REGISTER ERROR OVERLAY // REGISTER ERROR OVERLAY
if (process.env.NODE_ENV !== "production") { const showOverlay = true;
if (process.env.NODE_ENV !== "production" && showOverlay) {
const showErrorOverlay = (err) => { const showErrorOverlay = (err) => {
// must be within function call because that's when the element is defined for sure. // must be within function call because that's when the element is defined for sure.
const ErrorOverlay = customElements.get("vite-error-overlay"); const ErrorOverlay = customElements.get("vite-error-overlay");

View File

@@ -32,6 +32,33 @@ const schema2 = {
required: ["age"] required: ["age"]
} as const satisfies JSONSchema; } as const satisfies JSONSchema;
const authSchema = {
type: "object",
properties: {
what: {
type: "array",
items: {
type: "string"
}
},
jwt: {
type: "object",
properties: {
fields: {
type: "array",
items: {
type: "string"
}
}
}
}
}
} as const satisfies JSONSchema;
const formOptions = {
debug: true
};
export default function JsonSchemaForm3() { export default function JsonSchemaForm3() {
const { schema: _schema, config } = useBknd(); const { schema: _schema, config } = useBknd();
const schema = JSON.parse(JSON.stringify(_schema)); const schema = JSON.parse(JSON.stringify(_schema));
@@ -46,6 +73,8 @@ export default function JsonSchemaForm3() {
return ( return (
<Scrollable> <Scrollable>
<div className="flex flex-col p-3"> <div className="flex flex-col p-3">
<Form schema={_schema.auth} options={formOptions} />
{/*<Form {/*<Form
onChange={(data) => console.log("change", data)} onChange={(data) => console.log("change", data)}
onSubmit={(data) => console.log("submit", data)} onSubmit={(data) => console.log("submit", data)}
@@ -249,13 +278,13 @@ export default function JsonSchemaForm3() {
</Form>*/} </Form>*/}
{/*<CustomMediaForm />*/} {/*<CustomMediaForm />*/}
<Form
{/*<Form
schema={schema.media} schema={schema.media}
initialValues={config.media as any} initialValues={config.media as any}
onSubmit={console.log} onSubmit={console.log}
options={{ debug: true }} options={{ debug: true }}
/*validateOn="change"*/ />*/}
/>
{/*<Form {/*<Form
schema={removeKeyRecursively(schema.media, "pattern") as any} schema={removeKeyRecursively(schema.media, "pattern") as any}
@@ -333,7 +362,7 @@ function CustomMediaForm() {
<AnyOf.Root path="adapter"> <AnyOf.Root path="adapter">
<CustomMediaFormAdapter /> <CustomMediaFormAdapter />
</AnyOf.Root> </AnyOf.Root>
{/*<FormDebug force />*/} <FormDebug force />
</Form> </Form>
); );
} }