diff --git a/app/__test__/core/object/utils.spec.ts b/app/__test__/core/object/utils.spec.ts new file mode 100644 index 0000000..03a572f --- /dev/null +++ b/app/__test__/core/object/utils.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import * as utils from "../../../src/core/utils/objects"; + +describe("object utils", () => { + test("flattenObject", () => { + const obj = { + a: { + b: { + c: 1, + a: ["a", "b", "c"] + } + }, + d: [1, 2, { e: 3 }] + }; + + console.log("flat", utils.flattenObject2(obj)); + expect(utils.flattenObject2(obj)).toEqual({ + "a.b.c": 1, + "a.b.a[0]": "a", + "a.b.a[1]": "b", + "a.b.a[2]": "c", + "d[0]": 1, + "d[1]": 2, + "d[2].e": 3 + }); + }); +}); diff --git a/app/__test__/ui/json-form.spec.ts b/app/__test__/ui/json-form.spec.ts new file mode 100644 index 0000000..16998ca --- /dev/null +++ b/app/__test__/ui/json-form.spec.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { Draft2019 } from "json-schema-library"; +import type { JSONSchema } from "json-schema-to-ts"; +import * as utils from "../../src/ui/components/form/json-schema-form2/utils"; + +describe("json form", () => { + test("normalize path", () => { + const examples = [ + ["description", "#/description"], + ["/description", "#/description"], + ["nested/property", "#/nested/property"], + ["nested.property", "#/nested/property"], + ["nested.property[0]", "#/nested/property/0"], + ["nested.property[0].name", "#/nested/property/0/name"] + ]; + + for (const [input, output] of examples) { + expect(utils.normalizePath(input)).toBe(output); + } + }); + + test("coerse", () => { + const examples = [ + ["test", { type: "string" }, "test"], + ["1", { type: "integer" }, 1], + ["1", { type: "number" }, 1], + ["true", { type: "boolean" }, true], + ["false", { type: "boolean" }, false], + ["1", { type: "boolean" }, true], + ["0", { type: "boolean" }, false], + ["on", { type: "boolean" }, true], + ["off", { type: "boolean" }, false], + ["null", { type: "null" }, null] + ] satisfies [string, Exclude, any][]; + + for (const [input, schema, output] of examples) { + expect(utils.coerce(input, schema)).toBe(output); + } + }); + + test("getParentPointer", () => { + const examples = [ + ["#/nested/property/0/name", "#/nested/property/0"], + ["#/nested/property/0", "#/nested/property"], + ["#/nested/property", "#/nested"], + ["#/nested", "#"] + ]; + + for (const [input, output] of examples) { + expect(utils.getParentPointer(input)).toBe(output); + } + }); + + test("isRequired", () => { + const examples = [ + [ + "#/description", + { type: "object", properties: { description: { type: "string" } } }, + false + ], + [ + "#/description", + { + type: "object", + required: ["description"], + properties: { description: { type: "string" } } + }, + true + ], + [ + "#/nested/property", + { + type: "object", + properties: { + nested: { + type: "object", + properties: { property: { type: "string" } } + } + } + }, + false + ], + [ + "#/nested/property", + { + type: "object", + properties: { + nested: { + type: "object", + required: ["property"], + properties: { property: { type: "string" } } + } + } + }, + true + ] + ] satisfies [string, Exclude, boolean][]; + + for (const [pointer, schema, output] of examples) { + expect(utils.isRequired(pointer, schema)).toBe(output); + } + }); + + test("unflatten", () => { + const examples = [ + [ + { "#/description": "test" }, + { + type: "object", + properties: { + description: { type: "string" } + } + }, + { + description: "test" + } + ] + ] satisfies [Record, Exclude, object][]; + + for (const [input, schema, output] of examples) { + expect(utils.unflatten(input, schema)).toEqual(output); + } + }); + + test.only("...", () => { + const schema = { + type: "object", + properties: { + name: { type: "string", maxLength: 2 }, + description: { type: "string", maxLength: 2 }, + age: { type: "number", description: "Age of you" }, + deep: { + type: "object", + properties: { + nested: { type: "string", maxLength: 2 } + } + } + }, + required: ["description"] + }; + + const lib = new Draft2019(schema); + + lib.eachSchema(console.log); + }); +}); diff --git a/app/package.json b/app/package.json index 9baffe0..4373a2a 100644 --- a/app/package.json +++ b/app/package.json @@ -33,26 +33,28 @@ "license": "FSL-1.1-MIT", "dependencies": { "@cfworker/json-schema": "^2.0.1", + "@codemirror/lang-html": "^6.4.9", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-liquid": "^6.2.1", + "@hello-pangea/dnd": "^17.0.0", "@libsql/client": "^0.14.0", + "@mantine/core": "^7.13.4", "@sinclair/typebox": "^0.32.34", "@tanstack/react-form": "0.19.2", + "@uiw/react-codemirror": "^4.23.6", + "@xyflow/react": "^12.3.2", "aws4fetch": "^1.0.18", "dayjs": "^1.11.13", "fast-xml-parser": "^4.4.0", "hono": "^4.6.12", + "json-schema-form-react": "link:json-schema-form-react", + "json-schema-library": "^10.0.0-rc7", "kysely": "^0.27.4", "liquidjs": "^10.15.0", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", - "swr": "^2.2.5", - "json-schema-form-react": "^0.0.2", - "@uiw/react-codemirror": "^4.23.6", - "@codemirror/lang-html": "^6.4.9", - "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-liquid": "^6.2.1", - "@xyflow/react": "^12.3.2", - "@mantine/core": "^7.13.4", - "@hello-pangea/dnd": "^17.0.0" + "object-path-immutable": "^4.1.2", + "swr": "^2.2.5" }, "devDependencies": { "@aws-sdk/client-s3": "^3.613.0", @@ -73,6 +75,7 @@ "clsx": "^2.1.1", "esbuild-postcss": "^0.0.4", "jotai": "^2.10.1", + "json-schema-to-ts": "^3.1.1", "open": "^10.1.0", "openapi-types": "^12.1.3", "postcss": "^8.4.47", diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index ab5b807..ce37261 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -166,6 +166,29 @@ export function flattenObject(obj: any, parentKey = "", result: any = {}): any { return result; } +export function flattenObject2(obj: any, parentKey = "", result: any = {}): any { + for (const key in obj) { + if (key in obj) { + const newKey = parentKey ? `${parentKey}.${key}` : key; + if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) { + flattenObject2(obj[key], newKey, result); + } else if (Array.isArray(obj[key])) { + obj[key].forEach((item, index) => { + const arrayKey = `${newKey}[${index}]`; + if (typeof item === "object" && item !== null) { + flattenObject2(item, arrayKey, result); + } else { + result[arrayKey] = item; + } + }); + } else { + result[newKey] = obj[key]; + } + } + } + return result; +} + export function objectDepth(object: object): number { let level = 1; for (const key in object) { diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts index c7789dd..fc268e4 100644 --- a/app/src/core/utils/strings.ts +++ b/app/src/core/utils/strings.ts @@ -84,6 +84,9 @@ export function identifierToHumanReadable(str: string) { return str; } } +export function autoFormatString(str: string) { + return identifierToHumanReadable(str); +} export function kebabToPascalWithSpaces(str: string): string { return str.split("-").map(ucFirst).join(" "); diff --git a/app/src/ui/client/schema/media/use-bknd-media.ts b/app/src/ui/client/schema/media/use-bknd-media.ts new file mode 100644 index 0000000..4d8dbd1 --- /dev/null +++ b/app/src/ui/client/schema/media/use-bknd-media.ts @@ -0,0 +1,33 @@ +import { useBknd } from "ui/client/BkndProvider"; + +export function useBkndMedia() { + const { config, schema, actions: bkndActions } = useBknd(); + + const actions = { + /*roles: { + add: async (name: string, data: any = {}) => { + console.log("add role", name, data); + return await bkndActions.add("auth", `roles.${name}`, data); + }, + patch: async (name: string, data: any) => { + console.log("patch role", name, data); + return await bkndActions.patch("auth", `roles.${name}`, data); + }, + delete: async (name: string) => { + console.log("delete role", name); + if (window.confirm(`Are you sure you want to delete the role "${name}"?`)) { + return await bkndActions.remove("auth", `roles.${name}`); + } + return false; + } + }*/ + }; + const $media = {}; + + return { + $media, + config: config.media, + schema: schema.media, + actions + }; +} diff --git a/app/src/ui/components/form/Formy/BooleanInputMantine.tsx b/app/src/ui/components/form/Formy/BooleanInputMantine.tsx index 3f3c77a..48bd5e5 100644 --- a/app/src/ui/components/form/Formy/BooleanInputMantine.tsx +++ b/app/src/ui/components/form/Formy/BooleanInputMantine.tsx @@ -3,9 +3,10 @@ import { forwardRef, useEffect, useState } from "react"; export const BooleanInputMantine = forwardRef>( (props, ref) => { - const [checked, setChecked] = useState(Boolean(props.value)); + const [checked, setChecked] = useState(Boolean(props.value ?? props.defaultValue)); useEffect(() => { + console.log("value change", props.value); setChecked(Boolean(props.value)); }, [props.value]); diff --git a/app/src/ui/components/form/Formy/components.tsx b/app/src/ui/components/form/Formy/components.tsx index d725238..7bf691f 100644 --- a/app/src/ui/components/form/Formy/components.tsx +++ b/app/src/ui/components/form/Formy/components.tsx @@ -1,25 +1,37 @@ import { getBrowser } from "core/utils"; import type { Field } from "data"; -import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; +import { + type ElementType, + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState +} from "react"; import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb"; import { twMerge } from "tailwind-merge"; import { IconButton } from "ui/components/buttons/IconButton"; import { useEvent } from "ui/hooks/use-event"; -export const Group: React.FC & { error?: boolean }> = ({ +export const Group = ({ error, + as, ...props -}) => ( -
& { error?: boolean; as?: E }) => { + const Tag = as || "div"; - error && "text-red-500", - props.className - )} - /> -); + return ( + + ); +}; export const formElementFactory = (element: string, props: any) => { switch (element) { @@ -34,7 +46,21 @@ export const formElementFactory = (element: string, props: any) => { } }; -export const Label: React.FC> = (props) =>
@@ -57,16 +60,3 @@ export function MediaRoot({ children }) { ); } - -// @todo: add infinite load -export function MediaEmpty() { - useBrowserTitle(["Media"]); - - return ( - -
- -
-
- ); -} diff --git a/app/src/ui/routes/media/index.tsx b/app/src/ui/routes/media/index.tsx index 3949258..12259d7 100644 --- a/app/src/ui/routes/media/index.tsx +++ b/app/src/ui/routes/media/index.tsx @@ -1,10 +1,13 @@ import { Route } from "wouter"; -import { MediaEmpty, MediaRoot } from "./_media.root"; +import { MediaRoot } from "./_media.root"; +import { MediaIndex } from "./media.index"; +import { MediaSettings } from "./media.settings"; export default function MediaRoutes() { return ( - + + ); } diff --git a/app/src/ui/routes/media/media.index.tsx b/app/src/ui/routes/media/media.index.tsx new file mode 100644 index 0000000..44ed644 --- /dev/null +++ b/app/src/ui/routes/media/media.index.tsx @@ -0,0 +1,15 @@ +import { Media } from "ui/elements"; +import { useBrowserTitle } from "ui/hooks/use-browser-title"; +import * as AppShell from "ui/layouts/AppShell/AppShell"; + +export function MediaIndex() { + useBrowserTitle(["Media"]); + + return ( + +
+ +
+
+ ); +} diff --git a/app/src/ui/routes/media/media.settings.tsx b/app/src/ui/routes/media/media.settings.tsx new file mode 100644 index 0000000..7b63900 --- /dev/null +++ b/app/src/ui/routes/media/media.settings.tsx @@ -0,0 +1,126 @@ +import { Form, useFormContext } from "json-schema-form-react"; +import { omit } from "lodash-es"; +import { useState } from "react"; +import { useBknd } from "ui/client/BkndProvider"; +import { useBkndMedia } from "ui/client/schema/media/use-bknd-media"; +import { Button } from "ui/components/buttons/Button"; +import { JsonViewer } from "ui/components/code/JsonViewer"; +import { Message } from "ui/components/display/Message"; +import * as Formy from "ui/components/form/Formy"; +import { BooleanInputMantine } from "ui/components/form/Formy/BooleanInputMantine"; +import { JsonSchemaForm } from "ui/components/form/json-schema"; +import { TypeboxValidator } from "ui/components/form/json-schema-form"; +import { AutoForm, Field } from "ui/components/form/json-schema-form/components/Field"; +import { Media } from "ui/elements"; +import { useBrowserTitle } from "ui/hooks/use-browser-title"; +import * as AppShell from "ui/layouts/AppShell/AppShell"; + +const validator = new TypeboxValidator(); + +export function MediaSettings(props) { + useBrowserTitle(["Media", "Settings"]); + + const { hasSecrets } = useBknd({ withSecrets: true }); + if (!hasSecrets) { + return ; + } + + return ; +} + +function MediaSettingsInternal() { + const { config, schema } = useBkndMedia(); + const [data, setData] = useState(config); + //console.log("schema", schema); + + return ( + <> +
+ {({ errors, submitting, dirty }) => ( + <> + + Update + + } + > + Settings + + +
+ +
+ + + +
+
+
+ + +
+ +
+ + )} +
+ + ); +} + +function Adapters() { + const { config, schema } = useBkndMedia(); + const ctx = useFormContext(); + const current = config.adapter; + const schemas = schema.properties.adapter.anyOf; + const types = schemas.map((s) => s.properties.type.const) as string[]; + const currentType = current?.type ?? (types[0] as string); + const [selected, setSelected] = useState(currentType); + const $schema = schemas.find((s) => s.properties.type.const === selected); + console.log("$schema", $schema); + + function onChangeSelect(e) { + setSelected(e.target.value); + + // wait quickly for the form to update before triggering a change + setTimeout(() => { + ctx.setValue("adapter.type", e.target.value); + }, 10); + } + + return ( +
+ + {types.map((type) => ( + + ))} + +
current: {selected}
+
options: {schemas.map((s) => s.title).join(", ")}
+
+ ); +} + +const Overlay = ({ visible = false }) => + visible && ( +
+ ); diff --git a/app/src/ui/routes/test/index.tsx b/app/src/ui/routes/test/index.tsx index da6c0cd..9f1fd76 100644 --- a/app/src/ui/routes/test/index.tsx +++ b/app/src/ui/routes/test/index.tsx @@ -1,5 +1,6 @@ import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test"; import JsonSchemaFormReactTest from "ui/routes/test/tests/json-schema-form-react-test"; +import JsonSchemaForm2 from "ui/routes/test/tests/json-schema-form2"; import SwaggerTest from "ui/routes/test/tests/swagger-test"; import SWRAndAPI from "ui/routes/test/tests/swr-and-api"; import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api"; @@ -15,6 +16,7 @@ import DropdownTest from "./tests/dropdown-test"; import DropzoneElementTest from "./tests/dropzone-element-test"; import EntityFieldsForm from "./tests/entity-fields-form"; import FlowsTest from "./tests/flows-test"; +import JsonSchemaForm3 from "./tests/json-schema-form3"; import JsonFormTest from "./tests/jsonform-test"; import { LiquidJsTest } from "./tests/liquid-js-test"; import MantineTest from "./tests/mantine-test"; @@ -45,7 +47,9 @@ const tests = { SWRAndAPI, SwrAndDataApi, DropzoneElementTest, - JsonSchemaFormReactTest + JsonSchemaFormReactTest, + JsonSchemaForm2, + JsonSchemaForm3 } as const; export default function TestRoutes() { diff --git a/app/src/ui/routes/test/tests/json-schema-form2.tsx b/app/src/ui/routes/test/tests/json-schema-form2.tsx new file mode 100644 index 0000000..c137091 --- /dev/null +++ b/app/src/ui/routes/test/tests/json-schema-form2.tsx @@ -0,0 +1,287 @@ +import { Popover } from "@mantine/core"; +import { IconBug } from "@tabler/icons-react"; +import { autoFormatString } from "core/utils"; +import type { JSONSchema } from "json-schema-to-ts"; +import { type ChangeEvent, type ComponentPropsWithoutRef, useState } from "react"; +import { IconButton } from "ui/components/buttons/IconButton"; +import { JsonViewer } from "ui/components/code/JsonViewer"; +import * as Formy from "ui/components/form/Formy"; +import { ErrorMessage } from "ui/components/form/Formy"; +import { + Form, + useFieldContext, + useFormContext, + usePrefixContext +} from "ui/components/form/json-schema-form2/Form"; +import { isType } from "ui/components/form/json-schema-form2/utils"; + +const schema = { + type: "object", + properties: { + name: { type: "string", maxLength: 2 }, + description: { type: "string", maxLength: 2 }, + age: { type: "number", description: "Age of you" }, + deep: { + type: "object", + properties: { + nested: { type: "string", maxLength: 2 } + } + } + } + //required: ["description"] +} as const satisfies JSONSchema; + +const simpleSchema = { + type: "object", + properties: { + tags: { + type: "array", + items: { + type: "string" + } + } + } +} as const satisfies JSONSchema; + +export default function JsonSchemaForm2() { + return ( +
+

Form

+ + {/*
+ + + + + +
+ +
+ + +
*/} + + {/*
+ + */} + {/*
+ + */} + +
+ + + + {/*
+ + */} +
+ ); +} + +const Field = ({ + name = "", + schema: _schema +}: { name?: string; schema?: Exclude }) => { + const { value, errors, pointer, required, ...ctx } = useFieldContext(name); + const schema = _schema ?? ctx.schema; + if (!schema) return `"${name}" (${pointer}) has no schema`; + + if (isType(schema.type, ["object", "array"])) { + return null; + } + + const label = schema.title ?? name; //autoFormatString(name.split("/").pop()); + + return ( + 0}> + + {label} {required ? "*" : ""} + +
+
+ +
+ + + + + + + + +
+ {schema.description && {schema.description}} + {errors.length > 0 && ( + {errors.map((e) => e.message).join(", ")} + )} +
+ ); +}; + +const FieldComponent = ({ + schema, + ...props +}: { schema: JSONSchema } & ComponentPropsWithoutRef<"input">) => { + if (!schema || typeof schema === "boolean") return null; + + const common = {}; + + if (schema.enum) { + if (!Array.isArray(schema.enum)) return null; + + return ; + } + + if (isType(schema.type, ["number", "integer"])) { + return ; + } + + return ; +}; + +const ObjectField = ({ path = "" }: { path?: string }) => { + const { schema } = usePrefixContext(path); + if (!schema) return null; + const properties = schema.properties ?? {}; + const label = schema.title ?? path; + console.log("object", { path, schema, properties }); + + return ( +
+ Object: {label} + {Object.keys(properties).map((prop) => { + const schema = properties[prop]; + const pointer = `${path}/${prop}`; + + console.log("--", prop, pointer, schema); + if (schema.anyOf || schema.oneOf) { + return ; + } + + if (isType(schema.type, "object")) { + console.log("object", { prop, pointer, schema }); + return ; + } + + if (isType(schema.type, "array")) { + return ; + } + + return ; + })} +
+ ); +}; + +const AnyOfField = ({ path = "" }: { path?: string }) => { + const [selected, setSelected] = useState(null); + const { schema, select } = usePrefixContext(path); + if (!schema) return null; + const schemas = schema.anyOf ?? schema.oneOf ?? []; + const options = schemas.map((s, i) => ({ + value: i, + label: s.title ?? `Option ${i + 1}` + })); + const selectSchema = { + enum: options + }; + + function handleSelect(e: ChangeEvent) { + const i = e.target.value ? Number(e.target.value) : null; + setSelected(i); + select(path, i !== null ? i : undefined); + } + console.log("options", options, schemas, selected !== null && schemas[selected]); + + return ( + <> +
+ anyOf: {path} ({selected}) +
+ + + {selected !== null && ( + + )} + + ); +}; + +const ArrayField = ({ path = "" }: { path?: string }) => { + return "array: " + path; +}; + +const AutoForm = ({ prefix = "" }: { prefix?: string }) => { + const { schema } = usePrefixContext(prefix); + if (!schema) return null; + + if (isType(schema.type, "object")) { + return ; + } + + if (isType(schema.type, "array")) { + return ; + } + + 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 new file mode 100644 index 0000000..cb5e2f9 --- /dev/null +++ b/app/src/ui/routes/test/tests/json-schema-form3.tsx @@ -0,0 +1,328 @@ +import { useBknd } from "ui/client/bknd"; +import { Button } from "ui/components/buttons/Button"; +import { AnyOf, useAnyOfContext } from "ui/components/form/json-schema-form3/AnyOfField"; +import { Field } from "ui/components/form/json-schema-form3/Field"; +import { Form, FormContextOverride } from "ui/components/form/json-schema-form3/Form"; +import { ObjectField } from "ui/components/form/json-schema-form3/ObjectField"; +import { removeKeyRecursively } from "ui/components/form/json-schema-form3/utils"; +import { Scrollable } from "ui/layouts/AppShell/AppShell"; + +const mediaSchema = { + additionalProperties: false, + type: "object", + properties: { + enabled: { + default: false, + type: "boolean" + }, + basepath: { + default: "/api/media", + type: "string" + }, + entity_name: { + default: "media", + type: "string" + }, + storage: { + default: {}, + type: "object", + properties: { + body_max_size: { + description: "Max size of the body in bytes. Leave blank for unlimited.", + type: "number" + } + } + }, + adapter: { + anyOf: [ + { + title: "s3", + additionalProperties: false, + type: "object", + properties: { + type: { + default: "s3", + const: "s3", + readOnly: true, + type: "string" + }, + config: { + title: "S3", + type: "object", + properties: { + access_key: { + type: "string" + }, + secret_access_key: { + type: "string" + }, + url: { + pattern: "^https?://[^/]+", + description: "URL to S3 compatible endpoint without trailing slash", + examples: [ + "https://{account_id}.r2.cloudflarestorage.com/{bucket}", + "https://{bucket}.s3.{region}.amazonaws.com" + ], + type: "string" + } + }, + required: ["access_key", "secret_access_key", "url"] + } + }, + required: ["type", "config"] + }, + { + title: "cloudinary", + additionalProperties: false, + type: "object", + properties: { + type: { + default: "cloudinary", + const: "cloudinary", + readOnly: true, + type: "string" + }, + config: { + title: "Cloudinary", + type: "object", + properties: { + cloud_name: { + type: "string" + }, + api_key: { + type: "string" + }, + api_secret: { + type: "string" + }, + upload_preset: { + type: "string" + } + }, + required: ["cloud_name", "api_key", "api_secret"] + } + }, + required: ["type", "config"] + }, + { + title: "local", + additionalProperties: false, + type: "object", + properties: { + type: { + default: "local", + const: "local", + readOnly: true, + type: "string" + }, + config: { + title: "Local", + type: "object", + properties: { + path: { + default: "./", + type: "string" + } + }, + required: ["path"] + } + }, + required: ["type", "config"] + } + ] + } + }, + required: ["enabled", "basepath", "entity_name", "storage"] +}; + +export default function JsonSchemaForm3() { + const { schema, config } = useBknd(); + + return ( + +
+ {/*
+ + + + + */} + + {/*
+ + */} + + {/*
+ + */} + {/*
+ + */} + + {/*
*/} + + + + + {/* + + */} + + {/*
+ + */} +
+
+ ); +} + +function CustomMediaForm() { + const { schema, config } = useBknd(); + return ( +
+ + + + + + + + + ); +} + +function CustomMediaFormAdapter() { + const ctx = useAnyOfContext(); + + return ( + <> +
+ {ctx.schemas?.map((schema: any, i) => ( + + ))} +
+ + {ctx.selected !== null && ( + + + )} + + ); +} diff --git a/bun.lockb b/bun.lockb index 1b049f9..4d841b4 100755 Binary files a/bun.lockb and b/bun.lockb differ