From f29641c702fea7d2b51276b79fad6c5f760fac31 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 8 Feb 2025 09:18:24 +0100 Subject: [PATCH] using path instead of pointer, replaced lodash usage --- app/__test__/core/utils.spec.ts | 110 ++++++++++-- app/__test__/ui/json-form.spec.ts | 93 +++++------ app/src/core/utils/objects.ts | 93 +++++++++++ app/src/media/media-schema.ts | 1 - .../form/json-schema-form/AnyOfField.tsx | 19 ++- .../form/json-schema-form/ArrayField.tsx | 21 ++- .../form/json-schema-form/Field.tsx | 10 +- .../components/form/json-schema-form/Form.tsx | 53 +++--- .../components/form/json-schema-form/utils.ts | 156 ++++-------------- app/src/ui/routes/media/media.settings.tsx | 1 - .../routes/test/tests/json-schema-form3.tsx | 25 +-- 11 files changed, 325 insertions(+), 257 deletions(-) diff --git a/app/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts index c7ccd45..b484be8 100644 --- a/app/__test__/core/utils.spec.ts +++ b/app/__test__/core/utils.spec.ts @@ -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); + } + }); + }); }); diff --git a/app/__test__/ui/json-form.spec.ts b/app/__test__/ui/json-form.spec.ts index 644544b..2fa535a 100644 --- a/app/__test__/ui/json-form.spec.ts +++ b/app/__test__/ui/json-form.spec.ts @@ -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, 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, Exclude, 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); + } }); }); diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index ab5b807..2c7c68d 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -12,6 +12,20 @@ export function isObject(value: unknown): value is Record { return value !== null && typeof value === "object"; } +export function omitKeys( + obj: T, + keys_: readonly K[] +): Omit> { + const keys = new Set(keys_); + const result = {} as Omit>; + 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(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(".")}`); + } +} diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index f1d793f..f196c79 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -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." }) ) diff --git a/app/src/ui/components/form/json-schema-form/AnyOfField.tsx b/app/src/ui/components/form/json-schema-form/AnyOfField.tsx index 6671898..a51d107 100644 --- a/app/src/ui/components/form/json-schema-form/AnyOfField.tsx +++ b/app/src/ui/components/form/json-schema-form/AnyOfField.tsx @@ -5,7 +5,7 @@ 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, useDerivedFieldContext } from "./Form"; +import { FormContextOverride, useDerivedFieldContext, useFormError } from "./Form"; import { getLabel, getMultiSchemaMatched } from "./utils"; export type AnyOfFieldRootProps = { @@ -39,19 +39,19 @@ const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) => setValue, lib, pointer, - errors, 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 [_selected, setSelected] = useAtom(selectedAtom); const selected = _selected !== null ? _selected : matchedIndex > -1 ? matchedIndex : null; const select = useEvent((index: number | null) => { - setValue(pointer, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined); + setValue(path, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined); setSelected(index); }); @@ -92,20 +92,20 @@ const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) => const Select = () => { const { selected, select, path, schema, options, selectSchema } = useAnyOfContext(); - function handleSelect(e: ChangeEvent) { + const handleSelect = useEvent((e: ChangeEvent) => { const i = e.target.value ? Number(e.target.value) : null; select(i); - } + }); + + const _options = useMemo(() => options.map((label, value) => ({ label, value })), []); return ( <> - - {getLabel(path, schema)} {selected} - + {getLabel(path, schema)} ({ label, value }))} + options={_options} onChange={handleSelect} value={selected ?? undefined} className="h-8 py-1" @@ -114,6 +114,7 @@ const Select = () => { ); }; +// @todo: add local validation for AnyOf fields const Field = ({ name, label, schema, ...props }: Partial) => { const { selected, selectedSchema, path, errors } = useAnyOfContext(); if (selected === null) return null; diff --git a/app/src/ui/components/form/json-schema-form/ArrayField.tsx b/app/src/ui/components/form/json-schema-form/ArrayField.tsx index e48185d..905fb96 100644 --- a/app/src/ui/components/form/json-schema-form/ArrayField.tsx +++ b/app/src/ui/components/form/json-schema-form/ArrayField.tsx @@ -1,6 +1,5 @@ import { IconLibraryPlus, IconTrash } from "@tabler/icons-react"; import type { JsonSchema } from "json-schema-library"; -import { isEqual } from "lodash-es"; import { memo, useMemo } from "react"; import { Button } from "ui/components/buttons/Button"; import { IconButton } from "ui/components/buttons/IconButton"; @@ -8,8 +7,8 @@ import { Dropdown } from "ui/components/overlay/Dropdown"; import { useEvent } from "ui/hooks/use-event"; import { FieldComponent } from "./Field"; import { FieldWrapper } from "./FieldWrapper"; -import { useDerivedFieldContext, useFormContext, useFormValue } 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 = "", @@ -59,7 +58,7 @@ const ArrayItem = memo(({ path, index, schema }: any) => { const { value, ...ctx } = useDerivedFieldContext(path, schema, (ctx) => { return ctx.value?.[index]; }); - const pointer = [path, index].join("."); + const itemPath = suffixPath(path, index); let subschema = schema.items; const itemsMultiSchema = getMultiSchema(schema.items); if (itemsMultiSchema) { @@ -76,18 +75,18 @@ const ArrayItem = memo(({ path, index, schema }: any) => { }); const DeleteButton = useMemo( - () => handleDelete(pointer)} size="sm" />, - [pointer] + () => handleDelete(itemPath)} size="sm" />, + [itemPath] ); return ( -
+
{ - handleUpdate(pointer, coerce(e.target.value, subschema!)); + handleUpdate(itemPath, coerce(e.target.value, subschema!)); }} className="w-full" /> @@ -114,8 +113,8 @@ const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => { const itemsMultiSchema = getMultiSchema(schema.items); function handleAdd(template?: any) { - const newPointer = `${path}/${currentIndex}`.replace(/\/+/g, "/"); - setValue(newPointer, template ?? ctx.lib.getTemplate(undefined, schema!.items)); + const newPath = suffixPath(path, currentIndex); + setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items)); } if (itemsMultiSchema) { diff --git a/app/src/ui/components/form/json-schema-form/Field.tsx b/app/src/ui/components/form/json-schema-form/Field.tsx index 50cf6a7..bd225ae 100644 --- a/app/src/ui/components/form/json-schema-form/Field.tsx +++ b/app/src/ui/components/form/json-schema-form/Field.tsx @@ -17,12 +17,12 @@ export type FieldProps = { }; export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }: FieldProps) => { - const { pointer, setValue, required, ...ctx } = useDerivedFieldContext(name, _schema); + const { path, setValue, required, ...ctx } = useDerivedFieldContext(name, _schema); const schema = _schema ?? ctx.schema; if (!isTypeSchema(schema)) return (
-            [Field] {pointer} has no schema ({JSON.stringify(schema)})
+            [Field] {path} has no schema ({JSON.stringify(schema)})
          
); @@ -39,9 +39,9 @@ export const Field = ({ name, schema: _schema, onChange, label: _label, hidden } const handleChange = useEvent((e: ChangeEvent) => { const value = coerce(e.target.value, schema as any, { required }); if (typeof value === "undefined" && !required && ctx.options?.keepEmpty !== true) { - ctx.deleteValue(pointer); + ctx.deleteValue(path); } else { - setValue(pointer, value); + setValue(path, value); } }); @@ -68,7 +68,7 @@ export const FieldComponent = ({ schema, ..._props }: { schema: JsonSchema } & ComponentPropsWithoutRef<"input">) => { - const { value } = useFormValue(_props.name!); + const { value } = useFormValue(_props.name!, { strict: true }); if (!isTypeSchema(schema)) return null; const props = { ..._props, diff --git a/app/src/ui/components/form/json-schema-form/Form.tsx b/app/src/ui/components/form/json-schema-form/Form.tsx index e7876bb..3e124d6 100644 --- a/app/src/ui/components/form/json-schema-form/Form.tsx +++ b/app/src/ui/components/form/json-schema-form/Form.tsx @@ -10,7 +10,6 @@ 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 $JSONSchema, FromSchema } from "json-schema-to-ts"; -import { get, isEqual } from "lodash-es"; import * as immutable from "object-path-immutable"; import { type ComponentPropsWithoutRef, @@ -27,8 +26,9 @@ import { JsonViewer } from "ui/components/code/JsonViewer"; import { useEvent } from "ui/hooks/use-event"; import { Field } from "./Field"; import { + getPath, + isEqual, isRequired, - normalizePath, omitSchema, pathToPointer, prefixPath, @@ -139,25 +139,21 @@ export function Form< } } - const setValue = useEvent((pointer: string, value: any) => { - const normalized = normalizePath(pointer); - const key = normalized.substring(2).replace(/\//g, "."); + const setValue = useEvent((path: string, value: any) => { setFormState((state) => { const prev = state.data; - const changed = immutable.set(prev, key, value); - onChange?.(changed, key, value); + const changed = immutable.set(prev, path, value); + onChange?.(changed, path, value); return { ...state, data: changed }; }); check(); }); - const deleteValue = useEvent((pointer: string) => { - const normalized = normalizePath(pointer); - const key = normalized.substring(2).replace(/\//g, "."); + const deleteValue = useEvent((path: string) => { setFormState((state) => { const prev = state.data; - const changed = immutable.del(prev, key); - onChange?.(changed, key, undefined); + const changed = immutable.del(prev, path); + onChange?.(changed, path, undefined); return { ...state, data: changed }; }); check(); @@ -191,7 +187,8 @@ export function Form< schema, lib, options, - root: "" + root: "", + path: "" }), [schema, initialValues] ) as any; @@ -245,8 +242,11 @@ export function FormContextOverride({ return {children}; } -export function useFormValue(name: string) { +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( @@ -254,7 +254,7 @@ export function useFormValue(name: string) { const prefixedName = prefixPath(name, root); const pointer = pathToPointer(prefixedName); return { - value: get(state.data, prefixedName), + value: getPath(state.data, prefixedName), errors: state.errors.filter((error) => error.data.pointer.startsWith(pointer)) }; }, @@ -265,7 +265,7 @@ export function useFormValue(name: string) { return useAtom(selected)[0]; } -export function useFormError(name: string, opt?: { strict?: boolean }) { +export function useFormError(name: string, opt?: { strict?: boolean; debug?: boolean }) { const { _formStateAtom, root } = useFormContext(); const selected = selectAtom( _formStateAtom, @@ -303,12 +303,17 @@ export function useDerivedFieldContext( FormContext & { pointer: string; required: boolean; - errors: JsonError[]; value: any; + path: string; }, Reduced > -): FormContext & { value: Reduced; pointer: string; required: boolean; errors: JsonError[] } { +): FormContext & { + value: Reduced; + pointer: string; + required: boolean; + path: string; +} { const { _formStateAtom, root, lib, ...ctx } = useFormContext(); const schema = _schema ?? ctx.schema; const selected = selectAtom( @@ -318,23 +323,23 @@ export function useDerivedFieldContext( const pointer = pathToPointer(path); const prefixedName = prefixPath(path, root); const prefixedPointer = pathToPointer(prefixedName); - const value = get(state.data, prefixedName); - const errors = state.errors.filter((error) => + 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(prefixedPointer, schema, state.data); + const required = isRequired(lib, prefixedPointer, schema, state.data); const context = { ...ctx, + path: prefixedName, root, schema: fieldSchema as LibJsonSchema, pointer, - required, - errors + required }; const derived = deriveFn?.({ ...context, _formStateAtom, lib, value }); diff --git a/app/src/ui/components/form/json-schema-form/utils.ts b/app/src/ui/components/form/json-schema-form/utils.ts index 2e59b56..648a704 100644 --- a/app/src/ui/components/form/json-schema-form/utils.ts +++ b/app/src/ui/components/form/json-schema-form/utils.ts @@ -1,82 +1,10 @@ -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) { - 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, - schema: JsonSchema, - selections?: Record -) { - 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 { isEqual, getPath } from "core/utils/objects"; +//export { isEqual } from "lodash-es"; export function coerce(value: any, schema: JsonSchema, opts?: { required?: boolean }) { if (!value && typeof opts?.required === "boolean" && !opts.required) { @@ -98,25 +26,6 @@ export function coerce(value: any, schema: JsonSchema, opts?: { required?: boole 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); } @@ -125,22 +34,28 @@ export function prefixPointer(pointer: string, prefix: string) { return pointer.replace("#/", `#/${prefix.length > 0 ? prefix + "/" : ""}`).replace(/\/\//g, "/"); } -export function prefixPath(path: string = "", prefix: string = "") { +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(Boolean).join("."); + 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) { +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 }); + const childSchema = lib.getSchema({ pointer, data, schema }); if (typeof childSchema === "object" && "const" in childSchema) { return true; } @@ -152,12 +67,17 @@ export function isRequired(pointer: string, schema: JsonSchema, data?: any) { 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) { @@ -188,19 +108,6 @@ export function getMultiSchemaMatched( return [index, multiSchema, multiSchema[index]]; } -export function removeKeyRecursively(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(_schema: Given, keys: string[], _data?: any) { if (typeof _schema !== "object" || !("properties" in _schema) || keys.length === 0) return [_schema, _data]; @@ -209,13 +116,13 @@ export function omitSchema(_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]; } @@ -223,10 +130,3 @@ export function omitSchema(_schema: Given, keys: strin 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 } - ); -} diff --git a/app/src/ui/routes/media/media.settings.tsx b/app/src/ui/routes/media/media.settings.tsx index 85589ed..527c9c8 100644 --- a/app/src/ui/routes/media/media.settings.tsx +++ b/app/src/ui/routes/media/media.settings.tsx @@ -42,7 +42,6 @@ function MediaSettingsInternal() { async function onSubmit(data: any) { console.log("submit", data); await actions.config.patch(data); - //await new Promise((resolve) => setTimeout(resolve, 1000)); } return ( diff --git a/app/src/ui/routes/test/tests/json-schema-form3.tsx b/app/src/ui/routes/test/tests/json-schema-form3.tsx index 47e2fef..b7330cc 100644 --- a/app/src/ui/routes/test/tests/json-schema-form3.tsx +++ b/app/src/ui/routes/test/tests/json-schema-form3.tsx @@ -36,6 +36,7 @@ export default function JsonSchemaForm3() { 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 ( @@ -239,9 +240,14 @@ export default function JsonSchemaForm3() { */} {/**/} - {/*
+ - */} + {/*
*/} -
console.log(state)} - onSubmit={(state) => console.log(state)} - validateOn="change" - options={{ debug: true }} - /> + {/**/}
);