mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
initial json schema form implementation
This commit is contained in:
27
app/__test__/core/object/utils.spec.ts
Normal file
27
app/__test__/core/object/utils.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as utils from "../../../src/core/utils/objects";
|
||||
|
||||
describe("object utils", () => {
|
||||
test("flattenObject", () => {
|
||||
const obj = {
|
||||
a: {
|
||||
b: {
|
||||
c: 1,
|
||||
a: ["a", "b", "c"]
|
||||
}
|
||||
},
|
||||
d: [1, 2, { e: 3 }]
|
||||
};
|
||||
|
||||
console.log("flat", utils.flattenObject2(obj));
|
||||
expect(utils.flattenObject2(obj)).toEqual({
|
||||
"a.b.c": 1,
|
||||
"a.b.a[0]": "a",
|
||||
"a.b.a[1]": "b",
|
||||
"a.b.a[2]": "c",
|
||||
"d[0]": 1,
|
||||
"d[1]": 2,
|
||||
"d[2].e": 3
|
||||
});
|
||||
});
|
||||
});
|
||||
146
app/__test__/ui/json-form.spec.ts
Normal file
146
app/__test__/ui/json-form.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Draft2019 } from "json-schema-library";
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import * as utils from "../../src/ui/components/form/json-schema-form2/utils";
|
||||
|
||||
describe("json form", () => {
|
||||
test("normalize path", () => {
|
||||
const examples = [
|
||||
["description", "#/description"],
|
||||
["/description", "#/description"],
|
||||
["nested/property", "#/nested/property"],
|
||||
["nested.property", "#/nested/property"],
|
||||
["nested.property[0]", "#/nested/property/0"],
|
||||
["nested.property[0].name", "#/nested/property/0/name"]
|
||||
];
|
||||
|
||||
for (const [input, output] of examples) {
|
||||
expect(utils.normalizePath(input)).toBe(output);
|
||||
}
|
||||
});
|
||||
|
||||
test("coerse", () => {
|
||||
const examples = [
|
||||
["test", { type: "string" }, "test"],
|
||||
["1", { type: "integer" }, 1],
|
||||
["1", { type: "number" }, 1],
|
||||
["true", { type: "boolean" }, true],
|
||||
["false", { type: "boolean" }, false],
|
||||
["1", { type: "boolean" }, true],
|
||||
["0", { type: "boolean" }, false],
|
||||
["on", { type: "boolean" }, true],
|
||||
["off", { type: "boolean" }, false],
|
||||
["null", { type: "null" }, null]
|
||||
] satisfies [string, Exclude<JSONSchema, boolean>, any][];
|
||||
|
||||
for (const [input, schema, output] of examples) {
|
||||
expect(utils.coerce(input, schema)).toBe(output);
|
||||
}
|
||||
});
|
||||
|
||||
test("getParentPointer", () => {
|
||||
const examples = [
|
||||
["#/nested/property/0/name", "#/nested/property/0"],
|
||||
["#/nested/property/0", "#/nested/property"],
|
||||
["#/nested/property", "#/nested"],
|
||||
["#/nested", "#"]
|
||||
];
|
||||
|
||||
for (const [input, output] of examples) {
|
||||
expect(utils.getParentPointer(input)).toBe(output);
|
||||
}
|
||||
});
|
||||
|
||||
test("isRequired", () => {
|
||||
const examples = [
|
||||
[
|
||||
"#/description",
|
||||
{ type: "object", properties: { description: { type: "string" } } },
|
||||
false
|
||||
],
|
||||
[
|
||||
"#/description",
|
||||
{
|
||||
type: "object",
|
||||
required: ["description"],
|
||||
properties: { description: { type: "string" } }
|
||||
},
|
||||
true
|
||||
],
|
||||
[
|
||||
"#/nested/property",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: {
|
||||
type: "object",
|
||||
properties: { property: { type: "string" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
false
|
||||
],
|
||||
[
|
||||
"#/nested/property",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: {
|
||||
type: "object",
|
||||
required: ["property"],
|
||||
properties: { property: { type: "string" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
true
|
||||
]
|
||||
] satisfies [string, Exclude<JSONSchema, boolean>, boolean][];
|
||||
|
||||
for (const [pointer, schema, output] of examples) {
|
||||
expect(utils.isRequired(pointer, schema)).toBe(output);
|
||||
}
|
||||
});
|
||||
|
||||
test("unflatten", () => {
|
||||
const examples = [
|
||||
[
|
||||
{ "#/description": "test" },
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
description: { type: "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
description: "test"
|
||||
}
|
||||
]
|
||||
] satisfies [Record<string, string>, Exclude<JSONSchema, boolean>, object][];
|
||||
|
||||
for (const [input, schema, output] of examples) {
|
||||
expect(utils.unflatten(input, schema)).toEqual(output);
|
||||
}
|
||||
});
|
||||
|
||||
test.only("...", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", maxLength: 2 },
|
||||
description: { type: "string", maxLength: 2 },
|
||||
age: { type: "number", description: "Age of you" },
|
||||
deep: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: { type: "string", maxLength: 2 }
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["description"]
|
||||
};
|
||||
|
||||
const lib = new Draft2019(schema);
|
||||
|
||||
lib.eachSchema(console.log);
|
||||
});
|
||||
});
|
||||
@@ -33,26 +33,28 @@
|
||||
"license": "FSL-1.1-MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^2.0.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-liquid": "^6.2.1",
|
||||
"@hello-pangea/dnd": "^17.0.0",
|
||||
"@libsql/client": "^0.14.0",
|
||||
"@mantine/core": "^7.13.4",
|
||||
"@sinclair/typebox": "^0.32.34",
|
||||
"@tanstack/react-form": "0.19.2",
|
||||
"@uiw/react-codemirror": "^4.23.6",
|
||||
"@xyflow/react": "^12.3.2",
|
||||
"aws4fetch": "^1.0.18",
|
||||
"dayjs": "^1.11.13",
|
||||
"fast-xml-parser": "^4.4.0",
|
||||
"hono": "^4.6.12",
|
||||
"json-schema-form-react": "link:json-schema-form-react",
|
||||
"json-schema-library": "^10.0.0-rc7",
|
||||
"kysely": "^0.27.4",
|
||||
"liquidjs": "^10.15.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
"swr": "^2.2.5",
|
||||
"json-schema-form-react": "^0.0.2",
|
||||
"@uiw/react-codemirror": "^4.23.6",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-liquid": "^6.2.1",
|
||||
"@xyflow/react": "^12.3.2",
|
||||
"@mantine/core": "^7.13.4",
|
||||
"@hello-pangea/dnd": "^17.0.0"
|
||||
"object-path-immutable": "^4.1.2",
|
||||
"swr": "^2.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.613.0",
|
||||
@@ -73,6 +75,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"esbuild-postcss": "^0.0.4",
|
||||
"jotai": "^2.10.1",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"open": "^10.1.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"postcss": "^8.4.47",
|
||||
|
||||
@@ -166,6 +166,29 @@ export function flattenObject(obj: any, parentKey = "", result: any = {}): any {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function flattenObject2(obj: any, parentKey = "", result: any = {}): any {
|
||||
for (const key in obj) {
|
||||
if (key in obj) {
|
||||
const newKey = parentKey ? `${parentKey}.${key}` : key;
|
||||
if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) {
|
||||
flattenObject2(obj[key], newKey, result);
|
||||
} else if (Array.isArray(obj[key])) {
|
||||
obj[key].forEach((item, index) => {
|
||||
const arrayKey = `${newKey}[${index}]`;
|
||||
if (typeof item === "object" && item !== null) {
|
||||
flattenObject2(item, arrayKey, result);
|
||||
} else {
|
||||
result[arrayKey] = item;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
result[newKey] = obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function objectDepth(object: object): number {
|
||||
let level = 1;
|
||||
for (const key in object) {
|
||||
|
||||
@@ -84,6 +84,9 @@ export function identifierToHumanReadable(str: string) {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
export function autoFormatString(str: string) {
|
||||
return identifierToHumanReadable(str);
|
||||
}
|
||||
|
||||
export function kebabToPascalWithSpaces(str: string): string {
|
||||
return str.split("-").map(ucFirst).join(" ");
|
||||
|
||||
33
app/src/ui/client/schema/media/use-bknd-media.ts
Normal file
33
app/src/ui/client/schema/media/use-bknd-media.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
|
||||
export function useBkndMedia() {
|
||||
const { config, schema, actions: bkndActions } = useBknd();
|
||||
|
||||
const actions = {
|
||||
/*roles: {
|
||||
add: async (name: string, data: any = {}) => {
|
||||
console.log("add role", name, data);
|
||||
return await bkndActions.add("auth", `roles.${name}`, data);
|
||||
},
|
||||
patch: async (name: string, data: any) => {
|
||||
console.log("patch role", name, data);
|
||||
return await bkndActions.patch("auth", `roles.${name}`, data);
|
||||
},
|
||||
delete: async (name: string) => {
|
||||
console.log("delete role", name);
|
||||
if (window.confirm(`Are you sure you want to delete the role "${name}"?`)) {
|
||||
return await bkndActions.remove("auth", `roles.${name}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}*/
|
||||
};
|
||||
const $media = {};
|
||||
|
||||
return {
|
||||
$media,
|
||||
config: config.media,
|
||||
schema: schema.media,
|
||||
actions
|
||||
};
|
||||
}
|
||||
@@ -3,9 +3,10 @@ import { forwardRef, useEffect, useState } from "react";
|
||||
|
||||
export const BooleanInputMantine = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
(props, ref) => {
|
||||
const [checked, setChecked] = useState(Boolean(props.value));
|
||||
const [checked, setChecked] = useState(Boolean(props.value ?? props.defaultValue));
|
||||
|
||||
useEffect(() => {
|
||||
console.log("value change", props.value);
|
||||
setChecked(Boolean(props.value));
|
||||
}, [props.value]);
|
||||
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import { getBrowser } from "core/utils";
|
||||
import type { Field } from "data";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import {
|
||||
type ElementType,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
|
||||
export const Group: React.FC<React.ComponentProps<"div"> & { error?: boolean }> = ({
|
||||
export const Group = <E extends ElementType = "div">({
|
||||
error,
|
||||
as,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
}: React.ComponentProps<E> & { error?: boolean; as?: E }) => {
|
||||
const Tag = as || "div";
|
||||
|
||||
return (
|
||||
<Tag
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col gap-1.5",
|
||||
|
||||
as === "fieldset" && "border border-primary/10 p-3 rounded-md",
|
||||
error && "text-red-500",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export const formElementFactory = (element: string, props: any) => {
|
||||
switch (element) {
|
||||
@@ -34,7 +46,21 @@ export const formElementFactory = (element: string, props: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const Label: React.FC<React.ComponentProps<"label">> = (props) => <label {...props} />;
|
||||
export const Label = <E extends ElementType = "label">({
|
||||
as,
|
||||
...props
|
||||
}: React.ComponentProps<E> & { as?: E }) => {
|
||||
const Tag = as || "label";
|
||||
return <Tag {...props} />;
|
||||
};
|
||||
|
||||
export const Help: React.FC<React.ComponentProps<"div">> = ({ className, ...props }) => (
|
||||
<div {...props} className={twMerge("text-sm text-primary/50", className)} />
|
||||
);
|
||||
|
||||
export const ErrorMessage: React.FC<React.ComponentProps<"div">> = ({ className, ...props }) => (
|
||||
<div {...props} className={twMerge("text-sm text-red-500", className)} />
|
||||
);
|
||||
|
||||
export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field }> = ({
|
||||
field,
|
||||
@@ -145,8 +171,12 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
|
||||
}
|
||||
);
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, React.ComponentProps<"select">>(
|
||||
(props, ref) => (
|
||||
export const Select = forwardRef<
|
||||
HTMLSelectElement,
|
||||
React.ComponentProps<"select"> & {
|
||||
options?: { value: string; label: string }[] | (string | number)[];
|
||||
}
|
||||
>(({ children, options, ...props }, ref) => (
|
||||
<div className="flex w-full relative">
|
||||
<select
|
||||
{...props}
|
||||
@@ -154,11 +184,32 @@ export const Select = forwardRef<HTMLSelectElement, React.ComponentProps<"select
|
||||
className={twMerge(
|
||||
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
||||
"appearance-none h-11 w-full",
|
||||
"border-r-8 border-r-transparent",
|
||||
!props.multiple && "border-r-8 border-r-transparent",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{options ? (
|
||||
<>
|
||||
{!props.required && <option value="" />}
|
||||
{options
|
||||
.map((o, i) => {
|
||||
if (typeof o !== "object") {
|
||||
return { value: o, label: String(o) };
|
||||
}
|
||||
return o;
|
||||
})
|
||||
.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</select>
|
||||
{!props.multiple && (
|
||||
<TbChevronDown className="absolute right-3 top-0 bottom-0 h-full opacity-70" size={18} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
));
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Switch } from "@mantine/core";
|
||||
import { autoFormatString } from "core/utils";
|
||||
import { type JSONSchema, useFieldContext, useFormContext } from "json-schema-form-react";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
|
||||
// make a local version of JSONSchema that is always an object
|
||||
export type FieldProps = JSONSchema & {
|
||||
name: string;
|
||||
defaultValue?: any;
|
||||
hidden?: boolean;
|
||||
overrides?: ComponentPropsWithoutRef<"input">;
|
||||
};
|
||||
|
||||
export function Field(p: FieldProps) {
|
||||
const { schema, defaultValue, required } = useFieldContext(p.name);
|
||||
const props = {
|
||||
...(typeof schema === "object" ? schema : {}),
|
||||
defaultValue,
|
||||
required,
|
||||
...p
|
||||
} as FieldProps;
|
||||
console.log("schema", p.name, schema, defaultValue);
|
||||
|
||||
const field = renderField(props);
|
||||
const label = props.title
|
||||
? props.title
|
||||
: autoFormatString(
|
||||
props.name?.includes(".") ? (props.name.split(".").pop() as string) : props.name
|
||||
);
|
||||
|
||||
return p.hidden ? (
|
||||
field
|
||||
) : (
|
||||
<Formy.Group>
|
||||
<Formy.Label>
|
||||
{label}
|
||||
{props.required ? " *" : ""}
|
||||
</Formy.Label>
|
||||
{field}
|
||||
{props.description ? <Formy.Help>{props.description}</Formy.Help> : null}
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function isType(_type: JSONSchema["type"], _compare: JSONSchema["type"]) {
|
||||
if (!_type || !_compare) return false;
|
||||
const type = Array.isArray(_type) ? _type : [_type];
|
||||
const compare = Array.isArray(_compare) ? _compare : [_compare];
|
||||
return compare.some((t) => type.includes(t));
|
||||
}
|
||||
|
||||
function renderField(props: FieldProps) {
|
||||
//console.log("renderField", props.name, props);
|
||||
const common = {
|
||||
name: props.name,
|
||||
defaultValue: typeof props.defaultValue !== "undefined" ? props.defaultValue : props.default
|
||||
} as any;
|
||||
|
||||
if (props.hidden) {
|
||||
common.type = "hidden";
|
||||
}
|
||||
|
||||
if (isType(props.type, "boolean")) {
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<Switch
|
||||
disabled={props.disabled}
|
||||
id={props.id}
|
||||
defaultChecked={props.defaultValue}
|
||||
name={props.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (isType(props.type, ["number", "integer"])) {
|
||||
return <Formy.Input type="number" {...common} />;
|
||||
}
|
||||
|
||||
return <Formy.Input type="text" {...common} />;
|
||||
}
|
||||
|
||||
export function AutoForm({ schema, prefix = "" }: { schema: JSONSchema; prefix?: string }) {
|
||||
const required = schema.required ?? [];
|
||||
const properties = schema.properties ?? {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*<pre>{JSON.stringify(schema, null, 2)}</pre>;*/}
|
||||
<div>
|
||||
{Object.keys(properties).map((name) => {
|
||||
const field = properties[name];
|
||||
const _name = `${prefix ? prefix + "." : ""}${name}`;
|
||||
return <Field key={_name} name={_name} {...(field as any)} />;
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
2
app/src/ui/components/form/json-schema-form/index.ts
Normal file
2
app/src/ui/components/form/json-schema-form/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TypeboxValidator, type ValueError } from "./validators/tb-validator";
|
||||
export { CfValidator, type OutputUnit } from "./validators/cf-validator";
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type Schema as JsonSchema, type OutputUnit, Validator } from "@cfworker/json-schema";
|
||||
import type { Validator as TValidator } from "json-schema-form-react";
|
||||
|
||||
export class CfValidator implements TValidator<OutputUnit> {
|
||||
async validate(schema: JsonSchema, data: any) {
|
||||
const result = new Validator(schema).validate(data);
|
||||
return result.errors;
|
||||
}
|
||||
}
|
||||
|
||||
export type { OutputUnit };
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ValueError } from "@sinclair/typebox/value";
|
||||
import { type TSchema, Value } from "core/utils";
|
||||
import type { Validator } from "json-schema-form-react";
|
||||
|
||||
export class TypeboxValidator implements Validator<ValueError> {
|
||||
async validate(schema: TSchema, data: any) {
|
||||
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
|
||||
}
|
||||
}
|
||||
|
||||
export type { ValueError };
|
||||
192
app/src/ui/components/form/json-schema-form2/Form.tsx
Normal file
192
app/src/ui/components/form/json-schema-form2/Form.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Draft2019, type JsonError, type JsonSchema as LibJsonSchema } from "json-schema-library";
|
||||
import type { JSONSchema as $JSONSchema, FromSchema } from "json-schema-to-ts";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
type FormEvent,
|
||||
createContext,
|
||||
startTransition,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { flatten, getFormTarget, isRequired, normalizePath, unflatten } from "./utils";
|
||||
|
||||
type JSONSchema = Exclude<$JSONSchema, boolean>;
|
||||
type TFormData = Record<string, string>;
|
||||
|
||||
export type FormProps<
|
||||
Schema extends JSONSchema = JSONSchema,
|
||||
Data = Schema extends JSONSchema ? FromSchema<JSONSchema> : any
|
||||
> = Omit<ComponentPropsWithoutRef<"form">, "onChange"> & {
|
||||
schema: Schema;
|
||||
validateOn?: "change" | "submit";
|
||||
initialValues?: Partial<Data>;
|
||||
onChange?: (data: Partial<Data>, name: string, value: any) => void;
|
||||
hiddenSubmit?: boolean;
|
||||
};
|
||||
|
||||
export type FormContext = {
|
||||
data: TFormData;
|
||||
setData: (data: TFormData) => void;
|
||||
errors: JsonError[];
|
||||
schema: JSONSchema;
|
||||
lib: Draft2019;
|
||||
select: (pointer: string, choice: number | undefined) => void;
|
||||
selections: Record<string, number | undefined>;
|
||||
};
|
||||
|
||||
const FormContext = createContext<FormContext>(undefined!);
|
||||
|
||||
export function Form<
|
||||
Schema extends JSONSchema = JSONSchema,
|
||||
Data = Schema extends JSONSchema ? FromSchema<JSONSchema> : any
|
||||
>({
|
||||
schema: _schema,
|
||||
initialValues: _initialValues,
|
||||
children,
|
||||
onChange,
|
||||
validateOn = "submit",
|
||||
hiddenSubmit = true,
|
||||
...props
|
||||
}: FormProps<Schema, Data>) {
|
||||
const schema = useMemo(() => _schema, [JSON.stringify(_schema)]);
|
||||
const initialValues = useMemo(
|
||||
() => (_initialValues ? flatten(_initialValues) : {}),
|
||||
[JSON.stringify(_initialValues)]
|
||||
);
|
||||
|
||||
const [data, setData] = useState<TFormData>(initialValues);
|
||||
const [errors, setErrors] = useState<JsonError[]>([]);
|
||||
const [selections, setSelections] = useState<Record<string, number | undefined>>({});
|
||||
const lib = new Draft2019(schema);
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("setting", initialValues);
|
||||
if (formRef.current) {
|
||||
Object.entries(initialValues).forEach(([name, value]) => {
|
||||
const pointer = normalizePath(name);
|
||||
const input = formRef.current?.elements.namedItem(pointer);
|
||||
if (input && "value" in input) {
|
||||
input.value = value as any;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [initialValues]);
|
||||
|
||||
async function handleChange(e: FormEvent<HTMLFormElement>) {
|
||||
const target = getFormTarget(e);
|
||||
if (!target) return;
|
||||
const name = normalizePath(target.name);
|
||||
|
||||
startTransition(() => {
|
||||
const newData = { ...data, [name]: target.value };
|
||||
setData(newData);
|
||||
|
||||
const actual = unflatten(newData, schema, selections);
|
||||
if (validateOn === "change") {
|
||||
validate(actual);
|
||||
}
|
||||
|
||||
onChange?.(actual, name, target.value);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const actual = unflatten(data, schema, selections);
|
||||
const { data: newData, errors } = validate(actual);
|
||||
setData(newData);
|
||||
console.log("submit", newData, errors);
|
||||
return false;
|
||||
}
|
||||
|
||||
function validate(_data?: object) {
|
||||
const actual = _data ?? unflatten(data, schema, selections);
|
||||
const errors = lib.validate(actual);
|
||||
console.log("validate", actual, errors);
|
||||
setErrors(errors);
|
||||
return {
|
||||
data: actual,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
function select(pathOrPointer: string, choice: number | undefined) {
|
||||
setSelections((prev) => ({ ...prev, [normalizePath(pathOrPointer)]: choice }));
|
||||
}
|
||||
|
||||
const context = {
|
||||
data,
|
||||
setData,
|
||||
select,
|
||||
selections,
|
||||
errors,
|
||||
schema,
|
||||
lib
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form {...props} ref={formRef} onChange={handleChange} onSubmit={handleSubmit}>
|
||||
<FormContext.Provider value={context}>{children}</FormContext.Provider>
|
||||
{hiddenSubmit && (
|
||||
<button style={{ visibility: "hidden" }} type="submit">
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||
<pre>{JSON.stringify(unflatten(data, schema, selections), null, 2)}</pre>
|
||||
<pre>{JSON.stringify(errors, null, 2)}</pre>
|
||||
<pre>{JSON.stringify(selections, null, 2)}</pre>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFormContext() {
|
||||
return useContext(FormContext);
|
||||
}
|
||||
|
||||
export function useFieldContext(name: string) {
|
||||
const { data, setData, lib, schema, errors: formErrors, select, selections } = useFormContext();
|
||||
const pointer = normalizePath(name);
|
||||
//console.log("pointer", pointer);
|
||||
const value = data[pointer];
|
||||
const errors = formErrors.filter((error) => error.data.pointer === pointer);
|
||||
const fieldSchema = pointer === "#/" ? (schema as LibJsonSchema) : lib.getSchema({ pointer });
|
||||
const required = isRequired(pointer, schema);
|
||||
|
||||
return {
|
||||
value,
|
||||
setValue: (value: any) => setData({ ...data, [name]: value }),
|
||||
errors,
|
||||
schema: fieldSchema,
|
||||
pointer,
|
||||
required,
|
||||
select,
|
||||
selections
|
||||
};
|
||||
}
|
||||
|
||||
export function usePrefixContext(prefix: string) {
|
||||
const { data, setData, lib, schema, errors: formErrors, select, selections } = useFormContext();
|
||||
const pointer = normalizePath(prefix);
|
||||
const value = Object.fromEntries(Object.entries(data).filter(([key]) => key.startsWith(prefix)));
|
||||
const errors = formErrors.filter((error) => error.data.pointer.startsWith(pointer));
|
||||
const fieldSchema = pointer === "#/" ? (schema as LibJsonSchema) : lib.getSchema({ pointer });
|
||||
const required = isRequired(pointer, schema);
|
||||
|
||||
return {
|
||||
value,
|
||||
//setValue: (value: any) => setData({ ...data, [name]: value }),
|
||||
errors,
|
||||
schema: fieldSchema,
|
||||
pointer,
|
||||
required,
|
||||
select,
|
||||
selections
|
||||
};
|
||||
}
|
||||
198
app/src/ui/components/form/json-schema-form2/utils.ts
Normal file
198
app/src/ui/components/form/json-schema-form2/utils.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { autoFormatString } from "core/utils";
|
||||
import { Draft2019, type JsonSchema } from "json-schema-library";
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import type { JSONSchemaType } from "json-schema-to-ts/lib/types/definitions/jsonSchema";
|
||||
import { set } from "lodash-es";
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
export function getFormTarget(e: FormEvent<HTMLFormElement>) {
|
||||
const form = e.currentTarget;
|
||||
const target = e.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null;
|
||||
|
||||
// check if target has attribute "data-ignore" set
|
||||
// also check if target is within a "data-ignore" element
|
||||
|
||||
if (
|
||||
!target ||
|
||||
!form.contains(target) ||
|
||||
!target.name ||
|
||||
target.hasAttribute("data-ignore") ||
|
||||
target.closest("[data-ignore]")
|
||||
) {
|
||||
return; // Ignore events from outside the form
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function flatten(obj: any, parentKey = "", result: any = {}): any {
|
||||
for (const key in obj) {
|
||||
if (key in obj) {
|
||||
const newKey = parentKey ? `${parentKey}/${key}` : "#/" + key;
|
||||
if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) {
|
||||
flatten(obj[key], newKey, result);
|
||||
} else if (Array.isArray(obj[key])) {
|
||||
obj[key].forEach((item, index) => {
|
||||
const arrayKey = `${newKey}.${index}`;
|
||||
if (typeof item === "object" && item !== null) {
|
||||
flatten(item, arrayKey, result);
|
||||
} else {
|
||||
result[arrayKey] = item;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
result[newKey] = obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// @todo: make sure it's in the right order
|
||||
export function unflatten(
|
||||
obj: Record<string, string>,
|
||||
schema: JSONSchema,
|
||||
selections?: Record<string, number | undefined>
|
||||
) {
|
||||
const result = {};
|
||||
const lib = new Draft2019(schema as any);
|
||||
for (const pointer in obj) {
|
||||
const required = isRequired(pointer, schema);
|
||||
let subschema = lib.getSchema({ pointer });
|
||||
console.log("subschema", pointer, subschema, selections);
|
||||
if (!subschema) {
|
||||
throw new Error(`"${pointer}" not found in schema`);
|
||||
}
|
||||
|
||||
// if subschema starts with "anyOf" or "oneOf"
|
||||
if (subschema.anyOf || subschema.oneOf) {
|
||||
const selected = selections?.[pointer];
|
||||
if (selected !== undefined) {
|
||||
subschema = subschema.anyOf ? subschema.anyOf[selected] : subschema.oneOf![selected];
|
||||
}
|
||||
}
|
||||
|
||||
const value = coerce(obj[pointer], subschema as any, { required });
|
||||
|
||||
set(result, pointer.substring(2).replace(/\//g, "."), value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function coerce(
|
||||
value: any,
|
||||
schema: Exclude<JSONSchema, boolean>,
|
||||
opts?: { required?: boolean }
|
||||
) {
|
||||
if (!value && typeof opts?.required === "boolean" && !opts.required) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "string":
|
||||
return String(value);
|
||||
case "integer":
|
||||
case "number":
|
||||
return Number(value);
|
||||
case "boolean":
|
||||
return ["true", "1", 1, "on"].includes(value);
|
||||
case "null":
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* normalizes any path to a full json pointer
|
||||
*
|
||||
* examples: in -> out
|
||||
* description -> #/description
|
||||
* #/description -> #/description
|
||||
* /description -> #/description
|
||||
* nested/property -> #/nested/property
|
||||
* nested.property -> #/nested/property
|
||||
* nested.property[0] -> #/nested/property/0
|
||||
* nested.property[0].name -> #/nested/property/0/name
|
||||
* @param path
|
||||
*/
|
||||
export function normalizePath(path: string) {
|
||||
return path.startsWith("#/")
|
||||
? path
|
||||
: `#/${path.replace(/#?\/?/, "").replace(/\./g, "/").replace(/\[/g, "/").replace(/\]/g, "")}`;
|
||||
}
|
||||
|
||||
export function getParentPointer(pointer: string) {
|
||||
return pointer.substring(0, pointer.lastIndexOf("/"));
|
||||
}
|
||||
|
||||
export function isRequired(pointer: string, schema: JSONSchema, data?: any) {
|
||||
if (pointer === "#/") {
|
||||
return false;
|
||||
}
|
||||
const lib = new Draft2019(schema as any);
|
||||
|
||||
const childSchema = lib.getSchema({ pointer, data });
|
||||
if (typeof childSchema === "object" && ("const" in childSchema || "enum" in childSchema)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parentPointer = getParentPointer(pointer);
|
||||
const parentSchema = lib.getSchema({ pointer: parentPointer, data });
|
||||
const required = parentSchema?.required?.includes(pointer.split("/").pop()!);
|
||||
|
||||
console.log("isRequired", {
|
||||
pointer,
|
||||
parentPointer,
|
||||
parent: parentSchema ? JSON.parse(JSON.stringify(parentSchema)) : null,
|
||||
required
|
||||
});
|
||||
|
||||
return !!required;
|
||||
}
|
||||
|
||||
type TType = JSONSchemaType | JSONSchemaType[] | readonly JSONSchemaType[] | undefined;
|
||||
export function isType(_type: TType, _compare: TType) {
|
||||
if (!_type || !_compare) return false;
|
||||
const type = Array.isArray(_type) ? _type : [_type];
|
||||
const compare = Array.isArray(_compare) ? _compare : [_compare];
|
||||
return compare.some((t) => type.includes(t));
|
||||
}
|
||||
|
||||
export function getLabel(name: string, schema: JSONSchema) {
|
||||
if (typeof schema === "object" && "title" in schema) return schema.title;
|
||||
const label = name.includes("/") ? (name.split("/").pop() ?? "") : name;
|
||||
return autoFormatString(label);
|
||||
}
|
||||
|
||||
export function getMultiSchema(schema: JSONSchema): Exclude<JSONSchema, boolean>[] | undefined {
|
||||
if (!schema || typeof schema !== "object") return;
|
||||
return (schema.anyOf ?? schema.oneOf) as any;
|
||||
}
|
||||
|
||||
export function getMultiSchemaMatched(
|
||||
schema: JsonSchema,
|
||||
data: any
|
||||
): [number, Exclude<JSONSchema, boolean>[], Exclude<JSONSchema, boolean> | undefined] {
|
||||
const multiSchema = getMultiSchema(schema);
|
||||
if (!multiSchema) return [-1, [], undefined];
|
||||
const index = multiSchema.findIndex((subschema) => {
|
||||
const lib = new Draft2019(subschema as any);
|
||||
return lib.validate(data, subschema).length === 0;
|
||||
});
|
||||
if (index === -1) return [-1, multiSchema, undefined];
|
||||
|
||||
return [index, multiSchema, multiSchema[index]];
|
||||
}
|
||||
|
||||
export function removeKeyRecursively<Given extends object>(obj: Given, keyToRemove: string): Given {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => removeKeyRecursively(item, keyToRemove)) as any;
|
||||
} else if (typeof obj === "object" && obj !== null) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj)
|
||||
.filter(([key]) => key !== keyToRemove)
|
||||
.map(([key, value]) => [key, removeKeyRecursively(value, keyToRemove)])
|
||||
) as any;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
115
app/src/ui/components/form/json-schema-form3/AnyOfField.tsx
Normal file
115
app/src/ui/components/form/json-schema-form3/AnyOfField.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import { type ChangeEvent, type ReactNode, createContext, useContext, useState } from "react";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field";
|
||||
import { useFieldContext } from "./Form";
|
||||
import { getLabel, getMultiSchemaMatched } from "./utils";
|
||||
|
||||
export type AnyOfFieldRootProps = {
|
||||
path?: string;
|
||||
schema?: Exclude<JSONSchema, boolean>;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export type AnyOfFieldContext = {
|
||||
path: string;
|
||||
schema: Exclude<JSONSchema, boolean>;
|
||||
schemas?: JSONSchema[];
|
||||
selectedSchema?: Exclude<JSONSchema, boolean>;
|
||||
selected: number | null;
|
||||
select: (index: number | null) => void;
|
||||
options: string[];
|
||||
selectSchema: any;
|
||||
};
|
||||
|
||||
const AnyOfContext = createContext<AnyOfFieldContext>(undefined!);
|
||||
|
||||
export const useAnyOfContext = () => {
|
||||
const ctx = useContext(AnyOfContext);
|
||||
if (!ctx) throw new Error("useAnyOfContext: no context");
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) => {
|
||||
const { setValue, pointer, lib, value, ...ctx } = useFieldContext(path);
|
||||
const schema = _schema ?? ctx.schema;
|
||||
if (!schema) return `AnyOfField(${path}): no schema ${pointer}`;
|
||||
const [matchedIndex, schemas = []] = getMultiSchemaMatched(schema, value);
|
||||
const [selected, setSelected] = useState<number | null>(matchedIndex > -1 ? matchedIndex : null);
|
||||
const options = schemas.map((s, i) => s.title ?? `Option ${i + 1}`);
|
||||
const selectSchema = {
|
||||
enum: options
|
||||
};
|
||||
|
||||
const selectedSchema =
|
||||
selected !== null ? (schemas[selected] as Exclude<JSONSchema, boolean>) : undefined;
|
||||
|
||||
function select(index: number | null) {
|
||||
setValue(pointer, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined);
|
||||
setSelected(index);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnyOfContext.Provider
|
||||
value={{ selected, select, options, selectSchema, path, schema, schemas, selectedSchema }}
|
||||
>
|
||||
{children}
|
||||
</AnyOfContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const Select = () => {
|
||||
const { selected, select, path, schema, selectSchema } = useAnyOfContext();
|
||||
|
||||
function handleSelect(e: ChangeEvent<HTMLInputElement>) {
|
||||
console.log("selected", e.target.value);
|
||||
const i = e.target.value ? Number(e.target.value) : null;
|
||||
select(i);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formy.Label>{getLabel(path, schema)}</Formy.Label>
|
||||
<FieldComponent
|
||||
schema={selectSchema as any}
|
||||
onChange={handleSelect}
|
||||
value={selected ?? undefined}
|
||||
className="h-8 py-1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Field = ({ name, label, ...props }: Partial<FormFieldProps>) => {
|
||||
const { selected, selectedSchema, path } = useAnyOfContext();
|
||||
if (selected === null) return null;
|
||||
return (
|
||||
<FormField
|
||||
key={`${path}_${selected}`}
|
||||
schema={selectedSchema}
|
||||
name={path}
|
||||
label={false}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AnyOf = {
|
||||
Root,
|
||||
Select,
|
||||
Field,
|
||||
useContext: useAnyOfContext
|
||||
};
|
||||
|
||||
export const AnyOfField = (props: Omit<AnyOfFieldRootProps, "children">) => {
|
||||
return (
|
||||
<fieldset>
|
||||
<AnyOf.Root {...props}>
|
||||
<legend className="flex flex-row gap-2 items-center py-2">
|
||||
<AnyOf.Select />
|
||||
</legend>
|
||||
<AnyOf.Field />
|
||||
</AnyOf.Root>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
101
app/src/ui/components/form/json-schema-form3/ArrayField.tsx
Normal file
101
app/src/ui/components/form/json-schema-form3/ArrayField.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { IconLibraryPlus, IconTrash } from "@tabler/icons-react";
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import { FieldComponent } from "./Field";
|
||||
import { FieldWrapper } from "./FieldWrapper";
|
||||
import { useFieldContext } from "./Form";
|
||||
import { coerce, getMultiSchema, getMultiSchemaMatched } from "./utils";
|
||||
|
||||
export const ArrayField = ({
|
||||
path = "",
|
||||
schema: _schema
|
||||
}: { path?: string; schema?: Exclude<JSONSchema, boolean> }) => {
|
||||
const { setValue, value, pointer, required, ...ctx } = useFieldContext(path);
|
||||
const schema = _schema ?? ctx.schema;
|
||||
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
|
||||
|
||||
const itemsMultiSchema = getMultiSchema(schema.items);
|
||||
|
||||
function handleAdd(template?: any) {
|
||||
const currentIndex = value?.length ?? 0;
|
||||
const newPointer = `${path}/${currentIndex}`.replace(/\/+/g, "/");
|
||||
setValue(newPointer, template ?? ctx.lib.getTemplate(undefined, schema!.items));
|
||||
}
|
||||
|
||||
function handleUpdate(pointer: string, value: any) {
|
||||
setValue(pointer, value);
|
||||
}
|
||||
|
||||
function handleDelete(pointer: string) {
|
||||
return () => {
|
||||
ctx.deleteValue(pointer);
|
||||
};
|
||||
}
|
||||
|
||||
const Wrapper = ({ children }) => (
|
||||
<FieldWrapper pointer={path} schema={schema} wrapper="fieldset">
|
||||
{children}
|
||||
</FieldWrapper>
|
||||
);
|
||||
|
||||
if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Formy.Select
|
||||
required
|
||||
options={schema.items.enum}
|
||||
multiple
|
||||
value={value}
|
||||
className="h-auto"
|
||||
onChange={(e) => {
|
||||
const selected = Array.from(e.target.selectedOptions).map((o) => o.value);
|
||||
console.log("selected", selected);
|
||||
setValue(pointer, selected);
|
||||
}}
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
{value?.map((v, index: number) => {
|
||||
const pointer = `${path}/${index}`.replace(/\/+/g, "/");
|
||||
const [, , subschema] = getMultiSchemaMatched(schema.items, v);
|
||||
return (
|
||||
<div key={pointer} className="flex flex-row gap-2">
|
||||
<FieldComponent
|
||||
name={pointer}
|
||||
schema={subschema!}
|
||||
value={v}
|
||||
onChange={(e) => {
|
||||
handleUpdate(pointer, coerce(e.target.value, subschema!));
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<IconButton Icon={IconTrash} onClick={handleDelete(pointer)} size="sm" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{itemsMultiSchema ? (
|
||||
<Dropdown
|
||||
dropdownWrapperProps={{
|
||||
className: "min-w-0"
|
||||
}}
|
||||
items={itemsMultiSchema.map((s, i) => ({
|
||||
label: s!.title ?? `Option ${i + 1}`,
|
||||
onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!))
|
||||
}))}
|
||||
onClickItem={console.log}
|
||||
>
|
||||
<Button IconLeft={IconLibraryPlus}>Add</Button>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Button onClick={() => handleAdd()}>Add</Button>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
93
app/src/ui/components/form/json-schema-form3/Field.tsx
Normal file
93
app/src/ui/components/form/json-schema-form3/Field.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import type { ChangeEvent, ComponentPropsWithoutRef } from "react";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { ArrayField } from "./ArrayField";
|
||||
import { FieldWrapper } from "./FieldWrapper";
|
||||
import { useFieldContext } from "./Form";
|
||||
import { ObjectField } from "./ObjectField";
|
||||
import { coerce, isType } from "./utils";
|
||||
|
||||
export type FieldProps = {
|
||||
name: string;
|
||||
schema?: Exclude<JSONSchema, boolean>;
|
||||
onChange?: (e: ChangeEvent<any>) => void;
|
||||
label?: string | false;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }: FieldProps) => {
|
||||
const { pointer, value, errors, setValue, required, ...ctx } = useFieldContext(name);
|
||||
const schema = _schema ?? ctx.schema;
|
||||
if (!schema) return `"${name}" (${pointer}) has no schema`;
|
||||
|
||||
if (isType(schema.type, "object")) {
|
||||
return <ObjectField path={name} schema={schema} />;
|
||||
}
|
||||
|
||||
if (isType(schema.type, "array")) {
|
||||
return <ArrayField path={name} schema={schema} />;
|
||||
}
|
||||
|
||||
const disabled = schema.readOnly ?? "const" in schema ?? false;
|
||||
//console.log("field", name, disabled, schema, ctx.schema, _schema);
|
||||
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
// don't remove for now, causes issues in anyOf
|
||||
/*const value = coerce(e.target.value, schema as any);
|
||||
setValue(pointer, value as any);*/
|
||||
|
||||
const value = coerce(e.target.value, schema as any, { required });
|
||||
//console.log("handleChange", pointer, e.target.value, { value });
|
||||
if (!value && !required) {
|
||||
ctx.deleteValue(pointer);
|
||||
} else {
|
||||
setValue(pointer, value);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldWrapper
|
||||
pointer={pointer}
|
||||
label={_label}
|
||||
required={required}
|
||||
errors={errors}
|
||||
schema={schema}
|
||||
debug={{ value }}
|
||||
hidden={hidden}
|
||||
>
|
||||
<FieldComponent
|
||||
schema={schema}
|
||||
name={pointer}
|
||||
placeholder={pointer}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={onChange ?? handleChange}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const FieldComponent = ({
|
||||
schema,
|
||||
...props
|
||||
}: { schema: JSONSchema } & ComponentPropsWithoutRef<"input">) => {
|
||||
if (!schema || typeof schema === "boolean") return null;
|
||||
//console.log("field", props.name, props.disabled);
|
||||
|
||||
if (schema.enum) {
|
||||
if (!Array.isArray(schema.enum)) return null;
|
||||
let options = schema.enum;
|
||||
if (schema.enum.every((v) => typeof v === "string")) {
|
||||
options = schema.enum.map((v, i) => ({ value: i, label: v }));
|
||||
}
|
||||
|
||||
return <Formy.Select {...(props as any)} options={options} />;
|
||||
}
|
||||
|
||||
if (isType(schema.type, ["number", "integer"])) {
|
||||
return <Formy.Input type="number" {...props} value={props.value ?? ""} />;
|
||||
}
|
||||
|
||||
return <Formy.Input {...props} value={props.value ?? ""} />;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Popover } from "@mantine/core";
|
||||
import { IconBug } from "@tabler/icons-react";
|
||||
import type { JsonError } from "json-schema-library";
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import { Children, type ReactElement, type ReactNode, cloneElement } from "react";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { getLabel } from "./utils";
|
||||
|
||||
export type FieldwrapperProps = {
|
||||
pointer: string;
|
||||
label?: string | false;
|
||||
required?: boolean;
|
||||
errors?: JsonError[];
|
||||
schema?: Exclude<JSONSchema, boolean>;
|
||||
debug?: object;
|
||||
wrapper?: "group" | "fieldset";
|
||||
hidden?: boolean;
|
||||
children: ReactElement | ReactNode;
|
||||
};
|
||||
|
||||
export function FieldWrapper({
|
||||
pointer,
|
||||
label: _label,
|
||||
required,
|
||||
errors = [],
|
||||
schema,
|
||||
debug = {},
|
||||
wrapper,
|
||||
hidden,
|
||||
children
|
||||
}: FieldwrapperProps) {
|
||||
const examples = schema?.examples || [];
|
||||
const examplesId = `${pointer}-examples`;
|
||||
const description = schema?.description;
|
||||
const label =
|
||||
typeof _label !== "undefined" ? _label : schema ? getLabel(pointer, schema) : pointer;
|
||||
|
||||
return (
|
||||
<Formy.Group
|
||||
error={errors.length > 0}
|
||||
as={wrapper === "fieldset" ? "fieldset" : "div"}
|
||||
className={hidden ? "hidden" : "relative"}
|
||||
>
|
||||
<div className="absolute right-0 top-0">
|
||||
<Popover>
|
||||
<Popover.Target>
|
||||
<IconButton Icon={IconBug} size="xs" className="opacity-30" />
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<JsonViewer
|
||||
json={{ ...debug, pointer, required, schema, errors }}
|
||||
expand={6}
|
||||
className="p-0"
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{label && (
|
||||
<Formy.Label as={wrapper === "fieldset" ? "legend" : "label"}>
|
||||
{label} {required ? "*" : ""}
|
||||
</Formy.Label>
|
||||
)}
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
{children}
|
||||
{/*{Children.count(children) === 1
|
||||
? cloneElement(children, {
|
||||
list: examples.length > 0 ? examplesId : undefined
|
||||
})
|
||||
: children}
|
||||
{examples.length > 0 && (
|
||||
<datalist id={examplesId}>
|
||||
{examples.map((e, i) => (
|
||||
<option key={i} value={e as any} />
|
||||
))}
|
||||
</datalist>
|
||||
)}*/}
|
||||
</div>
|
||||
</div>
|
||||
{description && <Formy.Help>{description}</Formy.Help>}
|
||||
{errors.length > 0 && (
|
||||
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
|
||||
)}
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
196
app/src/ui/components/form/json-schema-form3/Form.tsx
Normal file
196
app/src/ui/components/form/json-schema-form3/Form.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { Draft2019, type JsonError } from "json-schema-library";
|
||||
import type { TemplateOptions as LibTemplateOptions } from "json-schema-library/dist/lib/getTemplate";
|
||||
import type { JsonSchema as LibJsonSchema } from "json-schema-library/dist/lib/types";
|
||||
import type { JSONSchema as $JSONSchema, FromSchema } from "json-schema-to-ts";
|
||||
import { get } from "lodash-es";
|
||||
import * as immutable from "object-path-immutable";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { Field } from "./Field";
|
||||
import { isRequired, normalizePath, prefixPointer } from "./utils";
|
||||
|
||||
type JSONSchema = Exclude<$JSONSchema, boolean>;
|
||||
|
||||
export type FormProps<
|
||||
Schema extends JSONSchema = JSONSchema,
|
||||
Data = Schema extends JSONSchema ? FromSchema<JSONSchema> : any
|
||||
> = Omit<ComponentPropsWithoutRef<"form">, "onChange"> & {
|
||||
schema: Schema;
|
||||
validateOn?: "change" | "submit";
|
||||
initialValues?: Partial<Data>;
|
||||
initialOpts?: LibTemplateOptions;
|
||||
onChange?: (data: Partial<Data>, name: string, value: any) => void;
|
||||
hiddenSubmit?: boolean;
|
||||
};
|
||||
|
||||
export type FormContext<Data> = {
|
||||
data: Data;
|
||||
setData: (data: Data) => void;
|
||||
setValue: (pointer: string, value: any) => void;
|
||||
deleteValue: (pointer: string) => void;
|
||||
errors: JsonError[];
|
||||
schema: JSONSchema;
|
||||
lib: Draft2019;
|
||||
};
|
||||
|
||||
const FormContext = createContext<FormContext<any>>(undefined!);
|
||||
|
||||
export function Form<
|
||||
Schema extends JSONSchema = JSONSchema,
|
||||
Data = Schema extends JSONSchema ? FromSchema<JSONSchema> : any
|
||||
>({
|
||||
schema,
|
||||
initialValues,
|
||||
initialOpts,
|
||||
children,
|
||||
onChange,
|
||||
validateOn = "submit",
|
||||
hiddenSubmit = true,
|
||||
...props
|
||||
}: FormProps<Schema, Data>) {
|
||||
const lib = new Draft2019(schema);
|
||||
const [data, setData] = useState<Partial<Data>>(
|
||||
initialValues ?? lib.getTemplate(undefined, schema, initialOpts)
|
||||
);
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
const [errors, setErrors] = useState<JsonError[]>([]);
|
||||
|
||||
async function handleChange(e: FormEvent<HTMLFormElement>) {}
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
function setValue(pointer: string, value: any) {
|
||||
const normalized = normalizePath(pointer);
|
||||
console.log("setValue", { pointer, normalized, value });
|
||||
const key = normalized.substring(2).replace(/\//g, ".");
|
||||
setData((prev) => {
|
||||
const changed = immutable.set(prev, key, value);
|
||||
//console.log("changed", prev, changed, { key, value });
|
||||
return changed;
|
||||
});
|
||||
}
|
||||
|
||||
function deleteValue(pointer: string) {
|
||||
const normalized = normalizePath(pointer);
|
||||
const key = normalized.substring(2).replace(/\//g, ".");
|
||||
setData((prev) => {
|
||||
const changed = immutable.del(prev, key);
|
||||
//console.log("changed", prev, changed, { key });
|
||||
return changed;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (validateOn === "change") {
|
||||
validate();
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
function validate(_data?: Partial<Data>) {
|
||||
const actual = _data ?? data;
|
||||
const errors = lib.validate(actual, schema);
|
||||
//console.log("errors", errors);
|
||||
setErrors(errors);
|
||||
return { data: actual, errors };
|
||||
}
|
||||
|
||||
const context = {
|
||||
data: data ?? {},
|
||||
setData,
|
||||
setValue,
|
||||
deleteValue,
|
||||
errors,
|
||||
schema,
|
||||
lib
|
||||
} as any;
|
||||
//console.log("context", context);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form {...props} ref={formRef} onChange={handleChange} onSubmit={handleSubmit}>
|
||||
<FormContext.Provider value={context}>
|
||||
{children ? children : <Field name="" />}
|
||||
</FormContext.Provider>
|
||||
{hiddenSubmit && (
|
||||
<button style={{ visibility: "hidden" }} type="submit">
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||
<pre>{JSON.stringify(errors, null, 2)}</pre>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFormContext() {
|
||||
return useContext(FormContext);
|
||||
}
|
||||
|
||||
export function FormContextOverride({
|
||||
children,
|
||||
overrideData,
|
||||
path,
|
||||
...overrides
|
||||
}: Partial<FormContext<any>> & { children: ReactNode; path?: string; overrideData?: boolean }) {
|
||||
const ctx = useFormContext();
|
||||
const additional: Partial<FormContext<any>> = {};
|
||||
|
||||
// this makes a local schema down the three
|
||||
// especially useful for AnyOf, since it doesn't need to fully validate (e.g. pattern)
|
||||
if (overrideData && path) {
|
||||
const pointer = normalizePath(path);
|
||||
const value =
|
||||
pointer === "#/" ? ctx.data : get(ctx.data, pointer.substring(2).replace(/\//g, "."));
|
||||
|
||||
additional.data = value;
|
||||
additional.setValue = (pointer: string, value: any) => {
|
||||
ctx.setValue(prefixPointer(pointer, path), value);
|
||||
};
|
||||
additional.deleteValue = (pointer: string) => {
|
||||
ctx.deleteValue(prefixPointer(pointer, path));
|
||||
};
|
||||
}
|
||||
|
||||
const context = {
|
||||
...ctx,
|
||||
...overrides,
|
||||
...additional
|
||||
};
|
||||
|
||||
return <FormContext.Provider value={context}>{children}</FormContext.Provider>;
|
||||
}
|
||||
|
||||
export function useFieldContext(name: string) {
|
||||
const { data, lib, schema, errors: formErrors, ...rest } = useFormContext();
|
||||
const pointer = normalizePath(name);
|
||||
const isRootPointer = pointer === "#/";
|
||||
//console.log("pointer", pointer);
|
||||
const value = isRootPointer ? data : get(data, pointer.substring(2).replace(/\//g, "."));
|
||||
const errors = formErrors.filter((error) => error.data.pointer.startsWith(pointer));
|
||||
const fieldSchema = isRootPointer
|
||||
? (schema as LibJsonSchema)
|
||||
: lib.getSchema({ pointer, data, schema });
|
||||
const required = isRequired(pointer, schema, data);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
lib,
|
||||
value,
|
||||
errors,
|
||||
schema: fieldSchema,
|
||||
pointer,
|
||||
required
|
||||
};
|
||||
}
|
||||
46
app/src/ui/components/form/json-schema-form3/ObjectField.tsx
Normal file
46
app/src/ui/components/form/json-schema-form3/ObjectField.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import { AnyOfField } from "./AnyOfField";
|
||||
import { Field } from "./Field";
|
||||
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
||||
import { useFieldContext } from "./Form";
|
||||
|
||||
export type ObjectFieldProps = {
|
||||
path?: string;
|
||||
schema?: Exclude<JSONSchema, boolean>;
|
||||
label?: string | false;
|
||||
wrapperProps?: Partial<FieldwrapperProps>;
|
||||
};
|
||||
|
||||
export const ObjectField = ({
|
||||
path = "",
|
||||
schema: _schema,
|
||||
label: _label,
|
||||
wrapperProps = {}
|
||||
}: ObjectFieldProps) => {
|
||||
const { errors, ...ctx } = useFieldContext(path);
|
||||
const schema = _schema ?? ctx.schema;
|
||||
if (!schema) return "ObjectField: no schema";
|
||||
const properties = schema.properties ?? {};
|
||||
|
||||
return (
|
||||
<FieldWrapper
|
||||
pointer={path}
|
||||
errors={errors}
|
||||
schema={schema}
|
||||
wrapper="fieldset"
|
||||
{...wrapperProps}
|
||||
>
|
||||
{Object.keys(properties).map((prop) => {
|
||||
const schema = properties[prop];
|
||||
const pointer = `${path}/${prop}`.replace(/\/+/g, "/");
|
||||
if (!schema) return;
|
||||
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
return <AnyOfField key={pointer} path={pointer} schema={schema} />;
|
||||
}
|
||||
|
||||
return <Field key={pointer} name={pointer} schema={schema} />;
|
||||
})}
|
||||
</FieldWrapper>
|
||||
);
|
||||
};
|
||||
202
app/src/ui/components/form/json-schema-form3/utils.ts
Normal file
202
app/src/ui/components/form/json-schema-form3/utils.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { autoFormatString } from "core/utils";
|
||||
import { Draft2019, type JsonSchema } from "json-schema-library";
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import type { JSONSchemaType } from "json-schema-to-ts/lib/types/definitions/jsonSchema";
|
||||
import { set } from "lodash-es";
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
export function getFormTarget(e: FormEvent<HTMLFormElement>) {
|
||||
const form = e.currentTarget;
|
||||
const target = e.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null;
|
||||
|
||||
// check if target has attribute "data-ignore" set
|
||||
// also check if target is within a "data-ignore" element
|
||||
|
||||
if (
|
||||
!target ||
|
||||
!form.contains(target) ||
|
||||
!target.name ||
|
||||
target.hasAttribute("data-ignore") ||
|
||||
target.closest("[data-ignore]")
|
||||
) {
|
||||
return; // Ignore events from outside the form
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function flatten(obj: any, parentKey = "", result: any = {}): any {
|
||||
for (const key in obj) {
|
||||
if (key in obj) {
|
||||
const newKey = parentKey ? `${parentKey}/${key}` : "#/" + key;
|
||||
if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) {
|
||||
flatten(obj[key], newKey, result);
|
||||
} else if (Array.isArray(obj[key])) {
|
||||
obj[key].forEach((item, index) => {
|
||||
const arrayKey = `${newKey}.${index}`;
|
||||
if (typeof item === "object" && item !== null) {
|
||||
flatten(item, arrayKey, result);
|
||||
} else {
|
||||
result[arrayKey] = item;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
result[newKey] = obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// @todo: make sure it's in the right order
|
||||
export function unflatten(
|
||||
obj: Record<string, string>,
|
||||
schema: JSONSchema,
|
||||
selections?: Record<string, number | undefined>
|
||||
) {
|
||||
const result = {};
|
||||
const lib = new Draft2019(schema as any);
|
||||
for (const pointer in obj) {
|
||||
const required = isRequired(pointer, schema);
|
||||
let subschema = lib.getSchema({ pointer });
|
||||
console.log("subschema", pointer, subschema, selections);
|
||||
if (!subschema) {
|
||||
throw new Error(`"${pointer}" not found in schema`);
|
||||
}
|
||||
|
||||
// if subschema starts with "anyOf" or "oneOf"
|
||||
if (subschema.anyOf || subschema.oneOf) {
|
||||
const selected = selections?.[pointer];
|
||||
if (selected !== undefined) {
|
||||
subschema = subschema.anyOf ? subschema.anyOf[selected] : subschema.oneOf![selected];
|
||||
}
|
||||
}
|
||||
|
||||
const value = coerce(obj[pointer], subschema as any, { required });
|
||||
|
||||
set(result, pointer.substring(2).replace(/\//g, "."), value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function coerce(
|
||||
value: any,
|
||||
schema: Exclude<JSONSchema, boolean>,
|
||||
opts?: { required?: boolean }
|
||||
) {
|
||||
if (!value && typeof opts?.required === "boolean" && !opts.required) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "string":
|
||||
return String(value);
|
||||
case "integer":
|
||||
case "number":
|
||||
return Number(value);
|
||||
case "boolean":
|
||||
return ["true", "1", 1, "on"].includes(value);
|
||||
case "null":
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* normalizes any path to a full json pointer
|
||||
*
|
||||
* examples: in -> out
|
||||
* description -> #/description
|
||||
* #/description -> #/description
|
||||
* /description -> #/description
|
||||
* nested/property -> #/nested/property
|
||||
* nested.property -> #/nested/property
|
||||
* nested.property[0] -> #/nested/property/0
|
||||
* nested.property[0].name -> #/nested/property/0/name
|
||||
* @param path
|
||||
*/
|
||||
export function normalizePath(path: string) {
|
||||
return path.startsWith("#/")
|
||||
? path
|
||||
: `#/${path.replace(/#?\/?/, "").replace(/\./g, "/").replace(/\[/g, "/").replace(/\]/g, "")}`;
|
||||
}
|
||||
|
||||
export function prefixPointer(pointer: string, prefix: string) {
|
||||
return pointer.replace("#/", `#/${prefix}/`);
|
||||
}
|
||||
|
||||
export function getParentPointer(pointer: string) {
|
||||
return pointer.substring(0, pointer.lastIndexOf("/"));
|
||||
}
|
||||
|
||||
export function isRequired(pointer: string, schema: JSONSchema, data?: any) {
|
||||
if (pointer === "#/") {
|
||||
return false;
|
||||
}
|
||||
const lib = new Draft2019(schema as any);
|
||||
|
||||
const childSchema = lib.getSchema({ pointer, data });
|
||||
if (typeof childSchema === "object" && ("const" in childSchema || "enum" in childSchema)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parentPointer = getParentPointer(pointer);
|
||||
const parentSchema = lib.getSchema({ pointer: parentPointer, data });
|
||||
const required = parentSchema?.required?.includes(pointer.split("/").pop()!);
|
||||
|
||||
/*console.log("isRequired", {
|
||||
pointer,
|
||||
parentPointer,
|
||||
parent: parentSchema ? JSON.parse(JSON.stringify(parentSchema)) : null,
|
||||
required
|
||||
});*/
|
||||
|
||||
return !!required;
|
||||
}
|
||||
|
||||
type TType = JSONSchemaType | JSONSchemaType[] | readonly JSONSchemaType[] | undefined;
|
||||
export function isType(_type: TType, _compare: TType) {
|
||||
if (!_type || !_compare) return false;
|
||||
const type = Array.isArray(_type) ? _type : [_type];
|
||||
const compare = Array.isArray(_compare) ? _compare : [_compare];
|
||||
return compare.some((t) => type.includes(t));
|
||||
}
|
||||
|
||||
export function getLabel(name: string, schema: JSONSchema) {
|
||||
if (typeof schema === "object" && "title" in schema) return schema.title;
|
||||
const label = name.includes("/") ? (name.split("/").pop() ?? "") : name;
|
||||
return autoFormatString(label);
|
||||
}
|
||||
|
||||
export function getMultiSchema(schema: JSONSchema): Exclude<JSONSchema, boolean>[] | undefined {
|
||||
if (!schema || typeof schema !== "object") return;
|
||||
return (schema.anyOf ?? schema.oneOf) as any;
|
||||
}
|
||||
|
||||
export function getMultiSchemaMatched(
|
||||
schema: JsonSchema,
|
||||
data: any
|
||||
): [number, Exclude<JSONSchema, boolean>[], Exclude<JSONSchema, boolean> | undefined] {
|
||||
const multiSchema = getMultiSchema(schema);
|
||||
if (!multiSchema) return [-1, [], undefined];
|
||||
const index = multiSchema.findIndex((subschema) => {
|
||||
const lib = new Draft2019(subschema as any);
|
||||
return lib.validate(data, subschema).length === 0;
|
||||
});
|
||||
if (index === -1) return [-1, multiSchema, undefined];
|
||||
|
||||
return [index, multiSchema, multiSchema[index]];
|
||||
}
|
||||
|
||||
export function removeKeyRecursively<Given extends object>(obj: Given, keyToRemove: string): Given {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => removeKeyRecursively(item, keyToRemove)) as any;
|
||||
} else if (typeof obj === "object" && obj !== null) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj)
|
||||
.filter(([key]) => key !== keyToRemove)
|
||||
.map(([key, value]) => [key, removeKeyRecursively(value, keyToRemove)])
|
||||
) as any;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { ValueError } from "@sinclair/typebox/value";
|
||||
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
|
||||
import clsx from "clsx";
|
||||
import { type TSchema, Type, Value } from "core/utils";
|
||||
import { Form, type Validator } from "json-schema-form-react";
|
||||
import { Type } from "core/utils";
|
||||
import { Form } from "json-schema-form-react";
|
||||
import { transform } from "lodash-es";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Group, Input, Label } from "ui/components/form/Formy/components";
|
||||
import { TypeboxValidator } from "ui/components/form/json-schema-form";
|
||||
import { SocialLink } from "./SocialLink";
|
||||
|
||||
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
|
||||
@@ -18,14 +18,7 @@ export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" |
|
||||
buttonLabel?: string;
|
||||
};
|
||||
|
||||
class TypeboxValidator implements Validator<ValueError> {
|
||||
async validate(schema: TSchema, data: any) {
|
||||
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
|
||||
}
|
||||
}
|
||||
|
||||
const validator = new TypeboxValidator();
|
||||
|
||||
const schema = Type.Object({
|
||||
email: Type.String({
|
||||
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
||||
|
||||
@@ -46,9 +46,12 @@ export function MediaRoot({ children }) {
|
||||
<SearchInput placeholder="Search buckets" />
|
||||
</div>*/}
|
||||
<nav className="flex flex-col flex-1 gap-1">
|
||||
<AppShell.SidebarLink as={Link} href="/media" className="active">
|
||||
<AppShell.SidebarLink as={Link} href={"/"}>
|
||||
Main Bucket
|
||||
</AppShell.SidebarLink>
|
||||
<AppShell.SidebarLink as={Link} href={"/settings"}>
|
||||
Settings
|
||||
</AppShell.SidebarLink>
|
||||
</nav>
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
@@ -57,16 +60,3 @@ export function MediaRoot({ children }) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// @todo: add infinite load
|
||||
export function MediaEmpty() {
|
||||
useBrowserTitle(["Media"]);
|
||||
|
||||
return (
|
||||
<AppShell.Scrollable>
|
||||
<div className="flex flex-1 p-3">
|
||||
<Media.Dropzone />
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Route } from "wouter";
|
||||
import { MediaEmpty, MediaRoot } from "./_media.root";
|
||||
import { MediaRoot } from "./_media.root";
|
||||
import { MediaIndex } from "./media.index";
|
||||
import { MediaSettings } from "./media.settings";
|
||||
|
||||
export default function MediaRoutes() {
|
||||
return (
|
||||
<MediaRoot>
|
||||
<Route path="/" component={MediaEmpty} />
|
||||
<Route path="/" component={MediaIndex} />
|
||||
<Route path="/settings" component={MediaSettings} />
|
||||
</MediaRoot>
|
||||
);
|
||||
}
|
||||
|
||||
15
app/src/ui/routes/media/media.index.tsx
Normal file
15
app/src/ui/routes/media/media.index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Media } from "ui/elements";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
|
||||
export function MediaIndex() {
|
||||
useBrowserTitle(["Media"]);
|
||||
|
||||
return (
|
||||
<AppShell.Scrollable>
|
||||
<div className="flex flex-1 p-3">
|
||||
<Media.Dropzone />
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
);
|
||||
}
|
||||
126
app/src/ui/routes/media/media.settings.tsx
Normal file
126
app/src/ui/routes/media/media.settings.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Form, useFormContext } from "json-schema-form-react";
|
||||
import { omit } from "lodash-es";
|
||||
import { useState } from "react";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { useBkndMedia } from "ui/client/schema/media/use-bknd-media";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { BooleanInputMantine } from "ui/components/form/Formy/BooleanInputMantine";
|
||||
import { JsonSchemaForm } from "ui/components/form/json-schema";
|
||||
import { TypeboxValidator } from "ui/components/form/json-schema-form";
|
||||
import { AutoForm, Field } from "ui/components/form/json-schema-form/components/Field";
|
||||
import { Media } from "ui/elements";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
|
||||
const validator = new TypeboxValidator();
|
||||
|
||||
export function MediaSettings(props) {
|
||||
useBrowserTitle(["Media", "Settings"]);
|
||||
|
||||
const { hasSecrets } = useBknd({ withSecrets: true });
|
||||
if (!hasSecrets) {
|
||||
return <Message.MissingPermission what="Media Settings" />;
|
||||
}
|
||||
|
||||
return <MediaSettingsInternal {...props} />;
|
||||
}
|
||||
|
||||
function MediaSettingsInternal() {
|
||||
const { config, schema } = useBkndMedia();
|
||||
const [data, setData] = useState<any>(config);
|
||||
//console.log("schema", schema);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
schema={schema}
|
||||
validator={validator}
|
||||
onChange={setData}
|
||||
defaultValues={config as any}
|
||||
>
|
||||
{({ errors, submitting, dirty }) => (
|
||||
<>
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<Button variant="primary" disabled={!dirty || submitting}>
|
||||
Update
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Settings
|
||||
</AppShell.SectionHeader>
|
||||
<AppShell.Scrollable>
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<Field name="enabled" />
|
||||
<div className="flex flex-col gap-3 relative">
|
||||
<Overlay visible={!data.enabled} />
|
||||
<Field name="entity_name" />
|
||||
<Field name="storage.body_max_size" title="Storage Body Max Size" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 p-3 mt-3 border-t border-muted">
|
||||
<Overlay visible={!data.enabled} />
|
||||
<Adapters />
|
||||
</div>
|
||||
<JsonViewer json={data} expand={999} />
|
||||
</AppShell.Scrollable>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Adapters() {
|
||||
const { config, schema } = useBkndMedia();
|
||||
const ctx = useFormContext();
|
||||
const current = config.adapter;
|
||||
const schemas = schema.properties.adapter.anyOf;
|
||||
const types = schemas.map((s) => s.properties.type.const) as string[];
|
||||
const currentType = current?.type ?? (types[0] as string);
|
||||
const [selected, setSelected] = useState<string>(currentType);
|
||||
const $schema = schemas.find((s) => s.properties.type.const === selected);
|
||||
console.log("$schema", $schema);
|
||||
|
||||
function onChangeSelect(e) {
|
||||
setSelected(e.target.value);
|
||||
|
||||
// wait quickly for the form to update before triggering a change
|
||||
setTimeout(() => {
|
||||
ctx.setValue("adapter.type", e.target.value);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Formy.Select value={selected} onChange={onChangeSelect}>
|
||||
{types.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</Formy.Select>
|
||||
<div>current: {selected}</div>
|
||||
<div>options: {schemas.map((s) => s.title).join(", ")}</div>
|
||||
<Field name="adapter.type" defaultValue={selected} hidden />
|
||||
{$schema && <AutoForm schema={$schema?.properties.config} prefix="adapter.config" />}
|
||||
<hr />
|
||||
{/*{$schema && (
|
||||
<div data-ignore>
|
||||
<JsonSchemaForm
|
||||
schema={omit($schema?.properties.config, "title")}
|
||||
className="legacy hide-required-mark fieldset-alternative mute-root"
|
||||
/>
|
||||
</div>
|
||||
)}*/}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Overlay = ({ visible = false }) =>
|
||||
visible && (
|
||||
<div className="absolute w-full h-full z-50 bg-background opacity-70 pointer-events-none" />
|
||||
);
|
||||
@@ -1,5 +1,6 @@
|
||||
import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test";
|
||||
import JsonSchemaFormReactTest from "ui/routes/test/tests/json-schema-form-react-test";
|
||||
import JsonSchemaForm2 from "ui/routes/test/tests/json-schema-form2";
|
||||
import SwaggerTest from "ui/routes/test/tests/swagger-test";
|
||||
import SWRAndAPI from "ui/routes/test/tests/swr-and-api";
|
||||
import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api";
|
||||
@@ -15,6 +16,7 @@ import DropdownTest from "./tests/dropdown-test";
|
||||
import DropzoneElementTest from "./tests/dropzone-element-test";
|
||||
import EntityFieldsForm from "./tests/entity-fields-form";
|
||||
import FlowsTest from "./tests/flows-test";
|
||||
import JsonSchemaForm3 from "./tests/json-schema-form3";
|
||||
import JsonFormTest from "./tests/jsonform-test";
|
||||
import { LiquidJsTest } from "./tests/liquid-js-test";
|
||||
import MantineTest from "./tests/mantine-test";
|
||||
@@ -45,7 +47,9 @@ const tests = {
|
||||
SWRAndAPI,
|
||||
SwrAndDataApi,
|
||||
DropzoneElementTest,
|
||||
JsonSchemaFormReactTest
|
||||
JsonSchemaFormReactTest,
|
||||
JsonSchemaForm2,
|
||||
JsonSchemaForm3
|
||||
} as const;
|
||||
|
||||
export default function TestRoutes() {
|
||||
|
||||
287
app/src/ui/routes/test/tests/json-schema-form2.tsx
Normal file
287
app/src/ui/routes/test/tests/json-schema-form2.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Popover } from "@mantine/core";
|
||||
import { IconBug } from "@tabler/icons-react";
|
||||
import { autoFormatString } from "core/utils";
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import { type ChangeEvent, type ComponentPropsWithoutRef, useState } from "react";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { ErrorMessage } from "ui/components/form/Formy";
|
||||
import {
|
||||
Form,
|
||||
useFieldContext,
|
||||
useFormContext,
|
||||
usePrefixContext
|
||||
} from "ui/components/form/json-schema-form2/Form";
|
||||
import { isType } from "ui/components/form/json-schema-form2/utils";
|
||||
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", maxLength: 2 },
|
||||
description: { type: "string", maxLength: 2 },
|
||||
age: { type: "number", description: "Age of you" },
|
||||
deep: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: { type: "string", maxLength: 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
//required: ["description"]
|
||||
} as const satisfies JSONSchema;
|
||||
|
||||
const simpleSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
tags: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
} as const satisfies JSONSchema;
|
||||
|
||||
export default function JsonSchemaForm2() {
|
||||
return (
|
||||
<div className="flex flex-col p-3">
|
||||
<h1>Form</h1>
|
||||
|
||||
{/*<Form
|
||||
schema={schema}
|
||||
onChange={console.log}
|
||||
validateOn="change"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<input name="name" placeholder="name" />
|
||||
<Field name="age" />
|
||||
<Field name="description" />
|
||||
<input name="deep/nested" placeholder="deep/nested" />
|
||||
|
||||
<div className="border border-red-500 p-3">
|
||||
<ObjectField prefix="deep" />
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<hr />*/}
|
||||
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", maxLength: 2 },
|
||||
description: { type: "string", maxLength: 2 },
|
||||
age: { type: "number", description: "Age of you" },
|
||||
deep: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: { type: "string", maxLength: 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onChange={console.log}
|
||||
initialValues={{ age: 12, deep: { nested: "test" } }}
|
||||
validateOn="change"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<AutoForm />
|
||||
</Form>*/}
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
bla: { type: "string" },
|
||||
type: { type: "string", enum: ["a", "b"] }
|
||||
}
|
||||
}}
|
||||
onChange={console.log}
|
||||
validateOn="change"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<AutoForm />
|
||||
</Form>*/}
|
||||
|
||||
<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
bla: {
|
||||
anyOf: [
|
||||
{ type: "string", maxLength: 2, title: "String" },
|
||||
{ type: "number", title: "Number" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}}
|
||||
onChange={console.log}
|
||||
validateOn="change"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<AutoForm />
|
||||
</Form>
|
||||
|
||||
{/*<Form schema={simpleSchema} validateOn="change" className="flex flex-col gap-2">
|
||||
<AutoForm />
|
||||
</Form>*/}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Field = ({
|
||||
name = "",
|
||||
schema: _schema
|
||||
}: { name?: string; schema?: Exclude<JSONSchema, boolean> }) => {
|
||||
const { value, errors, pointer, required, ...ctx } = useFieldContext(name);
|
||||
const schema = _schema ?? ctx.schema;
|
||||
if (!schema) return `"${name}" (${pointer}) has no schema`;
|
||||
|
||||
if (isType(schema.type, ["object", "array"])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = schema.title ?? name; //autoFormatString(name.split("/").pop());
|
||||
|
||||
return (
|
||||
<Formy.Group error={errors.length > 0}>
|
||||
<Formy.Label>
|
||||
{label} {required ? "*" : ""}
|
||||
</Formy.Label>
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<FieldComponent
|
||||
schema={schema}
|
||||
name={pointer}
|
||||
placeholder={pointer}
|
||||
required={required}
|
||||
/>
|
||||
</div>
|
||||
<Popover>
|
||||
<Popover.Target>
|
||||
<IconButton Icon={IconBug} />
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<JsonViewer
|
||||
json={{ pointer, value, required, schema, errors }}
|
||||
expand={6}
|
||||
className="p-0"
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
{schema.description && <Formy.Help>{schema.description}</Formy.Help>}
|
||||
{errors.length > 0 && (
|
||||
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
|
||||
)}
|
||||
</Formy.Group>
|
||||
);
|
||||
};
|
||||
|
||||
const FieldComponent = ({
|
||||
schema,
|
||||
...props
|
||||
}: { schema: JSONSchema } & ComponentPropsWithoutRef<"input">) => {
|
||||
if (!schema || typeof schema === "boolean") return null;
|
||||
|
||||
const common = {};
|
||||
|
||||
if (schema.enum) {
|
||||
if (!Array.isArray(schema.enum)) return null;
|
||||
|
||||
return <Formy.Select {...(props as any)} options={schema.enum} />;
|
||||
}
|
||||
|
||||
if (isType(schema.type, ["number", "integer"])) {
|
||||
return <Formy.Input type="number" {...props} />;
|
||||
}
|
||||
|
||||
return <Formy.Input {...props} />;
|
||||
};
|
||||
|
||||
const ObjectField = ({ path = "" }: { path?: string }) => {
|
||||
const { schema } = usePrefixContext(path);
|
||||
if (!schema) return null;
|
||||
const properties = schema.properties ?? {};
|
||||
const label = schema.title ?? path;
|
||||
console.log("object", { path, schema, properties });
|
||||
|
||||
return (
|
||||
<fieldset className="border border-muted p-3 flex flex-col gap-2">
|
||||
<legend>Object: {label}</legend>
|
||||
{Object.keys(properties).map((prop) => {
|
||||
const schema = properties[prop];
|
||||
const pointer = `${path}/${prop}`;
|
||||
|
||||
console.log("--", prop, pointer, schema);
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
return <AnyOfField key={pointer} path={pointer} />;
|
||||
}
|
||||
|
||||
if (isType(schema.type, "object")) {
|
||||
console.log("object", { prop, pointer, schema });
|
||||
return <ObjectField key={pointer} path={pointer} />;
|
||||
}
|
||||
|
||||
if (isType(schema.type, "array")) {
|
||||
return <ArrayField key={pointer} path={pointer} />;
|
||||
}
|
||||
|
||||
return <Field key={pointer} name={pointer} />;
|
||||
})}
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
const AnyOfField = ({ path = "" }: { path?: string }) => {
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const { schema, select } = usePrefixContext(path);
|
||||
if (!schema) return null;
|
||||
const schemas = schema.anyOf ?? schema.oneOf ?? [];
|
||||
const options = schemas.map((s, i) => ({
|
||||
value: i,
|
||||
label: s.title ?? `Option ${i + 1}`
|
||||
}));
|
||||
const selectSchema = {
|
||||
enum: options
|
||||
};
|
||||
|
||||
function handleSelect(e: ChangeEvent<HTMLInputElement>) {
|
||||
const i = e.target.value ? Number(e.target.value) : null;
|
||||
setSelected(i);
|
||||
select(path, i !== null ? i : undefined);
|
||||
}
|
||||
console.log("options", options, schemas, selected !== null && schemas[selected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
anyOf: {path} ({selected})
|
||||
</div>
|
||||
<FieldComponent schema={selectSchema as any} onChange={handleSelect} />
|
||||
|
||||
{selected !== null && (
|
||||
<Field key={`${path}_${selected}`} schema={schemas[selected]} name={path} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ArrayField = ({ path = "" }: { path?: string }) => {
|
||||
return "array: " + path;
|
||||
};
|
||||
|
||||
const AutoForm = ({ prefix = "" }: { prefix?: string }) => {
|
||||
const { schema } = usePrefixContext(prefix);
|
||||
if (!schema) return null;
|
||||
|
||||
if (isType(schema.type, "object")) {
|
||||
return <ObjectField path={prefix} />;
|
||||
}
|
||||
|
||||
if (isType(schema.type, "array")) {
|
||||
return <ArrayField path={prefix} />;
|
||||
}
|
||||
|
||||
return <Field name={prefix} />;
|
||||
};
|
||||
328
app/src/ui/routes/test/tests/json-schema-form3.tsx
Normal file
328
app/src/ui/routes/test/tests/json-schema-form3.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { AnyOf, useAnyOfContext } from "ui/components/form/json-schema-form3/AnyOfField";
|
||||
import { Field } from "ui/components/form/json-schema-form3/Field";
|
||||
import { Form, FormContextOverride } from "ui/components/form/json-schema-form3/Form";
|
||||
import { ObjectField } from "ui/components/form/json-schema-form3/ObjectField";
|
||||
import { removeKeyRecursively } from "ui/components/form/json-schema-form3/utils";
|
||||
import { Scrollable } from "ui/layouts/AppShell/AppShell";
|
||||
|
||||
const mediaSchema = {
|
||||
additionalProperties: false,
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: {
|
||||
default: false,
|
||||
type: "boolean"
|
||||
},
|
||||
basepath: {
|
||||
default: "/api/media",
|
||||
type: "string"
|
||||
},
|
||||
entity_name: {
|
||||
default: "media",
|
||||
type: "string"
|
||||
},
|
||||
storage: {
|
||||
default: {},
|
||||
type: "object",
|
||||
properties: {
|
||||
body_max_size: {
|
||||
description: "Max size of the body in bytes. Leave blank for unlimited.",
|
||||
type: "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
adapter: {
|
||||
anyOf: [
|
||||
{
|
||||
title: "s3",
|
||||
additionalProperties: false,
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
default: "s3",
|
||||
const: "s3",
|
||||
readOnly: true,
|
||||
type: "string"
|
||||
},
|
||||
config: {
|
||||
title: "S3",
|
||||
type: "object",
|
||||
properties: {
|
||||
access_key: {
|
||||
type: "string"
|
||||
},
|
||||
secret_access_key: {
|
||||
type: "string"
|
||||
},
|
||||
url: {
|
||||
pattern: "^https?://[^/]+",
|
||||
description: "URL to S3 compatible endpoint without trailing slash",
|
||||
examples: [
|
||||
"https://{account_id}.r2.cloudflarestorage.com/{bucket}",
|
||||
"https://{bucket}.s3.{region}.amazonaws.com"
|
||||
],
|
||||
type: "string"
|
||||
}
|
||||
},
|
||||
required: ["access_key", "secret_access_key", "url"]
|
||||
}
|
||||
},
|
||||
required: ["type", "config"]
|
||||
},
|
||||
{
|
||||
title: "cloudinary",
|
||||
additionalProperties: false,
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
default: "cloudinary",
|
||||
const: "cloudinary",
|
||||
readOnly: true,
|
||||
type: "string"
|
||||
},
|
||||
config: {
|
||||
title: "Cloudinary",
|
||||
type: "object",
|
||||
properties: {
|
||||
cloud_name: {
|
||||
type: "string"
|
||||
},
|
||||
api_key: {
|
||||
type: "string"
|
||||
},
|
||||
api_secret: {
|
||||
type: "string"
|
||||
},
|
||||
upload_preset: {
|
||||
type: "string"
|
||||
}
|
||||
},
|
||||
required: ["cloud_name", "api_key", "api_secret"]
|
||||
}
|
||||
},
|
||||
required: ["type", "config"]
|
||||
},
|
||||
{
|
||||
title: "local",
|
||||
additionalProperties: false,
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
default: "local",
|
||||
const: "local",
|
||||
readOnly: true,
|
||||
type: "string"
|
||||
},
|
||||
config: {
|
||||
title: "Local",
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
default: "./",
|
||||
type: "string"
|
||||
}
|
||||
},
|
||||
required: ["path"]
|
||||
}
|
||||
},
|
||||
required: ["type", "config"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
required: ["enabled", "basepath", "entity_name", "storage"]
|
||||
};
|
||||
|
||||
export default function JsonSchemaForm3() {
|
||||
const { schema, config } = useBknd();
|
||||
|
||||
return (
|
||||
<Scrollable>
|
||||
<div className="flex flex-col p-3">
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", default: "Peter" },
|
||||
age: { type: "number" },
|
||||
gender: {
|
||||
type: "string",
|
||||
enum: ["male", "female", "uni"]
|
||||
},
|
||||
deep: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: { type: "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["age"]
|
||||
}}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<Field name="name" />
|
||||
<Field name="age" />
|
||||
<Field name="gender" />
|
||||
<Field name="deep" />
|
||||
</Form>*/}
|
||||
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
bla: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
start: { type: "string", enum: ["a", "b", "c"] },
|
||||
end: { type: "number" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AutoForm />
|
||||
</Form>*/}
|
||||
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
title: {
|
||||
type: "string"
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
initialValues={{ tags: ["a", "b", "c"] }}
|
||||
>
|
||||
<AutoForm />
|
||||
</Form>*/}
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
title: {
|
||||
type: "string"
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "number"
|
||||
}
|
||||
},
|
||||
method: {
|
||||
type: "array",
|
||||
uniqueItems: true,
|
||||
items: {
|
||||
type: "string",
|
||||
enum: ["GET", "POST", "PUT", "DELETE"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
initialValues={{ tags: [0, 1] }}
|
||||
>
|
||||
<AutoForm />
|
||||
</Form>*/}
|
||||
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
title: {
|
||||
type: "string"
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: {
|
||||
anyOf: [
|
||||
{ type: "string", title: "String" },
|
||||
{ type: "number", title: "Number" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
initialValues={{ tags: [0, 1] }}
|
||||
/>*/}
|
||||
|
||||
<CustomMediaForm />
|
||||
<Form schema={schema.media} initialValues={config.media} />
|
||||
|
||||
{/*<Form
|
||||
schema={removeKeyRecursively(schema.media, "pattern") as any}
|
||||
initialValues={config.media}
|
||||
>
|
||||
<AutoForm />
|
||||
</Form>*/}
|
||||
|
||||
{/*<Form
|
||||
schema={removeKeyRecursively(schema.server, "pattern") as any}
|
||||
initialValues={config.server}
|
||||
>
|
||||
<AutoForm />
|
||||
</Form>*/}
|
||||
</div>
|
||||
</Scrollable>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomMediaForm() {
|
||||
const { schema, config } = useBknd();
|
||||
return (
|
||||
<Form schema={schema.media} initialValues={config.media} className="flex flex-col gap-3">
|
||||
<Field name="enabled" />
|
||||
<Field name="basepath" />
|
||||
<Field name="entity_name" />
|
||||
<Field name="storage" />
|
||||
<AnyOf.Root path="adapter">
|
||||
<CustomMediaFormAdapter />
|
||||
</AnyOf.Root>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomMediaFormAdapter() {
|
||||
const ctx = useAnyOfContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row gap-1">
|
||||
{ctx.schemas?.map((schema: any, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
onClick={() => ctx.select(i)}
|
||||
variant={ctx.selected === i ? "primary" : "default"}
|
||||
>
|
||||
{schema.title ?? `Option ${i + 1}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{ctx.selected !== null && (
|
||||
<FormContextOverride schema={ctx.selectedSchema} path={ctx.path} overrideData>
|
||||
<Field name="type" hidden />
|
||||
<ObjectField path="config" wrapperProps={{ label: false, wrapper: "group" }} />
|
||||
</FormContextOverride>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user