mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
Merge pull request #70 from bknd-io/feat/optimize-form-renders
Feat/optimize form renders
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Perf } from "../../src/core/utils";
|
||||
import * as reqres from "../../src/core/utils/reqres";
|
||||
import * as strings from "../../src/core/utils/strings";
|
||||
import * as utils from "../../src/core/utils";
|
||||
|
||||
async function wait(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
@@ -13,7 +12,7 @@ describe("Core Utils", async () => {
|
||||
describe("[core] strings", async () => {
|
||||
test("objectToKeyValueArray", async () => {
|
||||
const obj = { a: 1, b: 2, c: 3 };
|
||||
const result = strings.objectToKeyValueArray(obj);
|
||||
const result = utils.objectToKeyValueArray(obj);
|
||||
expect(result).toEqual([
|
||||
{ key: "a", value: 1 },
|
||||
{ key: "b", value: 2 },
|
||||
@@ -22,24 +21,24 @@ describe("Core Utils", async () => {
|
||||
});
|
||||
|
||||
test("snakeToPascalWithSpaces", async () => {
|
||||
const result = strings.snakeToPascalWithSpaces("snake_to_pascal");
|
||||
const result = utils.snakeToPascalWithSpaces("snake_to_pascal");
|
||||
expect(result).toBe("Snake To Pascal");
|
||||
});
|
||||
|
||||
test("randomString", async () => {
|
||||
const result = strings.randomString(10);
|
||||
const result = utils.randomString(10);
|
||||
expect(result).toHaveLength(10);
|
||||
});
|
||||
|
||||
test("pascalToKebab", async () => {
|
||||
const result = strings.pascalToKebab("PascalCase");
|
||||
const result = utils.pascalToKebab("PascalCase");
|
||||
expect(result).toBe("pascal-case");
|
||||
});
|
||||
|
||||
test("replaceSimplePlaceholders", async () => {
|
||||
const str = "Hello, {$name}!";
|
||||
const vars = { name: "John" };
|
||||
const result = strings.replaceSimplePlaceholders(str, vars);
|
||||
const result = utils.replaceSimplePlaceholders(str, vars);
|
||||
expect(result).toBe("Hello, John!");
|
||||
});
|
||||
});
|
||||
@@ -49,7 +48,7 @@ describe("Core Utils", async () => {
|
||||
const headers = new Headers();
|
||||
headers.append("Content-Type", "application/json");
|
||||
headers.append("Authorization", "Bearer 123");
|
||||
const obj = reqres.headersToObject(headers);
|
||||
const obj = utils.headersToObject(headers);
|
||||
expect(obj).toEqual({
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer 123"
|
||||
@@ -59,21 +58,21 @@ describe("Core Utils", async () => {
|
||||
test("replaceUrlParam", () => {
|
||||
const url = "/api/:id/:name";
|
||||
const params = { id: "123", name: "test" };
|
||||
const result = reqres.replaceUrlParam(url, params);
|
||||
const result = utils.replaceUrlParam(url, params);
|
||||
expect(result).toBe("/api/123/test");
|
||||
});
|
||||
|
||||
test("encode", () => {
|
||||
const obj = { id: "123", name: "test" };
|
||||
const result = reqres.encodeSearch(obj);
|
||||
const result = utils.encodeSearch(obj);
|
||||
expect(result).toBe("id=123&name=test");
|
||||
|
||||
const obj2 = { id: "123", name: ["test1", "test2"] };
|
||||
const result2 = reqres.encodeSearch(obj2);
|
||||
const result2 = utils.encodeSearch(obj2);
|
||||
expect(result2).toBe("id=123&name=test1&name=test2");
|
||||
|
||||
const obj3 = { id: "123", name: { test: "test" } };
|
||||
const result3 = reqres.encodeSearch(obj3, { encode: true });
|
||||
const result3 = utils.encodeSearch(obj3, { encode: true });
|
||||
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
|
||||
});
|
||||
});
|
||||
@@ -108,4 +107,91 @@ describe("Core Utils", async () => {
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("objects", () => {
|
||||
test("omitKeys", () => {
|
||||
const objects = [
|
||||
[{ a: 1, b: 2, c: 3 }, ["a"], { b: 2, c: 3 }],
|
||||
[{ a: 1, b: 2, c: 3 }, ["b"], { a: 1, c: 3 }],
|
||||
[{ a: 1, b: 2, c: 3 }, ["c"], { a: 1, b: 2 }],
|
||||
[{ a: 1, b: 2, c: 3 }, ["a", "b"], { c: 3 }],
|
||||
[{ a: 1, b: 2, c: 3 }, ["a", "b", "c"], {}]
|
||||
] as [object, string[], object][];
|
||||
|
||||
for (const [obj, keys, expected] of objects) {
|
||||
const result = utils.omitKeys(obj, keys as any);
|
||||
expect(result).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("isEqual", () => {
|
||||
const objects = [
|
||||
[1, 1, true],
|
||||
[1, "1", false],
|
||||
[1, 2, false],
|
||||
["1", "1", true],
|
||||
["1", "2", false],
|
||||
[true, true, true],
|
||||
[true, false, false],
|
||||
[false, false, true],
|
||||
[1, NaN, false],
|
||||
[NaN, NaN, true],
|
||||
[null, null, true],
|
||||
[null, undefined, false],
|
||||
[undefined, undefined, true],
|
||||
[new Map([["a", 1]]), new Map([["a", 1]]), true],
|
||||
[new Map([["a", 1]]), new Map([["a", 2]]), false],
|
||||
[new Map([["a", 1]]), new Map([["b", 1]]), false],
|
||||
[
|
||||
new Map([["a", 1]]),
|
||||
new Map([
|
||||
["a", 1],
|
||||
["b", 2]
|
||||
]),
|
||||
false
|
||||
],
|
||||
[{ a: 1 }, { a: 1 }, true],
|
||||
[{ a: 1 }, { a: 2 }, false],
|
||||
[{ a: 1 }, { b: 1 }, false],
|
||||
[{ a: "1" }, { a: "1" }, true],
|
||||
[{ a: "1" }, { a: "2" }, false],
|
||||
[{ a: "1" }, { b: "1" }, false],
|
||||
[{ a: 1 }, { a: 1, b: 2 }, false],
|
||||
[{ a: [1, 2, 3] }, { a: [1, 2, 3] }, true],
|
||||
[{ a: [1, 2, 3] }, { a: [1, 2, 4] }, false],
|
||||
[{ a: [1, 2, 3] }, { a: [1, 2, 3, 4] }, false],
|
||||
[{ a: { b: 1 } }, { a: { b: 1 } }, true],
|
||||
[{ a: { b: 1 } }, { a: { b: 2 } }, false],
|
||||
[{ a: { b: 1 } }, { a: { c: 1 } }, false],
|
||||
[{ a: { b: 1 } }, { a: { b: 1, c: 2 } }, false],
|
||||
[[1, 2, 3], [1, 2, 3], true],
|
||||
[[1, 2, 3], [1, 2, 4], false],
|
||||
[[1, 2, 3], [1, 2, 3, 4], false],
|
||||
[[{ a: 1 }], [{ a: 1 }], true],
|
||||
[[{ a: 1 }], [{ a: 2 }], false],
|
||||
[[{ a: 1 }], [{ b: 1 }], false]
|
||||
] as [any, any, boolean][];
|
||||
|
||||
for (const [a, b, expected] of objects) {
|
||||
const result = utils.isEqual(a, b);
|
||||
expect(result).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("getPath", () => {
|
||||
const tests = [
|
||||
[{ a: 1, b: 2, c: 3 }, "a", 1],
|
||||
[{ a: 1, b: 2, c: 3 }, "b", 2],
|
||||
[{ a: { b: 1 } }, "a.b", 1],
|
||||
[{ a: { b: 1 } }, "a.b.c", null, null],
|
||||
[{ a: { b: 1 } }, "a.b.c", 1, 1],
|
||||
[[[1]], "0.0", 1]
|
||||
] as [object, string, any, any][];
|
||||
|
||||
for (const [obj, path, expected, defaultValue] of tests) {
|
||||
const result = utils.getPath(obj, path, defaultValue);
|
||||
expect(result).toEqual(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,23 +2,9 @@ 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-form/utils";
|
||||
import type { IsTypeType } from "../../src/ui/components/form/json-schema-form/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"],
|
||||
@@ -38,6 +24,25 @@ describe("json form", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("isType", () => {
|
||||
const examples = [
|
||||
["string", "string", true],
|
||||
["integer", "number", false],
|
||||
["number", "number", true],
|
||||
["boolean", "boolean", true],
|
||||
["null", "null", true],
|
||||
["object", "object", true],
|
||||
["array", "array", true],
|
||||
["object", "array", false],
|
||||
[["string", "number"], "number", true],
|
||||
["number", ["string", "number"], true]
|
||||
] satisfies [IsTypeType, IsTypeType, boolean][];
|
||||
|
||||
for (const [type, schemaType, output] of examples) {
|
||||
expect(utils.isType(type, schemaType)).toBe(output);
|
||||
}
|
||||
});
|
||||
|
||||
test("getParentPointer", () => {
|
||||
const examples = [
|
||||
["#/nested/property/0/name", "#/nested/property/0"],
|
||||
@@ -97,49 +102,37 @@ describe("json form", () => {
|
||||
] satisfies [string, Exclude<JSONSchema, boolean>, boolean][];
|
||||
|
||||
for (const [pointer, schema, output] of examples) {
|
||||
expect(utils.isRequired(pointer, schema)).toBe(output);
|
||||
expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output);
|
||||
}
|
||||
});
|
||||
|
||||
test("unflatten", () => {
|
||||
test("prefixPath", () => {
|
||||
const examples = [
|
||||
[
|
||||
{ "#/description": "test" },
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
description: { type: "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
description: "test"
|
||||
}
|
||||
]
|
||||
] satisfies [Record<string, string>, Exclude<JSONSchema, boolean>, object][];
|
||||
["normal", "", "normal"],
|
||||
["", "prefix", "prefix"],
|
||||
["tags", "0", "0.tags"],
|
||||
["tags", 0, "0.tags"],
|
||||
["nested.property", "prefix", "prefix.nested.property"],
|
||||
["nested.property", "", "nested.property"]
|
||||
] satisfies [string, any, string][];
|
||||
|
||||
for (const [input, schema, output] of examples) {
|
||||
expect(utils.unflatten(input, schema)).toEqual(output);
|
||||
for (const [path, prefix, output] of examples) {
|
||||
expect(utils.prefixPath(path, prefix)).toBe(output);
|
||||
}
|
||||
});
|
||||
|
||||
test("...", () => {
|
||||
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"]
|
||||
};
|
||||
test("suffixPath", () => {
|
||||
const examples = [
|
||||
["normal", "", "normal"],
|
||||
["", "suffix", "suffix"],
|
||||
["tags", "0", "tags.0"],
|
||||
["tags", 0, "tags.0"],
|
||||
["nested.property", "suffix", "nested.property.suffix"],
|
||||
["nested.property", "", "nested.property"]
|
||||
] satisfies [string, any, string][];
|
||||
|
||||
//const lib = new Draft2019(schema);
|
||||
//lib.eachSchema(console.log);
|
||||
for (const [path, suffix, output] of examples) {
|
||||
expect(utils.suffixPath(path, suffix)).toBe(output);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,20 @@ export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object";
|
||||
}
|
||||
|
||||
export function omitKeys<T extends object, K extends keyof T>(
|
||||
obj: T,
|
||||
keys_: readonly K[]
|
||||
): Omit<T, Extract<K, keyof T>> {
|
||||
const keys = new Set(keys_);
|
||||
const result = {} as Omit<T, Extract<K, keyof T>>;
|
||||
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
|
||||
if (!keys.has(key as K)) {
|
||||
(result as any)[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
try {
|
||||
@@ -266,3 +280,82 @@ export function mergeObjectWith(object, source, customizer) {
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
export function isEqual(value1: any, value2: any): boolean {
|
||||
// Each type corresponds to a particular comparison algorithm
|
||||
const getType = (value: any) => {
|
||||
if (value !== Object(value)) return "primitive";
|
||||
if (Array.isArray(value)) return "array";
|
||||
if (value instanceof Map) return "map";
|
||||
if (value != null && [null, Object.prototype].includes(Object.getPrototypeOf(value)))
|
||||
return "plainObject";
|
||||
if (value instanceof Function) return "function";
|
||||
throw new Error(
|
||||
`deeply comparing an instance of type ${value1.constructor?.name} is not supported.`
|
||||
);
|
||||
};
|
||||
|
||||
const type = getType(value1);
|
||||
if (type !== getType(value2)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === "primitive") {
|
||||
return value1 === value2 || (Number.isNaN(value1) && Number.isNaN(value2));
|
||||
} else if (type === "array") {
|
||||
return (
|
||||
value1.length === value2.length &&
|
||||
value1.every((iterValue: any, i: number) => isEqual(iterValue, value2[i]))
|
||||
);
|
||||
} else if (type === "map") {
|
||||
// In this particular implementation, map keys are not
|
||||
// being deeply compared, only map values.
|
||||
return (
|
||||
value1.size === value2.size &&
|
||||
[...value1].every(([iterKey, iterValue]) => {
|
||||
return value2.has(iterKey) && isEqual(iterValue, value2.get(iterKey));
|
||||
})
|
||||
);
|
||||
} else if (type === "plainObject") {
|
||||
const value1AsMap = new Map(Object.entries(value1));
|
||||
const value2AsMap = new Map(Object.entries(value2));
|
||||
return (
|
||||
value1AsMap.size === value2AsMap.size &&
|
||||
[...value1AsMap].every(([iterKey, iterValue]) => {
|
||||
return value2AsMap.has(iterKey) && isEqual(iterValue, value2AsMap.get(iterKey));
|
||||
})
|
||||
);
|
||||
} else if (type === "function") {
|
||||
// just check signature
|
||||
return value1.toString() === value2.toString();
|
||||
} else {
|
||||
throw new Error("Unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
export function getPath(
|
||||
object: object,
|
||||
_path: string | (string | number)[],
|
||||
defaultValue = undefined
|
||||
): any {
|
||||
const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path;
|
||||
|
||||
if (path.length === 0) {
|
||||
return object;
|
||||
}
|
||||
|
||||
try {
|
||||
const [head, ...tail] = path;
|
||||
if (!head || !(head in object)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return getPath(object[head], tail, defaultValue);
|
||||
} catch (error) {
|
||||
if (typeof defaultValue !== "undefined") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid path: ${path.join(".")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ export function buildMediaSchema() {
|
||||
{
|
||||
body_max_size: Type.Optional(
|
||||
Type.Number({
|
||||
minimum: 0,
|
||||
description: "Max size of the body in bytes. Leave blank for unlimited."
|
||||
})
|
||||
)
|
||||
|
||||
@@ -191,6 +191,10 @@ export class AdminController extends Controller {
|
||||
/>
|
||||
<link rel="icon" href={favicon} type="image/x-icon" />
|
||||
<title>BKND</title>
|
||||
{/*<script
|
||||
crossOrigin="anonymous"
|
||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||
/>*/}
|
||||
{isProd ? (
|
||||
<Fragment>
|
||||
<script
|
||||
|
||||
@@ -189,7 +189,7 @@ export const Switch = forwardRef<
|
||||
>(({ type, ...props }, ref) => {
|
||||
return (
|
||||
<RadixSwitch.Root
|
||||
className="relative h-7 w-12 p-[2px] cursor-pointer rounded-full bg-muted/50 border border-muted outline-none data-[state=checked]:bg-primary appearance-none transition-colors hover:bg-muted/80"
|
||||
className="relative h-7 w-12 p-[2px] cursor-pointer rounded-full bg-muted border border-primary/10 outline-none data-[state=checked]:bg-primary/75 appearance-none transition-colors hover:bg-muted/80"
|
||||
onCheckedChange={(bool) => {
|
||||
props.onChange?.({ target: { value: bool } });
|
||||
}}
|
||||
@@ -229,7 +229,7 @@ export const Select = forwardRef<
|
||||
<>
|
||||
{!props.required && <option value="" />}
|
||||
{options
|
||||
.map((o, i) => {
|
||||
.map((o) => {
|
||||
if (typeof o !== "object") {
|
||||
return { value: o, label: String(o) };
|
||||
}
|
||||
@@ -246,7 +246,10 @@ export const Select = forwardRef<
|
||||
)}
|
||||
</select>
|
||||
{!props.multiple && (
|
||||
<TbChevronDown className="absolute right-3 top-0 bottom-0 h-full opacity-70" size={18} />
|
||||
<TbChevronDown
|
||||
className="absolute right-3 top-0 bottom-0 h-full opacity-70 pointer-events-none"
|
||||
size={18}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import type { JsonError } from "json-schema-library";
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import { type ChangeEvent, type ReactNode, createContext, useContext, useState } from "react";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import type { JsonError, JsonSchema } from "json-schema-library";
|
||||
import { type ChangeEvent, type ReactNode, createContext, useContext, useMemo } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field";
|
||||
import { FormContextOverride, useFieldContext } from "./Form";
|
||||
import { FormContextOverride, useDerivedFieldContext, useFormError } from "./Form";
|
||||
import { getLabel, getMultiSchemaMatched } from "./utils";
|
||||
|
||||
export type AnyOfFieldRootProps = {
|
||||
path?: string;
|
||||
schema?: Exclude<JSONSchema, boolean>;
|
||||
schema?: JsonSchema;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export type AnyOfFieldContext = {
|
||||
path: string;
|
||||
schema: Exclude<JSONSchema, boolean>;
|
||||
schemas?: JSONSchema[];
|
||||
selectedSchema?: Exclude<JSONSchema, boolean>;
|
||||
schema: JsonSchema;
|
||||
schemas?: JsonSchema[];
|
||||
selectedSchema?: JsonSchema;
|
||||
selected: number | null;
|
||||
select: (index: number | null) => void;
|
||||
options: string[];
|
||||
errors: JsonError[];
|
||||
selectSchema: JSONSchema;
|
||||
selectSchema: JsonSchema;
|
||||
};
|
||||
|
||||
const AnyOfContext = createContext<AnyOfFieldContext>(undefined!);
|
||||
@@ -31,61 +32,80 @@ export const useAnyOfContext = () => {
|
||||
return useContext(AnyOfContext);
|
||||
};
|
||||
|
||||
const selectedAtom = atom<number | null>(null);
|
||||
|
||||
const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) => {
|
||||
const { setValue, pointer, lib, value, errors, ...ctx } = useFieldContext(path);
|
||||
const schema = _schema ?? ctx.schema;
|
||||
const {
|
||||
setValue,
|
||||
lib,
|
||||
pointer,
|
||||
value: { matchedIndex, schemas },
|
||||
schema
|
||||
} = useDerivedFieldContext(path, _schema, (ctx) => {
|
||||
const [matchedIndex, schemas = []] = getMultiSchemaMatched(ctx.schema, ctx.value);
|
||||
return { matchedIndex, schemas };
|
||||
});
|
||||
const errors = useFormError(path, { strict: true });
|
||||
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 = {
|
||||
type: "string",
|
||||
enum: options
|
||||
} satisfies JSONSchema;
|
||||
//console.log("AnyOf:root", { value, matchedIndex, selected, schema });
|
||||
const [_selected, setSelected] = useAtom(selectedAtom);
|
||||
const selected = _selected !== null ? _selected : matchedIndex > -1 ? matchedIndex : null;
|
||||
|
||||
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);
|
||||
const select = useEvent((index: number | null) => {
|
||||
setValue(path, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined);
|
||||
setSelected(index);
|
||||
}
|
||||
});
|
||||
|
||||
const context = useMemo(() => {
|
||||
const options = schemas.map((s, i) => s.title ?? `Option ${i + 1}`);
|
||||
const selectSchema = {
|
||||
type: "string",
|
||||
enum: options
|
||||
} satisfies JsonSchema;
|
||||
|
||||
const selectedSchema = selected !== null ? (schemas[selected] as JsonSchema) : undefined;
|
||||
|
||||
return {
|
||||
options,
|
||||
selectSchema,
|
||||
selectedSchema,
|
||||
schema,
|
||||
schemas,
|
||||
selected
|
||||
};
|
||||
}, [selected]);
|
||||
|
||||
return (
|
||||
<AnyOfContext.Provider
|
||||
key={selected}
|
||||
value={{
|
||||
selected,
|
||||
...context,
|
||||
select,
|
||||
options,
|
||||
selectSchema,
|
||||
path,
|
||||
schema,
|
||||
schemas,
|
||||
selectedSchema,
|
||||
errors
|
||||
}}
|
||||
>
|
||||
{/*<pre>{JSON.stringify({ value, selected, errors: errors.length }, null, 2)}</pre>*/}
|
||||
{children}
|
||||
</AnyOfContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const Select = () => {
|
||||
const { selected, select, path, schema, selectSchema } = useAnyOfContext();
|
||||
const { selected, select, path, schema, options, selectSchema } = useAnyOfContext();
|
||||
|
||||
function handleSelect(e: ChangeEvent<HTMLInputElement>) {
|
||||
//console.log("selected", e.target.value);
|
||||
const handleSelect = useEvent((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const i = e.target.value ? Number(e.target.value) : null;
|
||||
select(i);
|
||||
}
|
||||
});
|
||||
|
||||
const _options = useMemo(() => options.map((label, value) => ({ label, value })), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formy.Label>{getLabel(path, schema)}</Formy.Label>
|
||||
<FieldComponent
|
||||
schema={selectSchema as any}
|
||||
/* @ts-ignore */
|
||||
options={_options}
|
||||
onChange={handleSelect}
|
||||
value={selected ?? undefined}
|
||||
className="h-8 py-1"
|
||||
@@ -94,11 +114,12 @@ const Select = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const Field = ({ name, label, ...props }: Partial<FormFieldProps>) => {
|
||||
// @todo: add local validation for AnyOf fields
|
||||
const Field = ({ name, label, schema, ...props }: Partial<FormFieldProps>) => {
|
||||
const { selected, selectedSchema, path, errors } = useAnyOfContext();
|
||||
if (selected === null) return null;
|
||||
return (
|
||||
<FormContextOverride path={path} schema={selectedSchema} overrideData>
|
||||
<FormContextOverride prefix={path} schema={selectedSchema}>
|
||||
<div className={twMerge(errors.length > 0 && "bg-red-500/10")}>
|
||||
<FormField key={`${path}_${selected}`} name={""} label={false} {...props} />
|
||||
</div>
|
||||
|
||||
@@ -1,49 +1,32 @@
|
||||
import { IconLibraryPlus, IconTrash } from "@tabler/icons-react";
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import type { JsonSchema } from "json-schema-library";
|
||||
import { memo, useMemo } from "react";
|
||||
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 { useEvent } from "ui/hooks/use-event";
|
||||
import { FieldComponent } from "./Field";
|
||||
import { FieldWrapper } from "./FieldWrapper";
|
||||
import { useFieldContext } from "./Form";
|
||||
import { coerce, getMultiSchema, getMultiSchemaMatched } from "./utils";
|
||||
import { useDerivedFieldContext, useFormValue } from "./Form";
|
||||
import { coerce, getMultiSchema, getMultiSchemaMatched, isEqual, suffixPath } from "./utils";
|
||||
|
||||
export const ArrayField = ({
|
||||
path = "",
|
||||
schema: _schema
|
||||
}: { path?: string; schema?: Exclude<JSONSchema, boolean> }) => {
|
||||
const { setValue, value, pointer, required, ...ctx } = useFieldContext(path);
|
||||
}: { path?: string; schema?: JsonSchema }) => {
|
||||
const { setValue, pointer, required, ...ctx } = useDerivedFieldContext(path, _schema);
|
||||
const schema = _schema ?? ctx.schema;
|
||||
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
// if unique items with enum
|
||||
if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) {
|
||||
return (
|
||||
<FieldWrapper pointer={path} schema={schema} wrapper="fieldset">
|
||||
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
|
||||
<FieldComponent
|
||||
required
|
||||
name={path}
|
||||
schema={schema.items}
|
||||
multiple
|
||||
value={value}
|
||||
className="h-auto"
|
||||
onChange={(e: any) => {
|
||||
// @ts-ignore
|
||||
@@ -56,48 +39,100 @@ export const ArrayField = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldWrapper pointer={path} schema={schema} wrapper="fieldset">
|
||||
{value?.map((v, index: number) => {
|
||||
const pointer = `${path}/${index}`.replace(/\/+/g, "/");
|
||||
let subschema = schema.items;
|
||||
if (itemsMultiSchema) {
|
||||
const [, , _subschema] = getMultiSchemaMatched(schema.items, v);
|
||||
subschema = _subschema;
|
||||
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
|
||||
<ArrayIterator name={path}>
|
||||
{({ value }) =>
|
||||
value?.map((v, index: number) => (
|
||||
<ArrayItem key={index} path={path} index={index} schema={schema} />
|
||||
))
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</ArrayIterator>
|
||||
<div className="flex flex-row">
|
||||
{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>
|
||||
)}
|
||||
<ArrayAdd path={path} schema={schema} />
|
||||
</div>
|
||||
</FieldWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const ArrayItem = memo(({ path, index, schema }: any) => {
|
||||
const { value, ...ctx } = useDerivedFieldContext(path, schema, (ctx) => {
|
||||
return ctx.value?.[index];
|
||||
});
|
||||
const itemPath = suffixPath(path, index);
|
||||
let subschema = schema.items;
|
||||
const itemsMultiSchema = getMultiSchema(schema.items);
|
||||
if (itemsMultiSchema) {
|
||||
const [, , _subschema] = getMultiSchemaMatched(schema.items, value);
|
||||
subschema = _subschema;
|
||||
}
|
||||
|
||||
const handleUpdate = useEvent((pointer: string, value: any) => {
|
||||
ctx.setValue(pointer, value);
|
||||
});
|
||||
|
||||
const handleDelete = useEvent((pointer: string) => {
|
||||
ctx.deleteValue(pointer);
|
||||
});
|
||||
|
||||
const DeleteButton = useMemo(
|
||||
() => <IconButton Icon={IconTrash} onClick={() => handleDelete(itemPath)} size="sm" />,
|
||||
[itemPath]
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={itemPath} className="flex flex-row gap-2">
|
||||
<FieldComponent
|
||||
name={itemPath}
|
||||
schema={subschema!}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
handleUpdate(itemPath, coerce(e.target.value, subschema!));
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
{DeleteButton}
|
||||
</div>
|
||||
);
|
||||
}, isEqual);
|
||||
|
||||
const ArrayIterator = memo(
|
||||
({ name, children }: any) => {
|
||||
return children(useFormValue(name));
|
||||
},
|
||||
(prev, next) => prev.value?.length === next.value?.length
|
||||
);
|
||||
|
||||
const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
|
||||
const {
|
||||
setValue,
|
||||
value: { currentIndex },
|
||||
...ctx
|
||||
} = useDerivedFieldContext(path, schema, (ctx) => {
|
||||
return { currentIndex: ctx.value?.length ?? 0 };
|
||||
});
|
||||
const itemsMultiSchema = getMultiSchema(schema.items);
|
||||
|
||||
function handleAdd(template?: any) {
|
||||
const newPath = suffixPath(path, currentIndex);
|
||||
setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items));
|
||||
}
|
||||
|
||||
if (itemsMultiSchema) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return <Button onClick={() => handleAdd()}>Add</Button>;
|
||||
};
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import type { JsonSchema } from "json-schema-library";
|
||||
import type { ChangeEvent, ComponentPropsWithoutRef } from "react";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { ArrayField } from "./ArrayField";
|
||||
import { FieldWrapper } from "./FieldWrapper";
|
||||
import { useFieldContext } from "./Form";
|
||||
import { useDerivedFieldContext, useFormValue } from "./Form";
|
||||
import { ObjectField } from "./ObjectField";
|
||||
import { coerce, enumToOptions, isType, isTypeSchema } from "./utils";
|
||||
import { coerce, isType, isTypeSchema } from "./utils";
|
||||
|
||||
export type FieldProps = {
|
||||
name: string;
|
||||
schema?: Exclude<JSONSchema, boolean>;
|
||||
schema?: JsonSchema;
|
||||
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 { path, setValue, required, ...ctx } = useDerivedFieldContext(name, _schema);
|
||||
const schema = _schema ?? ctx.schema;
|
||||
if (!isTypeSchema(schema)) return <Pre>{pointer} has no schema</Pre>;
|
||||
//console.log("field", name, schema);
|
||||
if (!isTypeSchema(schema))
|
||||
return (
|
||||
<Pre>
|
||||
[Field] {path} has no schema ({JSON.stringify(schema)})
|
||||
</Pre>
|
||||
);
|
||||
|
||||
if (isType(schema.type, "object")) {
|
||||
return <ObjectField path={name} schema={schema} />;
|
||||
@@ -30,39 +35,23 @@ export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }
|
||||
}
|
||||
|
||||
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 handleChange = useEvent((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = coerce(e.target.value, schema as any, { required });
|
||||
//console.log("handleChange", pointer, e.target.value, { value }, ctx.options);
|
||||
if (typeof value === "undefined" && !required && ctx.options?.keepEmpty !== true) {
|
||||
ctx.deleteValue(pointer);
|
||||
ctx.deleteValue(path);
|
||||
} else {
|
||||
//console.log("setValue", pointer, value);
|
||||
setValue(pointer, value);
|
||||
setValue(path, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<FieldWrapper
|
||||
pointer={pointer}
|
||||
label={_label}
|
||||
required={required}
|
||||
errors={errors}
|
||||
schema={schema}
|
||||
debug={{ value }}
|
||||
hidden={hidden}
|
||||
>
|
||||
<FieldWrapper name={name} label={_label} required={required} schema={schema} hidden={hidden}>
|
||||
<FieldComponent
|
||||
schema={schema}
|
||||
name={pointer}
|
||||
name={name}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={onChange ?? handleChange}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
@@ -70,28 +59,83 @@ export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }
|
||||
};
|
||||
|
||||
export const Pre = ({ children }) => (
|
||||
<pre className="dark:bg-red-950 bg-red-100 rounded-md px-3 py-1.5">{children}</pre>
|
||||
<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 const FieldComponent = ({
|
||||
schema,
|
||||
...props
|
||||
}: { schema: JSONSchema } & ComponentPropsWithoutRef<"input">) => {
|
||||
..._props
|
||||
}: { schema: JsonSchema } & ComponentPropsWithoutRef<"input">) => {
|
||||
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
|
||||
};
|
||||
|
||||
if (schema.enum) {
|
||||
return (
|
||||
<Formy.Select id={props.name} {...(props as any)} options={enumToOptions(schema.enum)} />
|
||||
);
|
||||
return <Formy.Select id={props.name} options={schema.enum} {...(props as any)} />;
|
||||
}
|
||||
|
||||
if (isType(schema.type, ["number", "integer"])) {
|
||||
return <Formy.Input type="number" id={props.name} {...props} value={props.value ?? ""} />;
|
||||
const additional = {
|
||||
min: schema.minimum,
|
||||
max: schema.maximum,
|
||||
step: schema.multipleOf
|
||||
};
|
||||
|
||||
return (
|
||||
<Formy.Input
|
||||
type="number"
|
||||
id={props.name}
|
||||
{...props}
|
||||
value={props.value ?? ""}
|
||||
{...additional}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isType(schema.type, "boolean")) {
|
||||
return <Formy.Switch id={props.name} {...(props as any)} checked={props.value as any} />;
|
||||
return <Formy.Switch id={props.name} {...(props as any)} checked={value === true} />;
|
||||
}
|
||||
|
||||
return <Formy.Input id={props.name} {...props} value={props.value ?? ""} />;
|
||||
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
|
||||
id={props.name}
|
||||
{...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 id={props.name} {...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.Input id={props.name} {...props} value={props.value ?? ""} {...additional} />;
|
||||
};
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
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 type { JsonSchema } from "json-schema-library";
|
||||
import { Children, type ReactElement, type ReactNode, cloneElement, isValidElement } 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 {
|
||||
useFormContext,
|
||||
useFormError,
|
||||
useFormValue
|
||||
} from "ui/components/form/json-schema-form/Form";
|
||||
import { getLabel } from "./utils";
|
||||
|
||||
export type FieldwrapperProps = {
|
||||
pointer: string;
|
||||
name: string;
|
||||
label?: string | false;
|
||||
required?: boolean;
|
||||
errors?: JsonError[];
|
||||
schema?: Exclude<JSONSchema, boolean>;
|
||||
schema?: JsonSchema;
|
||||
debug?: object | boolean;
|
||||
wrapper?: "group" | "fieldset";
|
||||
hidden?: boolean;
|
||||
@@ -21,21 +24,19 @@ export type FieldwrapperProps = {
|
||||
};
|
||||
|
||||
export function FieldWrapper({
|
||||
pointer,
|
||||
name,
|
||||
label: _label,
|
||||
required,
|
||||
errors = [],
|
||||
schema,
|
||||
debug,
|
||||
wrapper,
|
||||
hidden,
|
||||
children
|
||||
}: FieldwrapperProps) {
|
||||
const errors = useFormError(name, { strict: true });
|
||||
const examples = schema?.examples || [];
|
||||
const examplesId = `${pointer}-examples`;
|
||||
const examplesId = `${name}-examples`;
|
||||
const description = schema?.description;
|
||||
const label =
|
||||
typeof _label !== "undefined" ? _label : schema ? getLabel(pointer, schema) : pointer;
|
||||
const label = typeof _label !== "undefined" ? _label : schema ? getLabel(name, schema) : name;
|
||||
|
||||
return (
|
||||
<Formy.Group
|
||||
@@ -43,34 +44,12 @@ export function FieldWrapper({
|
||||
as={wrapper === "fieldset" ? "fieldset" : "div"}
|
||||
className={hidden ? "hidden" : "relative"}
|
||||
>
|
||||
{debug && (
|
||||
<div className="absolute right-0 top-0">
|
||||
{/* @todo: use radix */}
|
||||
<Popover>
|
||||
<Popover.Target>
|
||||
<IconButton Icon={IconBug} size="xs" className="opacity-30" />
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<JsonViewer
|
||||
json={{
|
||||
...(typeof debug === "object" ? debug : {}),
|
||||
pointer,
|
||||
required,
|
||||
schema,
|
||||
errors
|
||||
}}
|
||||
expand={6}
|
||||
className="p-0"
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
<FieldDebug name={name} schema={schema} required={required} />
|
||||
|
||||
{label && (
|
||||
<Formy.Label
|
||||
as={wrapper === "fieldset" ? "legend" : "label"}
|
||||
htmlFor={pointer}
|
||||
htmlFor={name}
|
||||
className="self-start"
|
||||
>
|
||||
{label} {required && <span className="font-medium opacity-30">*</span>}
|
||||
@@ -100,3 +79,38 @@ export function FieldWrapper({
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
const FieldDebug = ({
|
||||
name,
|
||||
schema,
|
||||
required
|
||||
}: Pick<FieldwrapperProps, "name" | "schema" | "required">) => {
|
||||
const { options } = useFormContext();
|
||||
if (!options?.debug) return null;
|
||||
const { value } = useFormValue(name);
|
||||
const errors = useFormError(name, { strict: true });
|
||||
|
||||
return (
|
||||
<div className="absolute right-0 top-0">
|
||||
{/* @todo: use radix */}
|
||||
<Popover>
|
||||
<Popover.Target>
|
||||
<IconButton Icon={IconBug} size="xs" className="opacity-30" />
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<JsonViewer
|
||||
json={{
|
||||
name,
|
||||
value,
|
||||
required,
|
||||
schema,
|
||||
errors
|
||||
}}
|
||||
expand={6}
|
||||
className="p-0"
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,62 +1,88 @@
|
||||
import { Draft2019, type JsonError } from "json-schema-library";
|
||||
import {
|
||||
type PrimitiveAtom,
|
||||
atom,
|
||||
getDefaultStore,
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
useSetAtom
|
||||
} from "jotai";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
import { Draft2019, type JsonError, type JsonSchema as LibJsonSchema } 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, isEqual } from "lodash-es";
|
||||
import * as immutable from "object-path-immutable";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
useMemo,
|
||||
useRef
|
||||
} from "react";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { Field } from "./Field";
|
||||
import { isRequired, normalizePath, omitSchema, prefixPointer } from "./utils";
|
||||
import {
|
||||
getPath,
|
||||
isEqual,
|
||||
isRequired,
|
||||
omitSchema,
|
||||
pathToPointer,
|
||||
prefixPath,
|
||||
prefixPointer
|
||||
} from "./utils";
|
||||
|
||||
type JSONSchema = Exclude<$JSONSchema, boolean>;
|
||||
type FormState<Data = any> = {
|
||||
dirty: boolean;
|
||||
submitting: boolean;
|
||||
errors: JsonError[];
|
||||
data: Data;
|
||||
};
|
||||
|
||||
export type FormProps<
|
||||
Schema extends JSONSchema = JSONSchema,
|
||||
Data = Schema extends JSONSchema ? FromSchema<JSONSchema> : any
|
||||
> = Omit<ComponentPropsWithoutRef<"form">, "onChange"> & {
|
||||
Data = Schema extends JSONSchema ? FromSchema<Schema> : any,
|
||||
InitialData = Schema extends JSONSchema ? FromSchema<Schema> : any
|
||||
> = Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit"> & {
|
||||
schema: Schema;
|
||||
validateOn?: "change" | "submit";
|
||||
initialValues?: Partial<Data>;
|
||||
initialOpts?: LibTemplateOptions;
|
||||
ignoreKeys?: string[];
|
||||
onChange?: (data: Partial<Data>, name: string, value: any) => void;
|
||||
onSubmit?: (data: Partial<Data>) => void | Promise<void>;
|
||||
onSubmit?: (data: Data) => void | Promise<void>;
|
||||
onInvalidSubmit?: (errors: JsonError[], data: Partial<Data>) => void;
|
||||
hiddenSubmit?: boolean;
|
||||
options?: {
|
||||
debug?: boolean;
|
||||
keepEmpty?: boolean;
|
||||
};
|
||||
initialValues?: InitialData;
|
||||
};
|
||||
|
||||
export type FormContext<Data> = {
|
||||
data: Data;
|
||||
setData: (data: Data) => void;
|
||||
setValue: (pointer: string, value: any) => void;
|
||||
deleteValue: (pointer: string) => void;
|
||||
errors: JsonError[];
|
||||
dirty: boolean;
|
||||
submitting: boolean;
|
||||
schema: JSONSchema;
|
||||
schema: LibJsonSchema;
|
||||
lib: Draft2019;
|
||||
options: FormProps["options"];
|
||||
root: string;
|
||||
_formStateAtom: PrimitiveAtom<FormState<Data>>;
|
||||
};
|
||||
|
||||
const FormContext = createContext<FormContext<any>>(undefined!);
|
||||
FormContext.displayName = "FormContext";
|
||||
|
||||
export function Form<
|
||||
Schema extends JSONSchema = JSONSchema,
|
||||
Data = Schema extends JSONSchema ? FromSchema<JSONSchema> : any
|
||||
Data = Schema extends JSONSchema ? FromSchema<Schema> : any
|
||||
>({
|
||||
schema: _schema,
|
||||
initialValues: _initialValues,
|
||||
@@ -72,24 +98,35 @@ export function Form<
|
||||
...props
|
||||
}: FormProps<Schema, Data>) {
|
||||
const [schema, initial] = omitSchema(_schema, ignoreKeys, _initialValues);
|
||||
const lib = new Draft2019(schema);
|
||||
const lib = useMemo(() => new Draft2019(schema), [JSON.stringify(schema)]);
|
||||
const initialValues = initial ?? lib.getTemplate(undefined, schema, initialOpts);
|
||||
const [data, setData] = useState<Partial<Data>>(initialValues);
|
||||
const [dirty, setDirty] = useState<boolean>(false);
|
||||
const [errors, setErrors] = useState<JsonError[]>([]);
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
const _formStateAtom = useMemo(() => {
|
||||
return atom<FormState<Data>>({
|
||||
dirty: false,
|
||||
submitting: false,
|
||||
errors: [] as JsonError[],
|
||||
data: initialValues
|
||||
});
|
||||
}, [initialValues]);
|
||||
const setFormState = useSetAtom(_formStateAtom);
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
validate();
|
||||
}
|
||||
}, [initialValues]);
|
||||
|
||||
// @ts-ignore
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
if (onSubmit) {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setFormState((prev) => ({ ...prev, submitting: true }));
|
||||
|
||||
try {
|
||||
const { data, errors } = validate();
|
||||
if (errors.length === 0) {
|
||||
await onSubmit(data);
|
||||
await onSubmit(data as Data);
|
||||
} else {
|
||||
console.log("invalid", errors);
|
||||
onInvalidSubmit?.(errors, data);
|
||||
@@ -97,71 +134,70 @@ export function Form<
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
setFormState((prev) => ({ ...prev, submitting: false }));
|
||||
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);
|
||||
onChange?.(changed, key, value);
|
||||
//console.log("changed", prev, changed, { key, value });
|
||||
return changed;
|
||||
const setValue = useEvent((path: string, value: any) => {
|
||||
setFormState((state) => {
|
||||
const prev = state.data;
|
||||
const changed = immutable.set(prev, path, value);
|
||||
onChange?.(changed, path, value);
|
||||
return { ...state, data: changed };
|
||||
});
|
||||
}
|
||||
check();
|
||||
});
|
||||
|
||||
function deleteValue(pointer: string) {
|
||||
const normalized = normalizePath(pointer);
|
||||
const key = normalized.substring(2).replace(/\//g, ".");
|
||||
setData((prev) => {
|
||||
const changed = immutable.del(prev, key);
|
||||
onChange?.(changed, key, undefined);
|
||||
//console.log("changed", prev, changed, { key });
|
||||
return changed;
|
||||
const deleteValue = useEvent((path: string) => {
|
||||
setFormState((state) => {
|
||||
const prev = state.data;
|
||||
const changed = immutable.del(prev, path);
|
||||
onChange?.(changed, path, undefined);
|
||||
return { ...state, data: changed };
|
||||
});
|
||||
}
|
||||
check();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setDirty(!isEqual(initialValues, data));
|
||||
const getCurrentState = useEvent(() => getDefaultStore().get(_formStateAtom));
|
||||
|
||||
const check = useEvent(() => {
|
||||
const state = getCurrentState();
|
||||
setFormState((prev) => ({ ...prev, dirty: !isEqual(initialValues, state.data) }));
|
||||
|
||||
if (validateOn === "change") {
|
||||
validate();
|
||||
} else if (errors.length > 0) {
|
||||
} else if (state?.errors?.length > 0) {
|
||||
validate();
|
||||
}
|
||||
}, [data]);
|
||||
});
|
||||
|
||||
function validate(_data?: Partial<Data>) {
|
||||
const actual = _data ?? data;
|
||||
const validate = useEvent((_data?: Partial<Data>) => {
|
||||
const actual = _data ?? getCurrentState()?.data;
|
||||
const errors = lib.validate(actual, schema);
|
||||
//console.log("errors", errors);
|
||||
setErrors(errors);
|
||||
setFormState((prev) => ({ ...prev, errors }));
|
||||
return { data: actual, errors };
|
||||
}
|
||||
});
|
||||
|
||||
const context = {
|
||||
data: data ?? {},
|
||||
dirty,
|
||||
submitting,
|
||||
setData,
|
||||
setValue,
|
||||
deleteValue,
|
||||
errors,
|
||||
schema,
|
||||
lib,
|
||||
options
|
||||
} as any;
|
||||
//console.log("context", context);
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
_formStateAtom,
|
||||
setValue,
|
||||
deleteValue,
|
||||
schema,
|
||||
lib,
|
||||
options,
|
||||
root: "",
|
||||
path: ""
|
||||
}),
|
||||
[schema, initialValues]
|
||||
) as any;
|
||||
|
||||
return (
|
||||
<form {...props} ref={formRef} onSubmit={handleSubmit}>
|
||||
<FormContext.Provider value={context}>
|
||||
{children ? children : <Field name="" />}
|
||||
{options?.debug && <FormDebug />}
|
||||
</FormContext.Provider>
|
||||
{hiddenSubmit && (
|
||||
<button style={{ visibility: "hidden" }} type="submit">
|
||||
@@ -179,25 +215,21 @@ export function useFormContext() {
|
||||
export function FormContextOverride({
|
||||
children,
|
||||
overrideData,
|
||||
path,
|
||||
prefix,
|
||||
...overrides
|
||||
}: Partial<FormContext<any>> & { children: ReactNode; path?: string; overrideData?: boolean }) {
|
||||
}: Partial<FormContext<any>> & { children: ReactNode; prefix?: 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;
|
||||
if (prefix) {
|
||||
additional.root = prefix;
|
||||
additional.setValue = (pointer: string, value: any) => {
|
||||
ctx.setValue(prefixPointer(pointer, path), value);
|
||||
ctx.setValue(prefixPointer(pointer, prefix), value);
|
||||
};
|
||||
additional.deleteValue = (pointer: string) => {
|
||||
ctx.deleteValue(prefixPointer(pointer, path));
|
||||
ctx.deleteValue(prefixPointer(pointer, prefix));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,37 +242,137 @@ export function FormContextOverride({
|
||||
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);
|
||||
export function useFormValue(name: string, opts?: { strict?: boolean }) {
|
||||
const { _formStateAtom, root } = useFormContext();
|
||||
if ((typeof name !== "string" || name.length === 0) && opts?.strict === true)
|
||||
return { value: undefined, errors: [] };
|
||||
|
||||
const selected = selectAtom(
|
||||
_formStateAtom,
|
||||
useCallback(
|
||||
(state) => {
|
||||
const prefixedName = prefixPath(name, root);
|
||||
const pointer = pathToPointer(prefixedName);
|
||||
return {
|
||||
value: getPath(state.data, prefixedName),
|
||||
errors: state.errors.filter((error) => error.data.pointer.startsWith(pointer))
|
||||
};
|
||||
},
|
||||
[name]
|
||||
),
|
||||
isEqual
|
||||
);
|
||||
return useAtom(selected)[0];
|
||||
}
|
||||
|
||||
export function useFormError(name: string, opt?: { strict?: boolean; debug?: boolean }) {
|
||||
const { _formStateAtom, root } = useFormContext();
|
||||
const selected = selectAtom(
|
||||
_formStateAtom,
|
||||
useCallback(
|
||||
(state) => {
|
||||
const prefixedName = prefixPath(name, root);
|
||||
const pointer = pathToPointer(prefixedName);
|
||||
return state.errors.filter((error) => {
|
||||
return opt?.strict
|
||||
? error.data.pointer === pointer
|
||||
: error.data.pointer.startsWith(pointer);
|
||||
});
|
||||
},
|
||||
[name]
|
||||
),
|
||||
isEqual
|
||||
);
|
||||
return useAtom(selected)[0];
|
||||
}
|
||||
|
||||
export function useFormStateSelector<Data = any, Reduced = Data>(
|
||||
selector: (state: FormState<Data>) => Reduced
|
||||
): Reduced {
|
||||
const { _formStateAtom } = useFormContext();
|
||||
const selected = selectAtom(_formStateAtom, useCallback(selector, []), isEqual);
|
||||
return useAtom(selected)[0];
|
||||
}
|
||||
|
||||
type SelectorFn<Ctx = any, Refined = any> = (state: Ctx) => Refined;
|
||||
|
||||
export function useDerivedFieldContext<Data = any, Reduced = undefined>(
|
||||
path,
|
||||
_schema?: LibJsonSchema,
|
||||
deriveFn?: SelectorFn<
|
||||
FormContext<Data> & {
|
||||
pointer: string;
|
||||
required: boolean;
|
||||
value: any;
|
||||
path: string;
|
||||
},
|
||||
Reduced
|
||||
>
|
||||
): FormContext<Data> & {
|
||||
value: Reduced;
|
||||
pointer: string;
|
||||
required: boolean;
|
||||
path: string;
|
||||
} {
|
||||
const { _formStateAtom, root, lib, ...ctx } = useFormContext();
|
||||
const schema = _schema ?? ctx.schema;
|
||||
const selected = selectAtom(
|
||||
_formStateAtom,
|
||||
useCallback(
|
||||
(state) => {
|
||||
const pointer = pathToPointer(path);
|
||||
const prefixedName = prefixPath(path, root);
|
||||
const prefixedPointer = pathToPointer(prefixedName);
|
||||
const value = getPath(state.data, prefixedName);
|
||||
/*const errors = state.errors.filter((error) =>
|
||||
error.data.pointer.startsWith(prefixedPointer)
|
||||
);*/
|
||||
const fieldSchema =
|
||||
pointer === "#/"
|
||||
? (schema as LibJsonSchema)
|
||||
: lib.getSchema({ pointer, data: value, schema });
|
||||
const required = isRequired(lib, prefixedPointer, schema, state.data);
|
||||
|
||||
const context = {
|
||||
...ctx,
|
||||
path: prefixedName,
|
||||
root,
|
||||
schema: fieldSchema as LibJsonSchema,
|
||||
pointer,
|
||||
required
|
||||
};
|
||||
const derived = deriveFn?.({ ...context, _formStateAtom, lib, value });
|
||||
|
||||
return {
|
||||
...context,
|
||||
value: derived
|
||||
};
|
||||
},
|
||||
[path, schema ?? {}, root]
|
||||
),
|
||||
isEqual
|
||||
);
|
||||
return {
|
||||
...rest,
|
||||
lib,
|
||||
value,
|
||||
errors,
|
||||
schema: fieldSchema,
|
||||
pointer,
|
||||
required
|
||||
};
|
||||
...useAtomValue(selected),
|
||||
_formStateAtom,
|
||||
lib
|
||||
} as any;
|
||||
}
|
||||
|
||||
export function Subscribe({ children }: { children: (ctx: FormContext<any>) => ReactNode }) {
|
||||
const ctx = useFormContext();
|
||||
return children(ctx);
|
||||
export function Subscribe<Data = any, Refined = Data>({
|
||||
children,
|
||||
selector
|
||||
}: {
|
||||
children: (state: Refined) => ReactNode;
|
||||
selector?: SelectorFn<FormState<Data>, Refined>;
|
||||
}) {
|
||||
return children(useFormStateSelector(selector ?? ((state) => state as unknown as Refined)));
|
||||
}
|
||||
|
||||
export function FormDebug() {
|
||||
const { options, data, dirty, errors, submitting } = useFormContext();
|
||||
if (options?.debug !== true) return null;
|
||||
export function FormDebug({ force = false }: { force?: boolean }) {
|
||||
const { options } = useFormContext();
|
||||
if (options?.debug !== true && force !== true) return null;
|
||||
const ctx = useFormStateSelector((s) => s);
|
||||
|
||||
return <JsonViewer json={{ dirty, submitting, data, errors }} expand={99} />;
|
||||
return <JsonViewer json={ctx} expand={99} />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { JsonError } from "json-schema-library";
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import { isTypeSchema } from "ui/components/form/json-schema-form/utils";
|
||||
import { AnyOfField } from "./AnyOfField";
|
||||
import { Field } from "./Field";
|
||||
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
||||
import { useFieldContext } from "./Form";
|
||||
import { useDerivedFieldContext } from "./Form";
|
||||
|
||||
export type ObjectFieldProps = {
|
||||
path?: string;
|
||||
@@ -18,29 +18,28 @@ export const ObjectField = ({
|
||||
label: _label,
|
||||
wrapperProps = {}
|
||||
}: ObjectFieldProps) => {
|
||||
const ctx = useFieldContext(path);
|
||||
const ctx = useDerivedFieldContext(path, _schema);
|
||||
const schema = _schema ?? ctx.schema;
|
||||
if (!schema) return "ObjectField: no schema";
|
||||
if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`;
|
||||
const properties = schema.properties ?? {};
|
||||
|
||||
return (
|
||||
<FieldWrapper
|
||||
pointer={path}
|
||||
errors={ctx.errors}
|
||||
name={path}
|
||||
schema={{ ...schema, description: undefined }}
|
||||
wrapper="fieldset"
|
||||
{...wrapperProps}
|
||||
>
|
||||
{Object.keys(properties).map((prop) => {
|
||||
const schema = properties[prop];
|
||||
const pointer = `${path}/${prop}`.replace(/\/+/g, "/");
|
||||
if (!schema) return;
|
||||
const name = [path, prop].filter(Boolean).join(".");
|
||||
if (typeof schema === "undefined" || typeof schema === "boolean") return;
|
||||
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
return <AnyOfField key={pointer} path={pointer} />;
|
||||
return <AnyOfField key={name} path={name} />;
|
||||
}
|
||||
|
||||
return <Field key={pointer} name={pointer} />;
|
||||
return <Field key={name} name={name} />;
|
||||
})}
|
||||
</FieldWrapper>
|
||||
);
|
||||
|
||||
@@ -1,88 +1,12 @@
|
||||
import { autoFormatString } from "core/utils";
|
||||
import { Draft2019, type JsonSchema } from "json-schema-library";
|
||||
import { autoFormatString, omitKeys } from "core/utils";
|
||||
import { type Draft, 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 { omit, 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;
|
||||
export { isEqual, getPath } from "core/utils/objects";
|
||||
//export { isEqual } from "lodash-es";
|
||||
|
||||
// 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 }
|
||||
) {
|
||||
export function coerce(value: any, schema: JsonSchema, opts?: { required?: boolean }) {
|
||||
if (!value && typeof opts?.required === "boolean" && !opts.required) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -102,41 +26,37 @@ export function coerce(
|
||||
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 pathToPointer(path: string) {
|
||||
return "#/" + (path.includes(".") ? path.split(".").join("/") : path);
|
||||
}
|
||||
|
||||
export function prefixPointer(pointer: string, prefix: string) {
|
||||
return pointer.replace("#/", `#/${prefix}/`).replace(/\/\//g, "/");
|
||||
return pointer.replace("#/", `#/${prefix.length > 0 ? prefix + "/" : ""}`).replace(/\/\//g, "/");
|
||||
}
|
||||
|
||||
const PathFilter = (value: any) => typeof value !== "undefined" && value !== null && value !== "";
|
||||
|
||||
export function prefixPath(path: string = "", prefix: string | number = "") {
|
||||
const p = path.includes(".") ? path.split(".") : [path];
|
||||
return [prefix, ...p].filter(PathFilter).join(".");
|
||||
}
|
||||
|
||||
export function suffixPath(path: string = "", suffix: string | number = "") {
|
||||
const p = path.includes(".") ? path.split(".") : [path];
|
||||
return [...p, suffix].filter(PathFilter).join(".");
|
||||
}
|
||||
|
||||
export function getParentPointer(pointer: string) {
|
||||
return pointer.substring(0, pointer.lastIndexOf("/"));
|
||||
}
|
||||
|
||||
export function isRequired(pointer: string, schema: JSONSchema, data?: any) {
|
||||
if (pointer === "#/") {
|
||||
export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data?: any) {
|
||||
if (pointer === "#/" || !schema) {
|
||||
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)) {
|
||||
const childSchema = lib.getSchema({ pointer, data, schema });
|
||||
if (typeof childSchema === "object" && "const" in childSchema) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -144,31 +64,30 @@ export function isRequired(pointer: string, schema: JSONSchema, data?: any) {
|
||||
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[] | string | 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 type IsTypeType =
|
||||
| JSONSchemaType
|
||||
| JSONSchemaType[]
|
||||
| readonly JSONSchemaType[]
|
||||
| string
|
||||
| undefined;
|
||||
export function isType(type: IsTypeType, compare: IsTypeType) {
|
||||
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) {
|
||||
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;
|
||||
if (!name) return "";
|
||||
const label = name.includes(".") ? (name.split(".").pop() ?? "") : name;
|
||||
return autoFormatString(label);
|
||||
}
|
||||
|
||||
export function getMultiSchema(schema: JSONSchema): Exclude<JSONSchema, boolean>[] | undefined {
|
||||
export function getMultiSchema(schema: JsonSchema): JsonSchema[] | undefined {
|
||||
if (!schema || typeof schema !== "object") return;
|
||||
return (schema.anyOf ?? schema.oneOf) as any;
|
||||
}
|
||||
@@ -176,8 +95,9 @@ export function getMultiSchema(schema: JSONSchema): Exclude<JSONSchema, boolean>
|
||||
export function getMultiSchemaMatched(
|
||||
schema: JsonSchema,
|
||||
data: any
|
||||
): [number, Exclude<JSONSchema, boolean>[], Exclude<JSONSchema, boolean> | undefined] {
|
||||
): [number, JsonSchema[], JsonSchema | undefined] {
|
||||
const multiSchema = getMultiSchema(schema);
|
||||
//console.log("getMultiSchemaMatched", schema, data, multiSchema);
|
||||
if (!multiSchema) return [-1, [], undefined];
|
||||
const index = multiSchema.findIndex((subschema) => {
|
||||
const lib = new Draft2019(subschema as any);
|
||||
@@ -188,19 +108,6 @@ export function getMultiSchemaMatched(
|
||||
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;
|
||||
}
|
||||
|
||||
export function omitSchema<Given extends JSONSchema>(_schema: Given, keys: string[], _data?: any) {
|
||||
if (typeof _schema !== "object" || !("properties" in _schema) || keys.length === 0)
|
||||
return [_schema, _data];
|
||||
@@ -209,24 +116,17 @@ export function omitSchema<Given extends JSONSchema>(_schema: Given, keys: strin
|
||||
|
||||
const updated = {
|
||||
...schema,
|
||||
properties: omit(schema.properties, keys)
|
||||
properties: omitKeys(schema.properties, keys)
|
||||
};
|
||||
if (updated.required) {
|
||||
updated.required = updated.required.filter((key) => !keys.includes(key as any));
|
||||
}
|
||||
|
||||
const reducedConfig = omit(data, keys) as any;
|
||||
const reducedConfig = omitKeys(data, keys) as any;
|
||||
|
||||
return [updated, reducedConfig];
|
||||
}
|
||||
|
||||
export function isTypeSchema(schema?: JSONSchema): schema is Exclude<JSONSchema, boolean> {
|
||||
export function isTypeSchema(schema?: JsonSchema): schema is JsonSchema {
|
||||
return typeof schema === "object" && "type" in schema && !isType(schema.type, "error");
|
||||
}
|
||||
|
||||
export function enumToOptions(_enum: any) {
|
||||
if (!Array.isArray(_enum)) return [];
|
||||
return _enum.map((v, i) =>
|
||||
typeof v === "string" ? { value: v, label: v } : { value: i, label: v }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
import type { IconType } from "react-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
export function Root({ children }) {
|
||||
return (
|
||||
@@ -74,8 +74,15 @@ export function Content({ children, center }: { children: React.ReactNode; cente
|
||||
}
|
||||
|
||||
export function Main({ children }) {
|
||||
const { sidebar } = useAppShell();
|
||||
return (
|
||||
<div data-shell="main" className="flex flex-col flex-grow w-1 flex-shrink-0">
|
||||
<div
|
||||
data-shell="main"
|
||||
className={twMerge(
|
||||
"flex flex-col flex-grow w-1 flex-shrink-1",
|
||||
sidebar.open && "max-w-[calc(100%-350px)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -298,7 +305,7 @@ export function Scrollable({
|
||||
|
||||
return (
|
||||
<ScrollArea.Root style={{ height: `calc(100dvh - ${offset}px` }} ref={scrollRef}>
|
||||
<ScrollArea.Viewport className="w-full h-full ">{children}</ScrollArea.Viewport>
|
||||
<ScrollArea.Viewport className="w-full h-full">{children}</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
forceMount
|
||||
className="flex select-none touch-none bg-transparent w-0.5"
|
||||
|
||||
@@ -73,18 +73,15 @@ export function HeaderNavigation() {
|
||||
<>
|
||||
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
|
||||
{items.map((item) => (
|
||||
<Tooltip
|
||||
key={item.label}
|
||||
label={item.tooltip}
|
||||
disabled={typeof item.tooltip === "undefined"}
|
||||
position="bottom"
|
||||
<NavLink
|
||||
key={item.href}
|
||||
as={Link}
|
||||
href={item.href}
|
||||
Icon={item.Icon}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<div>
|
||||
<NavLink as={Link} href={item.href} Icon={item.Icon} disabled={item.disabled}>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<nav className="flex md:hidden flex-row items-center">
|
||||
|
||||
@@ -42,20 +42,25 @@ function MediaSettingsInternal() {
|
||||
async function onSubmit(data: any) {
|
||||
console.log("submit", data);
|
||||
await actions.config.patch(data);
|
||||
//await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form schema={schema} initialValues={config as any} onSubmit={onSubmit} {...formConfig}>
|
||||
<Subscribe>
|
||||
<Subscribe
|
||||
selector={(state) => ({
|
||||
dirty: state.dirty,
|
||||
errors: state.errors.length > 0,
|
||||
submitting: state.submitting
|
||||
})}
|
||||
>
|
||||
{({ dirty, errors, submitting }) => (
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!dirty || errors.length > 0 || submitting}
|
||||
disabled={!dirty || errors || submitting}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
@@ -132,7 +137,7 @@ function Adapters() {
|
||||
<Formy.Label as="legend" className="font-mono px-2">
|
||||
{autoFormatString(ctx.selectedSchema!.title!)}
|
||||
</Formy.Label>
|
||||
<FormContextOverride schema={ctx.selectedSchema} path={ctx.path} overrideData>
|
||||
<FormContextOverride schema={ctx.selectedSchema} prefix={ctx.path}>
|
||||
<Field name="type" hidden />
|
||||
<ObjectField path="config" wrapperProps={{ label: false, wrapper: "group" }} />
|
||||
</FormContextOverride>
|
||||
@@ -143,9 +148,9 @@ function Adapters() {
|
||||
}
|
||||
|
||||
const Overlay = () => (
|
||||
<Subscribe>
|
||||
{({ data }) =>
|
||||
!data.enabled && (
|
||||
<Subscribe selector={(state) => ({ enabled: state.data.enabled })}>
|
||||
{({ enabled }) =>
|
||||
!enabled && (
|
||||
<div className="absolute w-full h-full z-50 bg-background opacity-70 pointer-events-none" />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ function TestRoot({ children }) {
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
</AppShell.Sidebar>
|
||||
<main className="flex flex-col flex-grow">{children}</main>
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import {
|
||||
AnyOf,
|
||||
AnyOfField,
|
||||
Field,
|
||||
Form,
|
||||
FormContextOverride,
|
||||
@@ -10,9 +12,32 @@ import {
|
||||
} from "ui/components/form/json-schema-form";
|
||||
import { Scrollable } from "ui/layouts/AppShell/AppShell";
|
||||
|
||||
const schema2 = {
|
||||
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"]
|
||||
};
|
||||
|
||||
export default function JsonSchemaForm3() {
|
||||
const { schema, config } = useBknd();
|
||||
|
||||
config.media.storage.body_max_size = 1;
|
||||
schema.media.properties.storage.properties.body_max_size.minimum = 0;
|
||||
//schema.media.properties.adapter.anyOf[2].properties.config.properties.path.minLength = 1;
|
||||
|
||||
return (
|
||||
<Scrollable>
|
||||
<div className="flex flex-col p-3">
|
||||
@@ -20,7 +45,65 @@ export default function JsonSchemaForm3() {
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", default: "Peter" },
|
||||
name: { type: "string", default: "Peter", maxLength: 3 },
|
||||
age: { type: "number" },
|
||||
deep: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: { type: "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["age"]
|
||||
}}
|
||||
initialValues={{ name: "Peter", age: 20, deep: { nested: "hello" } }}
|
||||
className="flex flex-col gap-3"
|
||||
validateOn="change"
|
||||
/>*/}
|
||||
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", default: "Peter", minLength: 3 },
|
||||
age: { type: "number" },
|
||||
deep: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: { type: "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
nested2: { type: "string" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
required: ["age"]
|
||||
}}
|
||||
className="flex flex-col gap-3"
|
||||
validateOn="change"
|
||||
>
|
||||
<Field name="" />
|
||||
<Subscribe2>
|
||||
{(state) => (
|
||||
<pre className="text-wrap whitespace-break-spaces break-all">
|
||||
{JSON.stringify(state, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</Subscribe2>
|
||||
</Form>*/}
|
||||
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", default: "Peter", maxLength: 3 },
|
||||
age: { type: "number" },
|
||||
gender: {
|
||||
type: "string",
|
||||
@@ -36,11 +119,23 @@ export default function JsonSchemaForm3() {
|
||||
required: ["age"]
|
||||
}}
|
||||
className="flex flex-col gap-3"
|
||||
validateOn="change"
|
||||
>
|
||||
<div>random thing</div>
|
||||
<Field name="name" />
|
||||
<Field name="age" />
|
||||
<Field name="gender" />
|
||||
<Field name="deep" />
|
||||
<FormDebug />
|
||||
<FormDebug2 name="name" />
|
||||
<hr />
|
||||
<Subscribe2
|
||||
selector={(state) => ({ dirty: state.dirty, submitting: state.submitting })}
|
||||
>
|
||||
{(state) => (
|
||||
<pre className="text-wrap whitespace-break-spaces break-all">
|
||||
{JSON.stringify(state)}
|
||||
</pre>
|
||||
)}
|
||||
</Subscribe2>
|
||||
</Form>*/}
|
||||
|
||||
{/*<Form
|
||||
@@ -90,7 +185,7 @@ export default function JsonSchemaForm3() {
|
||||
>
|
||||
<AutoForm />
|
||||
</Form>*/}
|
||||
<Form
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -113,12 +208,12 @@ export default function JsonSchemaForm3() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
initialValues={{ tags: [0, 1] }}
|
||||
initialValues={{ tags: [0, 1], method: ["GET"] }}
|
||||
options={{ debug: true }}
|
||||
>
|
||||
<Field name="" />
|
||||
<FormDebug />
|
||||
</Form>
|
||||
</Form>*/}
|
||||
|
||||
{/*<Form
|
||||
schema={{
|
||||
@@ -139,10 +234,20 @@ export default function JsonSchemaForm3() {
|
||||
}
|
||||
}}
|
||||
initialValues={{ tags: [0, 1] }}
|
||||
/>*/}
|
||||
>
|
||||
<Field name="" />
|
||||
<FormDebug force />
|
||||
</Form>*/}
|
||||
|
||||
{/*<CustomMediaForm />*/}
|
||||
{/*<Form schema={schema.media} initialValues={config.media} />*/}
|
||||
<Form
|
||||
schema={schema.media}
|
||||
initialValues={config.media as any}
|
||||
/* validateOn="change"*/
|
||||
onSubmit={console.log}
|
||||
>
|
||||
<Field name="" />
|
||||
</Form>
|
||||
|
||||
{/*<Form
|
||||
schema={removeKeyRecursively(schema.media, "pattern") as any}
|
||||
@@ -157,16 +262,57 @@ export default function JsonSchemaForm3() {
|
||||
>
|
||||
<AutoForm />
|
||||
</Form>*/}
|
||||
|
||||
{/*<Form schema={ss} validateOn="change" />*/}
|
||||
</div>
|
||||
</Scrollable>
|
||||
);
|
||||
}
|
||||
|
||||
const ss = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
email: { type: "string", format: "email" },
|
||||
interested: { type: "boolean" },
|
||||
bla: {
|
||||
type: "string",
|
||||
enum: ["small", "medium", "large"]
|
||||
},
|
||||
password: { type: "string", format: "password" },
|
||||
birthdate: { type: "string", format: "date" },
|
||||
dinnerTime: { type: "string", format: "date-time" },
|
||||
age: { type: "number", minimum: 0, multipleOf: 5 },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string"
|
||||
}
|
||||
},
|
||||
config: {
|
||||
type: "object",
|
||||
properties: {
|
||||
min: { type: "number" }
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["name"],
|
||||
additionalProperties: false
|
||||
} as const satisfies JSONSchema;
|
||||
|
||||
function CustomMediaForm() {
|
||||
const { schema, config } = useBknd();
|
||||
|
||||
config.media.storage.body_max_size = 1;
|
||||
schema.media.properties.storage.properties.body_max_size.minimum = 0;
|
||||
|
||||
return (
|
||||
<Form schema={schema.media} initialValues={config.media} className="flex flex-col gap-3">
|
||||
<Form
|
||||
schema={schema.media}
|
||||
initialValues={config.media as any}
|
||||
className="flex flex-col gap-3"
|
||||
validateOn="change"
|
||||
>
|
||||
<Field name="enabled" />
|
||||
<Field name="basepath" />
|
||||
<Field name="entity_name" />
|
||||
@@ -174,6 +320,7 @@ function CustomMediaForm() {
|
||||
<AnyOf.Root path="adapter">
|
||||
<CustomMediaFormAdapter />
|
||||
</AnyOf.Root>
|
||||
<FormDebug force />
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -196,7 +343,7 @@ function CustomMediaFormAdapter() {
|
||||
</div>
|
||||
|
||||
{ctx.selected !== null && (
|
||||
<FormContextOverride schema={ctx.selectedSchema} path={ctx.path} overrideData>
|
||||
<FormContextOverride schema={ctx.selectedSchema} prefix={ctx.path}>
|
||||
<Field name="type" hidden />
|
||||
<ObjectField path="config" wrapperProps={{ label: false, wrapper: "group" }} />
|
||||
</FormContextOverride>
|
||||
|
||||
@@ -34,7 +34,10 @@ body {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
/*div[data-radix-scroll-area-viewport] > div:first-child {}*/
|
||||
div[data-radix-scroll-area-viewport] > div:first-child {
|
||||
min-width: auto !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* hide calendar icon on inputs */
|
||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
|
||||
|
||||
@@ -26,6 +26,7 @@ type BkndContextProps = {
|
||||
};
|
||||
|
||||
const BkndContextContext = createContext<BkndGlobalContextProps>({} as any);
|
||||
BkndContextContext.displayName = "BkndContext";
|
||||
|
||||
export const BkndContext = ({
|
||||
children,
|
||||
|
||||
Reference in New Issue
Block a user