Files
bknd/app/src/ui/components/form/json-schema-form/Field.tsx
dswbx 6624927286 enhance form field components and add JsonEditor support
- Updated `ObjectField`, `ArrayField`, and `FieldWrapper` components to improve flexibility and integration options by supporting additional props like `wrapperProps`.
- Added `JsonEditor` for enhanced object editing capabilities with state management and safety checks.
- Refactored utility functions and error handling for improved stability and developer experience.
- Introduced new test cases to validate `JsonEditor` functionality and schema-based forms handling.
2025-10-14 16:36:16 +02:00

231 lines
6.6 KiB
TypeScript

import type { JsonSchema } from "json-schema-library";
import {
type ChangeEvent,
type ComponentPropsWithoutRef,
type ElementType,
type ReactNode,
useEffect,
useId,
} from "react";
import ErrorBoundary from "ui/components/display/ErrorBoundary";
import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event";
import { ArrayField } from "./ArrayField";
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
import { useDerivedFieldContext, useFormValue, type DeriveFn } from "./Form";
import { ObjectField } from "./ObjectField";
import { coerce, firstDefined, isType, isTypeSchema } from "./utils";
export type FieldProps = {
onChange?: (e: ChangeEvent<any>) => void;
placeholder?: string;
disabled?: boolean;
inputProps?: Partial<FieldComponentProps>;
} & Omit<FieldwrapperProps, "children" | "schema">;
export const Field = (props: FieldProps) => {
return (
<ErrorBoundary fallback={fieldErrorBoundary(props)}>
<FieldImpl {...props} />
</ErrorBoundary>
);
};
export const HiddenField = ({
as = "div",
className,
...props
}: FieldProps & { as?: ElementType; className?: string }) => {
const Component = as;
return (
<Component className={[className, "hidden"].filter(Boolean).join(" ")}>
<Field {...props} />
</Component>
);
};
const fieldErrorBoundary =
({ name }: FieldProps) =>
({ error }: { error: Error }) => (
<Pre>
Field "{name}" error: {error.message}
</Pre>
);
const FieldImpl = ({
name,
onChange,
placeholder,
required: _required,
inputProps,
...props
}: FieldProps) => {
const { path, setValue, schema, ...ctx } = useDerivedFieldContext(name);
const id = `${name}-${useId()}`;
const required = typeof _required === "boolean" ? _required : ctx.required;
if (!schema)
return (
<Pre>
[Field] {path} has no schema ({JSON.stringify(schema)})
</Pre>
);
if (isType(schema.type, "object")) {
return <ObjectField path={name} wrapperProps={props} />;
}
if (isType(schema.type, "array")) {
return <ArrayField path={name} />;
}
// account for `defaultValue`
// like <Field name="name" inputProps={{ defaultValue: "oauth" }} />
useEffect(() => {
if (inputProps?.defaultValue) {
setValue(path, inputProps.defaultValue);
}
}, [inputProps?.defaultValue]);
const disabled = firstDefined(
ctx.readOnly,
inputProps?.disabled,
props.disabled,
schema.readOnly,
"const" in schema,
false,
);
const handleChange = useEvent((e: ChangeEvent<HTMLInputElement>) => {
const value = coerce(e.target.value, schema as any, { required });
if (typeof value === "undefined" && !required && ctx.options?.keepEmpty !== true) {
ctx.deleteValue(path);
} else {
setValue(path, value);
}
});
return (
<FieldWrapper name={name} required={required} schema={schema} fieldId={id} {...props}>
<FieldComponent
placeholder={placeholder}
{...inputProps}
id={id}
schema={schema}
name={name}
required={required}
disabled={disabled}
onChange={onChange ?? handleChange}
/>
</FieldWrapper>
);
};
export const Pre = ({ children }) => (
<pre className="dark:bg-red-950 bg-red-100 rounded-md px-3 py-1.5 text-wrap whitespace-break-spaces break-all">
{children}
</pre>
);
export type FieldComponentProps = {
schema: JsonSchema;
render?: (props: Omit<FieldComponentProps, "render">) => ReactNode;
"data-testId"?: string;
} & ComponentPropsWithoutRef<"input"> & { [key: `data-${string}`]: string };
export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProps) => {
const { value } = useFormValue(_props.name!, { strict: true });
if (!isTypeSchema(schema)) return null;
const props = {
..._props,
// allow override
value: typeof _props.value !== "undefined" ? _props.value : value,
placeholder: _props.placeholder
? _props.placeholder
: typeof schema.default !== "undefined"
? String(schema.default)
: "",
};
if (render) return render({ schema, ...props });
if (schema.enum) {
return <Formy.Select options={schema.enum} {...(props as any)} />;
}
if (isType(schema.type, ["number", "integer"])) {
const additional = {
min: schema.minimum,
max: schema.maximum,
step: schema.multipleOf,
};
return <Formy.Input type="number" {...props} value={props.value ?? ""} {...additional} />;
}
if (isType(schema.type, "boolean")) {
return <Formy.Switch {...(props as any)} checked={value === true} />;
}
if (isType(schema.type, "string") && schema.format === "date-time") {
const value = props.value ? new Date(props.value as string).toISOString().slice(0, 16) : "";
return (
<Formy.DateInput
{...props}
value={value}
type="datetime-local"
onChange={(e) => {
const date = new Date(e.target.value);
props.onChange?.({
// @ts-ignore
target: { value: date.toISOString() },
});
}}
/>
);
}
if (isType(schema.type, "string") && schema.format === "date") {
return <Formy.DateInput {...props} value={props.value ?? ""} />;
}
const additional = {
maxLength: schema.maxLength,
minLength: schema.minLength,
pattern: schema.pattern,
} as any;
if (schema.format) {
if (["password", "hidden", "url", "email", "tel"].includes(schema.format)) {
additional.type = schema.format;
}
}
return <Formy.TypeAwareInput {...props} value={props.value ?? ""} {...additional} />;
};
export type CustomFieldProps<Data = any> = {
path: string;
valueStrict?: boolean;
deriveFn?: DeriveFn<Data>;
children: (
props: Omit<ReturnType<typeof useDerivedFieldContext<Data>>, "setValue"> &
ReturnType<typeof useFormValue> & {
setValue: (value: any) => void;
_setValue: (path: string, value: any) => void;
},
) => React.ReactNode;
};
export const CustomField = <Data = any>({
path: _path,
valueStrict = true,
deriveFn,
children,
}: CustomFieldProps<Data>) => {
const ctx = useDerivedFieldContext(_path, deriveFn);
const $value = useFormValue(ctx.path, { strict: valueStrict });
const setValue = (value: any) => ctx.setValue(ctx.path, value);
return children({ ...ctx, ...$value, setValue, _setValue: ctx.setValue });
};