mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 12:56:05 +00:00
Merge pull request #68 from bknd-io/feat/media-settings-ui
Improved media settings UI/UX
This commit is contained in:
146
app/__test__/ui/json-form.spec.ts
Normal file
146
app/__test__/ui/json-form.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { Draft2019 } from "json-schema-library";
|
||||||
|
import type { JSONSchema } from "json-schema-to-ts";
|
||||||
|
import * as utils from "../../src/ui/components/form/json-schema-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"],
|
||||||
|
["1", { type: "integer" }, 1],
|
||||||
|
["1", { type: "number" }, 1],
|
||||||
|
["true", { type: "boolean" }, true],
|
||||||
|
["false", { type: "boolean" }, false],
|
||||||
|
["1", { type: "boolean" }, true],
|
||||||
|
["0", { type: "boolean" }, false],
|
||||||
|
["on", { type: "boolean" }, true],
|
||||||
|
["off", { type: "boolean" }, false],
|
||||||
|
["null", { type: "null" }, null]
|
||||||
|
] satisfies [string, Exclude<JSONSchema, boolean>, any][];
|
||||||
|
|
||||||
|
for (const [input, schema, output] of examples) {
|
||||||
|
expect(utils.coerce(input, schema)).toBe(output);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getParentPointer", () => {
|
||||||
|
const examples = [
|
||||||
|
["#/nested/property/0/name", "#/nested/property/0"],
|
||||||
|
["#/nested/property/0", "#/nested/property"],
|
||||||
|
["#/nested/property", "#/nested"],
|
||||||
|
["#/nested", "#"]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [input, output] of examples) {
|
||||||
|
expect(utils.getParentPointer(input)).toBe(output);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isRequired", () => {
|
||||||
|
const examples = [
|
||||||
|
[
|
||||||
|
"#/description",
|
||||||
|
{ type: "object", properties: { description: { type: "string" } } },
|
||||||
|
false
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#/description",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
required: ["description"],
|
||||||
|
properties: { description: { type: "string" } }
|
||||||
|
},
|
||||||
|
true
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#/nested/property",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
nested: {
|
||||||
|
type: "object",
|
||||||
|
properties: { property: { type: "string" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
false
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#/nested/property",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
nested: {
|
||||||
|
type: "object",
|
||||||
|
required: ["property"],
|
||||||
|
properties: { property: { type: "string" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true
|
||||||
|
]
|
||||||
|
] satisfies [string, Exclude<JSONSchema, boolean>, boolean][];
|
||||||
|
|
||||||
|
for (const [pointer, schema, output] of examples) {
|
||||||
|
expect(utils.isRequired(pointer, schema)).toBe(output);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unflatten", () => {
|
||||||
|
const examples = [
|
||||||
|
[
|
||||||
|
{ "#/description": "test" },
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
description: { type: "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "test"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
] satisfies [Record<string, string>, Exclude<JSONSchema, boolean>, object][];
|
||||||
|
|
||||||
|
for (const [input, schema, output] of examples) {
|
||||||
|
expect(utils.unflatten(input, schema)).toEqual(output);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("...", () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.7.0-rc.4",
|
"version": "0.7.0-rc.5",
|
||||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||||
"homepage": "https://bknd.io",
|
"homepage": "https://bknd.io",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -33,26 +33,29 @@
|
|||||||
"license": "FSL-1.1-MIT",
|
"license": "FSL-1.1-MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cfworker/json-schema": "^2.0.1",
|
"@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",
|
"@libsql/client": "^0.14.0",
|
||||||
|
"@mantine/core": "^7.13.4",
|
||||||
"@sinclair/typebox": "^0.32.34",
|
"@sinclair/typebox": "^0.32.34",
|
||||||
"@tanstack/react-form": "0.19.2",
|
"@tanstack/react-form": "0.19.2",
|
||||||
|
"@uiw/react-codemirror": "^4.23.6",
|
||||||
|
"@xyflow/react": "^12.3.2",
|
||||||
"aws4fetch": "^1.0.18",
|
"aws4fetch": "^1.0.18",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"fast-xml-parser": "^4.4.0",
|
"fast-xml-parser": "^4.4.0",
|
||||||
"hono": "^4.6.12",
|
"hono": "^4.6.12",
|
||||||
|
"json-schema-form-react": "^0.0.2",
|
||||||
|
"json-schema-library": "^10.0.0-rc7",
|
||||||
"kysely": "^0.27.4",
|
"kysely": "^0.27.4",
|
||||||
"liquidjs": "^10.15.0",
|
"liquidjs": "^10.15.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
"swr": "^2.2.5",
|
"object-path-immutable": "^4.1.2",
|
||||||
"json-schema-form-react": "^0.0.2",
|
"radix-ui": "^1.1.2",
|
||||||
"@uiw/react-codemirror": "^4.23.6",
|
"swr": "^2.2.5"
|
||||||
"@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"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.613.0",
|
"@aws-sdk/client-s3": "^3.613.0",
|
||||||
@@ -62,7 +65,6 @@
|
|||||||
"@hono/zod-validator": "^0.4.1",
|
"@hono/zod-validator": "^0.4.1",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@libsql/kysely-libsql": "^0.4.1",
|
"@libsql/kysely-libsql": "^0.4.1",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
|
||||||
"@rjsf/core": "^5.22.2",
|
"@rjsf/core": "^5.22.2",
|
||||||
"@tabler/icons-react": "3.18.0",
|
"@tabler/icons-react": "3.18.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
@@ -73,6 +75,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"esbuild-postcss": "^0.0.4",
|
"esbuild-postcss": "^0.0.4",
|
||||||
"jotai": "^2.10.1",
|
"jotai": "^2.10.1",
|
||||||
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"open": "^10.1.0",
|
"open": "^10.1.0",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
|
|||||||
@@ -81,9 +81,12 @@ export function identifierToHumanReadable(str: string) {
|
|||||||
case "SCREAMING_SNAKE_CASE":
|
case "SCREAMING_SNAKE_CASE":
|
||||||
return snakeToPascalWithSpaces(str.toLowerCase());
|
return snakeToPascalWithSpaces(str.toLowerCase());
|
||||||
case "unknown":
|
case "unknown":
|
||||||
return str;
|
return ucFirst(str);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export function autoFormatString(str: string) {
|
||||||
|
return identifierToHumanReadable(str);
|
||||||
|
}
|
||||||
|
|
||||||
export function kebabToPascalWithSpaces(str: string): string {
|
export function kebabToPascalWithSpaces(str: string): string {
|
||||||
return str.split("-").map(ucFirst).join(" ");
|
return str.split("-").map(ucFirst).join(" ");
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export function buildMediaSchema() {
|
|||||||
config: adapter.schema
|
config: adapter.schema
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: name,
|
title: adapter.schema.title ?? name,
|
||||||
|
description: adapter.schema.description,
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -32,6 +33,7 @@ export function buildMediaSchema() {
|
|||||||
{
|
{
|
||||||
body_max_size: Type.Optional(
|
body_max_size: Type.Optional(
|
||||||
Type.Number({
|
Type.Number({
|
||||||
|
minimum: 0,
|
||||||
description: "Max size of the body in bytes. Leave blank for unlimited."
|
description: "Max size of the body in bytes. Leave blank for unlimited."
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const cloudinaryAdapterConfig = Type.Object(
|
|||||||
api_secret: Type.String(),
|
api_secret: Type.String(),
|
||||||
upload_preset: Type.Optional(Type.String())
|
upload_preset: Type.Optional(Type.String())
|
||||||
},
|
},
|
||||||
{ title: "Cloudinary" }
|
{ title: "Cloudinary", description: "Cloudinary media storage" }
|
||||||
);
|
);
|
||||||
|
|
||||||
export type CloudinaryConfig = Static<typeof cloudinaryAdapterConfig>;
|
export type CloudinaryConfig = Static<typeof cloudinaryAdapterConfig>;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const localAdapterConfig = Type.Object(
|
|||||||
{
|
{
|
||||||
path: Type.String({ default: "./" })
|
path: Type.String({ default: "./" })
|
||||||
},
|
},
|
||||||
{ title: "Local" }
|
{ title: "Local", description: "Local file system storage" }
|
||||||
);
|
);
|
||||||
export type LocalAdapterConfig = Static<typeof localAdapterConfig>;
|
export type LocalAdapterConfig = Static<typeof localAdapterConfig>;
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export const s3AdapterConfig = Type.Object(
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "S3"
|
title: "AWS S3",
|
||||||
|
description: "AWS S3 or compatible storage"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { IconAlertHexagon } from "@tabler/icons-react";
|
|
||||||
import type { ModuleConfigs, ModuleSchemas } from "modules";
|
import type { ModuleConfigs, ModuleSchemas } from "modules";
|
||||||
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
|
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
|
||||||
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
|
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
|
||||||
import { useApi } from "ui/client";
|
import { useApi } from "ui/client";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
|
||||||
import { Alert } from "ui/components/display/Alert";
|
|
||||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||||
import { AppReduced } from "./utils/AppReduced";
|
import { AppReduced } from "./utils/AppReduced";
|
||||||
|
|
||||||
|
|||||||
22
app/src/ui/client/schema/media/use-bknd-media.ts
Normal file
22
app/src/ui/client/schema/media/use-bknd-media.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { TAppMediaConfig } from "media/media-schema";
|
||||||
|
import { useBknd } from "ui/client/BkndProvider";
|
||||||
|
|
||||||
|
export function useBkndMedia() {
|
||||||
|
const { config, schema, actions: bkndActions } = useBknd();
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
config: {
|
||||||
|
patch: async (data: Partial<TAppMediaConfig>) => {
|
||||||
|
return await bkndActions.set("media", data, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const $media = {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
$media,
|
||||||
|
config: config.media,
|
||||||
|
schema: schema.media,
|
||||||
|
actions
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { Children } from "react";
|
||||||
import { forwardRef } from "react";
|
import { forwardRef } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { Link } from "ui/components/wouter/Link";
|
import { Link } from "ui/components/wouter/Link";
|
||||||
@@ -19,7 +20,7 @@ const styles = {
|
|||||||
default: "bg-primary/5 hover:bg-primary/10 link text-primary/70",
|
default: "bg-primary/5 hover:bg-primary/10 link text-primary/70",
|
||||||
primary: "bg-primary hover:bg-primary/80 link text-background",
|
primary: "bg-primary hover:bg-primary/80 link text-background",
|
||||||
ghost: "bg-transparent hover:bg-primary/5 link text-primary/70",
|
ghost: "bg-transparent hover:bg-primary/5 link text-primary/70",
|
||||||
outline: "border border-primary/70 bg-transparent hover:bg-primary/5 link text-primary/70",
|
outline: "border border-primary/20 bg-transparent hover:bg-primary/5 link text-primary/80",
|
||||||
red: "dark:bg-red-950 dark:hover:bg-red-900 bg-red-100 hover:bg-red-200 link text-primary/70",
|
red: "dark:bg-red-950 dark:hover:bg-red-900 bg-red-100 hover:bg-red-200 link text-primary/70",
|
||||||
subtlered:
|
subtlered:
|
||||||
"dark:text-red-950 text-red-700 dark:hover:bg-red-900 bg-transparent hover:bg-red-50 link"
|
"dark:text-red-950 text-red-700 dark:hover:bg-red-900 bg-transparent hover:bg-red-50 link"
|
||||||
@@ -50,7 +51,7 @@ const Base = ({
|
|||||||
}: BaseProps) => ({
|
}: BaseProps) => ({
|
||||||
...props,
|
...props,
|
||||||
className: twMerge(
|
className: twMerge(
|
||||||
"flex flex-row flex-nowrap items-center font-semibold disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed",
|
"flex flex-row flex-nowrap items-center font-semibold disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed transition-[opacity,background-color,color,border-color]",
|
||||||
sizes[size ?? "default"],
|
sizes[size ?? "default"],
|
||||||
styles[variant ?? "default"],
|
styles[variant ?? "default"],
|
||||||
props.className
|
props.className
|
||||||
@@ -58,7 +59,11 @@ const Base = ({
|
|||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
{IconLeft && <IconLeft size={iconSize} {...iconProps} />}
|
{IconLeft && <IconLeft size={iconSize} {...iconProps} />}
|
||||||
{children && <span className={twMerge("leading-none", labelClassName)}>{children}</span>}
|
{children && Children.count(children) === 1 ? (
|
||||||
|
<span className={twMerge("leading-none", labelClassName)}>{children}</span>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
{IconRight && <IconRight size={iconSize} {...iconProps} />}
|
{IconRight && <IconRight size={iconSize} {...iconProps} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { forwardRef, useEffect, useState } from "react";
|
|||||||
|
|
||||||
export const BooleanInputMantine = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
export const BooleanInputMantine = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const [checked, setChecked] = useState(Boolean(props.value));
|
const [checked, setChecked] = useState(Boolean(props.value ?? props.defaultValue));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log("value change", props.value);
|
||||||
setChecked(Boolean(props.value));
|
setChecked(Boolean(props.value));
|
||||||
}, [props.value]);
|
}, [props.value]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,41 @@
|
|||||||
import { getBrowser } from "core/utils";
|
import { getBrowser } from "core/utils";
|
||||||
import type { Field } from "data";
|
import type { Field } from "data";
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
import { Switch as RadixSwitch } from "radix-ui";
|
||||||
|
import {
|
||||||
|
type ChangeEventHandler,
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type ElementType,
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from "react";
|
||||||
import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb";
|
import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { useEvent } from "ui/hooks/use-event";
|
import { useEvent } from "ui/hooks/use-event";
|
||||||
|
|
||||||
export const Group: React.FC<React.ComponentProps<"div"> & { error?: boolean }> = ({
|
export const Group = <E extends ElementType = "div">({
|
||||||
error,
|
error,
|
||||||
|
as,
|
||||||
...props
|
...props
|
||||||
}) => (
|
}: React.ComponentProps<E> & { error?: boolean; as?: E }) => {
|
||||||
<div
|
const Tag = as || "div";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex flex-col gap-1.5",
|
"flex flex-col gap-1.5",
|
||||||
|
as === "fieldset" && "border border-primary/10 p-3 rounded-md",
|
||||||
|
as === "fieldset" && error && "border-red-500",
|
||||||
error && "text-red-500",
|
error && "text-red-500",
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const formElementFactory = (element: string, props: any) => {
|
export const formElementFactory = (element: string, props: any) => {
|
||||||
switch (element) {
|
switch (element) {
|
||||||
@@ -34,7 +50,21 @@ export const formElementFactory = (element: string, props: any) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Label: React.FC<React.ComponentProps<"label">> = (props) => <label {...props} />;
|
export const Label = <E extends ElementType = "label">({
|
||||||
|
as,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<E> & { as?: E }) => {
|
||||||
|
const Tag = as || "label";
|
||||||
|
return <Tag {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Help: React.FC<React.ComponentProps<"div">> = ({ className, ...props }) => (
|
||||||
|
<div {...props} className={twMerge("text-sm text-primary/50", className)} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ErrorMessage: React.FC<React.ComponentProps<"div">> = ({ className, ...props }) => (
|
||||||
|
<div {...props} className={twMerge("text-sm text-red-500", className)} />
|
||||||
|
);
|
||||||
|
|
||||||
export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field }> = ({
|
export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field }> = ({
|
||||||
field,
|
field,
|
||||||
@@ -145,8 +175,45 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Select = forwardRef<HTMLSelectElement, React.ComponentProps<"select">>(
|
export type SwitchValue = boolean | 1 | 0 | "true" | "false" | "on" | "off";
|
||||||
(props, ref) => (
|
export const Switch = forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
Pick<
|
||||||
|
ComponentPropsWithoutRef<"input">,
|
||||||
|
"name" | "required" | "disabled" | "checked" | "defaultChecked" | "id" | "type"
|
||||||
|
> & {
|
||||||
|
value?: SwitchValue;
|
||||||
|
onChange?: (e: { target: { value: boolean } }) => void;
|
||||||
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
>(({ 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"
|
||||||
|
onCheckedChange={(bool) => {
|
||||||
|
props.onChange?.({ target: { value: bool } });
|
||||||
|
}}
|
||||||
|
{...(props as any)}
|
||||||
|
checked={
|
||||||
|
typeof props.checked !== "undefined"
|
||||||
|
? props.checked
|
||||||
|
: typeof props.value !== "undefined"
|
||||||
|
? Boolean(props.value)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<RadixSwitch.Thumb className="block h-full aspect-square translate-x-0 rounded-full bg-background transition-transform duration-100 will-change-transform border border-muted data-[state=checked]:translate-x-[17px]" />
|
||||||
|
</RadixSwitch.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Select = forwardRef<
|
||||||
|
HTMLSelectElement,
|
||||||
|
React.ComponentProps<"select"> & {
|
||||||
|
options?: { value: string; label: string }[] | (string | number)[];
|
||||||
|
}
|
||||||
|
>(({ children, options, ...props }, ref) => (
|
||||||
<div className="flex w-full relative">
|
<div className="flex w-full relative">
|
||||||
<select
|
<select
|
||||||
{...props}
|
{...props}
|
||||||
@@ -154,11 +221,32 @@ export const Select = forwardRef<HTMLSelectElement, React.ComponentProps<"select
|
|||||||
className={twMerge(
|
className={twMerge(
|
||||||
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
||||||
"appearance-none h-11 w-full",
|
"appearance-none h-11 w-full",
|
||||||
"border-r-8 border-r-transparent",
|
!props.multiple && "border-r-8 border-r-transparent",
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
|
{options ? (
|
||||||
|
<>
|
||||||
|
{!props.required && <option value="" />}
|
||||||
|
{options
|
||||||
|
.map((o, i) => {
|
||||||
|
if (typeof o !== "object") {
|
||||||
|
return { value: o, label: String(o) };
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
})
|
||||||
|
.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
{!props.multiple && (
|
||||||
<TbChevronDown className="absolute right-3 top-0 bottom-0 h-full opacity-70" size={18} />
|
<TbChevronDown className="absolute right-3 top-0 bottom-0 h-full opacity-70" size={18} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
));
|
||||||
);
|
|
||||||
|
|||||||
127
app/src/ui/components/form/json-schema-form/AnyOfField.tsx
Normal file
127
app/src/ui/components/form/json-schema-form/AnyOfField.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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 { twMerge } from "tailwind-merge";
|
||||||
|
import * as Formy from "ui/components/form/Formy";
|
||||||
|
import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field";
|
||||||
|
import { FormContextOverride, useFieldContext } from "./Form";
|
||||||
|
import { getLabel, getMultiSchemaMatched } from "./utils";
|
||||||
|
|
||||||
|
export type AnyOfFieldRootProps = {
|
||||||
|
path?: string;
|
||||||
|
schema?: Exclude<JSONSchema, boolean>;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnyOfFieldContext = {
|
||||||
|
path: string;
|
||||||
|
schema: Exclude<JSONSchema, boolean>;
|
||||||
|
schemas?: JSONSchema[];
|
||||||
|
selectedSchema?: Exclude<JSONSchema, boolean>;
|
||||||
|
selected: number | null;
|
||||||
|
select: (index: number | null) => void;
|
||||||
|
options: string[];
|
||||||
|
errors: JsonError[];
|
||||||
|
selectSchema: JSONSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AnyOfContext = createContext<AnyOfFieldContext>(undefined!);
|
||||||
|
|
||||||
|
export const useAnyOfContext = () => {
|
||||||
|
return useContext(AnyOfContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) => {
|
||||||
|
const { setValue, pointer, lib, value, errors, ...ctx } = useFieldContext(path);
|
||||||
|
const schema = _schema ?? ctx.schema;
|
||||||
|
if (!schema) return `AnyOfField(${path}): no schema ${pointer}`;
|
||||||
|
const [matchedIndex, schemas = []] = getMultiSchemaMatched(schema, value);
|
||||||
|
const [selected, setSelected] = useState<number | null>(matchedIndex > -1 ? matchedIndex : null);
|
||||||
|
const options = schemas.map((s, i) => s.title ?? `Option ${i + 1}`);
|
||||||
|
const selectSchema = {
|
||||||
|
type: "string",
|
||||||
|
enum: options
|
||||||
|
} satisfies JSONSchema;
|
||||||
|
//console.log("AnyOf:root", { value, matchedIndex, selected, schema });
|
||||||
|
|
||||||
|
const selectedSchema =
|
||||||
|
selected !== null ? (schemas[selected] as Exclude<JSONSchema, boolean>) : undefined;
|
||||||
|
|
||||||
|
function select(index: number | null) {
|
||||||
|
setValue(pointer, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined);
|
||||||
|
setSelected(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnyOfContext.Provider
|
||||||
|
value={{
|
||||||
|
selected,
|
||||||
|
select,
|
||||||
|
options,
|
||||||
|
selectSchema,
|
||||||
|
path,
|
||||||
|
schema,
|
||||||
|
schemas,
|
||||||
|
selectedSchema,
|
||||||
|
errors
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/*<pre>{JSON.stringify({ value, selected, errors: errors.length }, null, 2)}</pre>*/}
|
||||||
|
{children}
|
||||||
|
</AnyOfContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Select = () => {
|
||||||
|
const { selected, select, path, schema, selectSchema } = useAnyOfContext();
|
||||||
|
|
||||||
|
function handleSelect(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
//console.log("selected", e.target.value);
|
||||||
|
const i = e.target.value ? Number(e.target.value) : null;
|
||||||
|
select(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Formy.Label>{getLabel(path, schema)}</Formy.Label>
|
||||||
|
<FieldComponent
|
||||||
|
schema={selectSchema as any}
|
||||||
|
onChange={handleSelect}
|
||||||
|
value={selected ?? undefined}
|
||||||
|
className="h-8 py-1"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Field = ({ name, label, ...props }: Partial<FormFieldProps>) => {
|
||||||
|
const { selected, selectedSchema, path, errors } = useAnyOfContext();
|
||||||
|
if (selected === null) return null;
|
||||||
|
return (
|
||||||
|
<FormContextOverride path={path} schema={selectedSchema} overrideData>
|
||||||
|
<div className={twMerge(errors.length > 0 && "bg-red-500/10")}>
|
||||||
|
<FormField key={`${path}_${selected}`} name={""} label={false} {...props} />
|
||||||
|
</div>
|
||||||
|
</FormContextOverride>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnyOf = {
|
||||||
|
Root,
|
||||||
|
Select,
|
||||||
|
Field,
|
||||||
|
useContext: useAnyOfContext
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnyOfField = (props: Omit<AnyOfFieldRootProps, "children">) => {
|
||||||
|
return (
|
||||||
|
<fieldset>
|
||||||
|
<AnyOf.Root {...props}>
|
||||||
|
<legend className="flex flex-row gap-2 items-center py-2">
|
||||||
|
<AnyOf.Select />
|
||||||
|
</legend>
|
||||||
|
<AnyOf.Field />
|
||||||
|
</AnyOf.Root>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
};
|
||||||
103
app/src/ui/components/form/json-schema-form/ArrayField.tsx
Normal file
103
app/src/ui/components/form/json-schema-form/ArrayField.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { IconLibraryPlus, IconTrash } from "@tabler/icons-react";
|
||||||
|
import type { JSONSchema } from "json-schema-to-ts";
|
||||||
|
import { Button } from "ui/components/buttons/Button";
|
||||||
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
|
import * as Formy from "ui/components/form/Formy";
|
||||||
|
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||||
|
import { FieldComponent } from "./Field";
|
||||||
|
import { FieldWrapper } from "./FieldWrapper";
|
||||||
|
import { useFieldContext } from "./Form";
|
||||||
|
import { coerce, getMultiSchema, getMultiSchemaMatched } from "./utils";
|
||||||
|
|
||||||
|
export const ArrayField = ({
|
||||||
|
path = "",
|
||||||
|
schema: _schema
|
||||||
|
}: { path?: string; schema?: Exclude<JSONSchema, boolean> }) => {
|
||||||
|
const { setValue, value, pointer, required, ...ctx } = useFieldContext(path);
|
||||||
|
const schema = _schema ?? ctx.schema;
|
||||||
|
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
|
||||||
|
|
||||||
|
const itemsMultiSchema = getMultiSchema(schema.items);
|
||||||
|
|
||||||
|
function handleAdd(template?: any) {
|
||||||
|
const currentIndex = value?.length ?? 0;
|
||||||
|
const newPointer = `${path}/${currentIndex}`.replace(/\/+/g, "/");
|
||||||
|
setValue(newPointer, template ?? ctx.lib.getTemplate(undefined, schema!.items));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdate(pointer: string, value: any) {
|
||||||
|
setValue(pointer, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(pointer: string) {
|
||||||
|
return () => {
|
||||||
|
ctx.deleteValue(pointer);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// if unique items with enum
|
||||||
|
if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) {
|
||||||
|
return (
|
||||||
|
<FieldWrapper pointer={path} schema={schema} wrapper="fieldset">
|
||||||
|
<FieldComponent
|
||||||
|
required
|
||||||
|
schema={schema.items}
|
||||||
|
multiple
|
||||||
|
value={value}
|
||||||
|
className="h-auto"
|
||||||
|
onChange={(e: any) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const selected = Array.from(e.target.selectedOptions).map((o) => o.value);
|
||||||
|
setValue(pointer, selected);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
97
app/src/ui/components/form/json-schema-form/Field.tsx
Normal file
97
app/src/ui/components/form/json-schema-form/Field.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { JSONSchema } from "json-schema-to-ts";
|
||||||
|
import type { ChangeEvent, ComponentPropsWithoutRef } from "react";
|
||||||
|
import * as Formy from "ui/components/form/Formy";
|
||||||
|
import { ArrayField } from "./ArrayField";
|
||||||
|
import { FieldWrapper } from "./FieldWrapper";
|
||||||
|
import { useFieldContext } from "./Form";
|
||||||
|
import { ObjectField } from "./ObjectField";
|
||||||
|
import { coerce, enumToOptions, isType, isTypeSchema } from "./utils";
|
||||||
|
|
||||||
|
export type FieldProps = {
|
||||||
|
name: string;
|
||||||
|
schema?: Exclude<JSONSchema, boolean>;
|
||||||
|
onChange?: (e: ChangeEvent<any>) => void;
|
||||||
|
label?: string | false;
|
||||||
|
hidden?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }: FieldProps) => {
|
||||||
|
const { pointer, value, errors, setValue, required, ...ctx } = useFieldContext(name);
|
||||||
|
const schema = _schema ?? ctx.schema;
|
||||||
|
if (!isTypeSchema(schema)) return <Pre>{pointer} has no schema</Pre>;
|
||||||
|
//console.log("field", name, schema);
|
||||||
|
|
||||||
|
if (isType(schema.type, "object")) {
|
||||||
|
return <ObjectField path={name} schema={schema} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isType(schema.type, "array")) {
|
||||||
|
return <ArrayField path={name} schema={schema} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabled = schema.readOnly ?? "const" in schema ?? false;
|
||||||
|
//console.log("field", name, disabled, schema, ctx.schema, _schema);
|
||||||
|
|
||||||
|
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
// don't remove for now, causes issues in anyOf
|
||||||
|
/*const value = coerce(e.target.value, schema as any);
|
||||||
|
setValue(pointer, value as any);*/
|
||||||
|
|
||||||
|
const value = coerce(e.target.value, schema as any, { required });
|
||||||
|
//console.log("handleChange", pointer, e.target.value, { value }, ctx.options);
|
||||||
|
if (typeof value === "undefined" && !required && ctx.options?.keepEmpty !== true) {
|
||||||
|
ctx.deleteValue(pointer);
|
||||||
|
} else {
|
||||||
|
//console.log("setValue", pointer, value);
|
||||||
|
setValue(pointer, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldWrapper
|
||||||
|
pointer={pointer}
|
||||||
|
label={_label}
|
||||||
|
required={required}
|
||||||
|
errors={errors}
|
||||||
|
schema={schema}
|
||||||
|
debug={{ value }}
|
||||||
|
hidden={hidden}
|
||||||
|
>
|
||||||
|
<FieldComponent
|
||||||
|
schema={schema}
|
||||||
|
name={pointer}
|
||||||
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange ?? handleChange}
|
||||||
|
/>
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Pre = ({ children }) => (
|
||||||
|
<pre className="dark:bg-red-950 bg-red-100 rounded-md px-3 py-1.5">{children}</pre>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FieldComponent = ({
|
||||||
|
schema,
|
||||||
|
...props
|
||||||
|
}: { schema: JSONSchema } & ComponentPropsWithoutRef<"input">) => {
|
||||||
|
if (!isTypeSchema(schema)) return null;
|
||||||
|
|
||||||
|
if (schema.enum) {
|
||||||
|
return (
|
||||||
|
<Formy.Select id={props.name} {...(props as any)} options={enumToOptions(schema.enum)} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isType(schema.type, ["number", "integer"])) {
|
||||||
|
return <Formy.Input type="number" id={props.name} {...props} value={props.value ?? ""} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isType(schema.type, "boolean")) {
|
||||||
|
return <Formy.Switch id={props.name} {...(props as any)} checked={props.value as any} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Formy.Input id={props.name} {...props} value={props.value ?? ""} />;
|
||||||
|
};
|
||||||
98
app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
Normal file
98
app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Popover } from "@mantine/core";
|
||||||
|
import { IconBug } from "@tabler/icons-react";
|
||||||
|
import type { JsonError } from "json-schema-library";
|
||||||
|
import type { JSONSchema } from "json-schema-to-ts";
|
||||||
|
import { Children, type ReactElement, type ReactNode, cloneElement, 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 { getLabel } from "./utils";
|
||||||
|
|
||||||
|
export type FieldwrapperProps = {
|
||||||
|
pointer: string;
|
||||||
|
label?: string | false;
|
||||||
|
required?: boolean;
|
||||||
|
errors?: JsonError[];
|
||||||
|
schema?: Exclude<JSONSchema, boolean>;
|
||||||
|
debug?: object | boolean;
|
||||||
|
wrapper?: "group" | "fieldset";
|
||||||
|
hidden?: boolean;
|
||||||
|
children: ReactElement | ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FieldWrapper({
|
||||||
|
pointer,
|
||||||
|
label: _label,
|
||||||
|
required,
|
||||||
|
errors = [],
|
||||||
|
schema,
|
||||||
|
debug,
|
||||||
|
wrapper,
|
||||||
|
hidden,
|
||||||
|
children
|
||||||
|
}: FieldwrapperProps) {
|
||||||
|
const examples = schema?.examples || [];
|
||||||
|
const examplesId = `${pointer}-examples`;
|
||||||
|
const description = schema?.description;
|
||||||
|
const label =
|
||||||
|
typeof _label !== "undefined" ? _label : schema ? getLabel(pointer, schema) : pointer;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formy.Group
|
||||||
|
error={errors.length > 0}
|
||||||
|
as={wrapper === "fieldset" ? "fieldset" : "div"}
|
||||||
|
className={hidden ? "hidden" : "relative"}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{label && (
|
||||||
|
<Formy.Label as={wrapper === "fieldset" ? "legend" : "label"} htmlFor={pointer}>
|
||||||
|
{label} {required && <span className="font-medium opacity-30">*</span>}
|
||||||
|
</Formy.Label>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<div className="flex flex-1 flex-col gap-3">
|
||||||
|
{Children.count(children) === 1 && isValidElement(children)
|
||||||
|
? cloneElement(children, {
|
||||||
|
// @ts-ignore
|
||||||
|
list: examples.length > 0 ? examplesId : undefined
|
||||||
|
})
|
||||||
|
: children}
|
||||||
|
{examples.length > 0 && (
|
||||||
|
<datalist id={examplesId}>
|
||||||
|
{examples.map((e, i) => (
|
||||||
|
<option key={i} value={e as any} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{description && <Formy.Help>{description}</Formy.Help>}
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
|
||||||
|
)}
|
||||||
|
</Formy.Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
app/src/ui/components/form/json-schema-form/Form.tsx
Normal file
246
app/src/ui/components/form/json-schema-form/Form.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { Draft2019, type JsonError } from "json-schema-library";
|
||||||
|
import type { TemplateOptions as LibTemplateOptions } from "json-schema-library/dist/lib/getTemplate";
|
||||||
|
import type { JsonSchema as LibJsonSchema } from "json-schema-library/dist/lib/types";
|
||||||
|
import type { JSONSchema as $JSONSchema, FromSchema } from "json-schema-to-ts";
|
||||||
|
import { get, isEqual } from "lodash-es";
|
||||||
|
import * as immutable from "object-path-immutable";
|
||||||
|
import {
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type FormEvent,
|
||||||
|
type ReactNode,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from "react";
|
||||||
|
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||||
|
import { Field } from "./Field";
|
||||||
|
import { isRequired, normalizePath, omitSchema, prefixPointer } from "./utils";
|
||||||
|
|
||||||
|
type JSONSchema = Exclude<$JSONSchema, boolean>;
|
||||||
|
|
||||||
|
export type FormProps<
|
||||||
|
Schema extends JSONSchema = JSONSchema,
|
||||||
|
Data = Schema extends JSONSchema ? FromSchema<JSONSchema> : any
|
||||||
|
> = Omit<ComponentPropsWithoutRef<"form">, "onChange"> & {
|
||||||
|
schema: Schema;
|
||||||
|
validateOn?: "change" | "submit";
|
||||||
|
initialValues?: Partial<Data>;
|
||||||
|
initialOpts?: LibTemplateOptions;
|
||||||
|
ignoreKeys?: string[];
|
||||||
|
onChange?: (data: Partial<Data>, name: string, value: any) => void;
|
||||||
|
onSubmit?: (data: Partial<Data>) => void | Promise<void>;
|
||||||
|
onInvalidSubmit?: (errors: JsonError[], data: Partial<Data>) => void;
|
||||||
|
hiddenSubmit?: boolean;
|
||||||
|
options?: {
|
||||||
|
debug?: boolean;
|
||||||
|
keepEmpty?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
lib: Draft2019;
|
||||||
|
options: FormProps["options"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormContext = createContext<FormContext<any>>(undefined!);
|
||||||
|
|
||||||
|
export function Form<
|
||||||
|
Schema extends JSONSchema = JSONSchema,
|
||||||
|
Data = Schema extends JSONSchema ? FromSchema<JSONSchema> : any
|
||||||
|
>({
|
||||||
|
schema: _schema,
|
||||||
|
initialValues: _initialValues,
|
||||||
|
initialOpts,
|
||||||
|
children,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
onInvalidSubmit,
|
||||||
|
validateOn = "submit",
|
||||||
|
hiddenSubmit = true,
|
||||||
|
ignoreKeys = [],
|
||||||
|
options = {},
|
||||||
|
...props
|
||||||
|
}: FormProps<Schema, Data>) {
|
||||||
|
const [schema, initial] = omitSchema(_schema, ignoreKeys, _initialValues);
|
||||||
|
const lib = new Draft2019(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 formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
|
if (onSubmit) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, errors } = validate();
|
||||||
|
if (errors.length === 0) {
|
||||||
|
await onSubmit(data);
|
||||||
|
} else {
|
||||||
|
console.log("invalid", errors);
|
||||||
|
onInvalidSubmit?.(errors, data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDirty(!isEqual(initialValues, data));
|
||||||
|
|
||||||
|
if (validateOn === "change") {
|
||||||
|
validate();
|
||||||
|
} else if (errors.length > 0) {
|
||||||
|
validate();
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
function validate(_data?: Partial<Data>) {
|
||||||
|
const actual = _data ?? data;
|
||||||
|
const errors = lib.validate(actual, schema);
|
||||||
|
//console.log("errors", errors);
|
||||||
|
setErrors(errors);
|
||||||
|
return { data: actual, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
data: data ?? {},
|
||||||
|
dirty,
|
||||||
|
submitting,
|
||||||
|
setData,
|
||||||
|
setValue,
|
||||||
|
deleteValue,
|
||||||
|
errors,
|
||||||
|
schema,
|
||||||
|
lib,
|
||||||
|
options
|
||||||
|
} as any;
|
||||||
|
//console.log("context", context);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form {...props} ref={formRef} onSubmit={handleSubmit}>
|
||||||
|
<FormContext.Provider value={context}>
|
||||||
|
{children ? children : <Field name="" />}
|
||||||
|
</FormContext.Provider>
|
||||||
|
{hiddenSubmit && (
|
||||||
|
<button style={{ visibility: "hidden" }} type="submit">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFormContext() {
|
||||||
|
return useContext(FormContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormContextOverride({
|
||||||
|
children,
|
||||||
|
overrideData,
|
||||||
|
path,
|
||||||
|
...overrides
|
||||||
|
}: Partial<FormContext<any>> & { children: ReactNode; path?: string; overrideData?: boolean }) {
|
||||||
|
const ctx = useFormContext();
|
||||||
|
const additional: Partial<FormContext<any>> = {};
|
||||||
|
|
||||||
|
// this makes a local schema down the three
|
||||||
|
// especially useful for AnyOf, since it doesn't need to fully validate (e.g. pattern)
|
||||||
|
if (overrideData && path) {
|
||||||
|
const pointer = normalizePath(path);
|
||||||
|
const value =
|
||||||
|
pointer === "#/" ? ctx.data : get(ctx.data, pointer.substring(2).replace(/\//g, "."));
|
||||||
|
|
||||||
|
additional.data = value;
|
||||||
|
additional.setValue = (pointer: string, value: any) => {
|
||||||
|
ctx.setValue(prefixPointer(pointer, path), value);
|
||||||
|
};
|
||||||
|
additional.deleteValue = (pointer: string) => {
|
||||||
|
ctx.deleteValue(prefixPointer(pointer, path));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
...ctx,
|
||||||
|
...overrides,
|
||||||
|
...additional
|
||||||
|
};
|
||||||
|
|
||||||
|
return <FormContext.Provider value={context}>{children}</FormContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFieldContext(name: string) {
|
||||||
|
const { data, lib, schema, errors: formErrors, ...rest } = useFormContext();
|
||||||
|
const pointer = normalizePath(name);
|
||||||
|
const isRootPointer = pointer === "#/";
|
||||||
|
//console.log("pointer", pointer);
|
||||||
|
const value = isRootPointer ? data : get(data, pointer.substring(2).replace(/\//g, "."));
|
||||||
|
const errors = formErrors.filter((error) => error.data.pointer.startsWith(pointer));
|
||||||
|
const fieldSchema = isRootPointer
|
||||||
|
? (schema as LibJsonSchema)
|
||||||
|
: lib.getSchema({ pointer, data, schema });
|
||||||
|
const required = isRequired(pointer, schema, data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
lib,
|
||||||
|
value,
|
||||||
|
errors,
|
||||||
|
schema: fieldSchema,
|
||||||
|
pointer,
|
||||||
|
required
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Subscribe({ children }: { children: (ctx: FormContext<any>) => ReactNode }) {
|
||||||
|
const ctx = useFormContext();
|
||||||
|
return children(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormDebug() {
|
||||||
|
const { options, data, dirty, errors, submitting } = useFormContext();
|
||||||
|
if (options?.debug !== true) return null;
|
||||||
|
|
||||||
|
return <JsonViewer json={{ dirty, submitting, data, errors }} expand={99} />;
|
||||||
|
}
|
||||||
47
app/src/ui/components/form/json-schema-form/ObjectField.tsx
Normal file
47
app/src/ui/components/form/json-schema-form/ObjectField.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { JsonError } from "json-schema-library";
|
||||||
|
import type { JSONSchema } from "json-schema-to-ts";
|
||||||
|
import { AnyOfField } from "./AnyOfField";
|
||||||
|
import { Field } from "./Field";
|
||||||
|
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
||||||
|
import { useFieldContext } from "./Form";
|
||||||
|
|
||||||
|
export type ObjectFieldProps = {
|
||||||
|
path?: string;
|
||||||
|
schema?: Exclude<JSONSchema, boolean>;
|
||||||
|
label?: string | false;
|
||||||
|
wrapperProps?: Partial<FieldwrapperProps>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ObjectField = ({
|
||||||
|
path = "",
|
||||||
|
schema: _schema,
|
||||||
|
label: _label,
|
||||||
|
wrapperProps = {}
|
||||||
|
}: ObjectFieldProps) => {
|
||||||
|
const ctx = useFieldContext(path);
|
||||||
|
const schema = _schema ?? ctx.schema;
|
||||||
|
if (!schema) return "ObjectField: no schema";
|
||||||
|
const properties = schema.properties ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldWrapper
|
||||||
|
pointer={path}
|
||||||
|
errors={ctx.errors}
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (schema.anyOf || schema.oneOf) {
|
||||||
|
return <AnyOfField key={pointer} path={pointer} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Field key={pointer} name={pointer} />;
|
||||||
|
})}
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
6
app/src/ui/components/form/json-schema-form/index.ts
Normal file
6
app/src/ui/components/form/json-schema-form/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export * from "./Field";
|
||||||
|
export * from "./Form";
|
||||||
|
export * from "./ObjectField";
|
||||||
|
export * from "./ArrayField";
|
||||||
|
export * from "./AnyOfField";
|
||||||
|
export * from "./FieldWrapper";
|
||||||
232
app/src/ui/components/form/json-schema-form/utils.ts
Normal file
232
app/src/ui/components/form/json-schema-form/utils.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { autoFormatString } from "core/utils";
|
||||||
|
import { Draft2019, type JsonSchema } from "json-schema-library";
|
||||||
|
import type { JSONSchema } from "json-schema-to-ts";
|
||||||
|
import type { JSONSchemaType } from "json-schema-to-ts/lib/types/definitions/jsonSchema";
|
||||||
|
import { 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;
|
||||||
|
|
||||||
|
// check if target has attribute "data-ignore" set
|
||||||
|
// also check if target is within a "data-ignore" element
|
||||||
|
|
||||||
|
if (
|
||||||
|
!target ||
|
||||||
|
!form.contains(target) ||
|
||||||
|
!target.name ||
|
||||||
|
target.hasAttribute("data-ignore") ||
|
||||||
|
target.closest("[data-ignore]")
|
||||||
|
) {
|
||||||
|
return; // Ignore events from outside the form
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flatten(obj: any, parentKey = "", result: any = {}): any {
|
||||||
|
for (const key in obj) {
|
||||||
|
if (key in obj) {
|
||||||
|
const newKey = parentKey ? `${parentKey}/${key}` : "#/" + key;
|
||||||
|
if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) {
|
||||||
|
flatten(obj[key], newKey, result);
|
||||||
|
} else if (Array.isArray(obj[key])) {
|
||||||
|
obj[key].forEach((item, index) => {
|
||||||
|
const arrayKey = `${newKey}.${index}`;
|
||||||
|
if (typeof item === "object" && item !== null) {
|
||||||
|
flatten(item, arrayKey, result);
|
||||||
|
} else {
|
||||||
|
result[arrayKey] = item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result[newKey] = obj[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @todo: make sure it's in the right order
|
||||||
|
export function unflatten(
|
||||||
|
obj: Record<string, string>,
|
||||||
|
schema: JSONSchema,
|
||||||
|
selections?: Record<string, number | undefined>
|
||||||
|
) {
|
||||||
|
const result = {};
|
||||||
|
const lib = new Draft2019(schema as any);
|
||||||
|
for (const pointer in obj) {
|
||||||
|
const required = isRequired(pointer, schema);
|
||||||
|
let subschema = lib.getSchema({ pointer });
|
||||||
|
console.log("subschema", pointer, subschema, selections);
|
||||||
|
if (!subschema) {
|
||||||
|
throw new Error(`"${pointer}" not found in schema`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if subschema starts with "anyOf" or "oneOf"
|
||||||
|
if (subschema.anyOf || subschema.oneOf) {
|
||||||
|
const selected = selections?.[pointer];
|
||||||
|
if (selected !== undefined) {
|
||||||
|
subschema = subschema.anyOf ? subschema.anyOf[selected] : subschema.oneOf![selected];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = coerce(obj[pointer], subschema as any, { required });
|
||||||
|
|
||||||
|
set(result, pointer.substring(2).replace(/\//g, "."), value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerce(
|
||||||
|
value: any,
|
||||||
|
schema: Exclude<JSONSchema, boolean>,
|
||||||
|
opts?: { required?: boolean }
|
||||||
|
) {
|
||||||
|
if (!value && typeof opts?.required === "boolean" && !opts.required) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (schema.type) {
|
||||||
|
case "string":
|
||||||
|
return String(value);
|
||||||
|
case "integer":
|
||||||
|
case "number":
|
||||||
|
return Number(value);
|
||||||
|
case "boolean":
|
||||||
|
return ["true", "1", 1, "on", true].includes(value);
|
||||||
|
case "null":
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* normalizes any path to a full json pointer
|
||||||
|
*
|
||||||
|
* examples: in -> out
|
||||||
|
* description -> #/description
|
||||||
|
* #/description -> #/description
|
||||||
|
* /description -> #/description
|
||||||
|
* nested/property -> #/nested/property
|
||||||
|
* nested.property -> #/nested/property
|
||||||
|
* nested.property[0] -> #/nested/property/0
|
||||||
|
* nested.property[0].name -> #/nested/property/0/name
|
||||||
|
* @param path
|
||||||
|
*/
|
||||||
|
export function normalizePath(path: string) {
|
||||||
|
return path.startsWith("#/")
|
||||||
|
? path
|
||||||
|
: `#/${path.replace(/#?\/?/, "").replace(/\./g, "/").replace(/\[/g, "/").replace(/\]/g, "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prefixPointer(pointer: string, prefix: string) {
|
||||||
|
return pointer.replace("#/", `#/${prefix}/`).replace(/\/\//g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParentPointer(pointer: string) {
|
||||||
|
return pointer.substring(0, pointer.lastIndexOf("/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRequired(pointer: string, schema: JSONSchema, data?: any) {
|
||||||
|
if (pointer === "#/") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const lib = new Draft2019(schema as any);
|
||||||
|
|
||||||
|
const childSchema = lib.getSchema({ pointer, data });
|
||||||
|
if (typeof childSchema === "object" && ("const" in childSchema || "enum" in childSchema)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPointer = getParentPointer(pointer);
|
||||||
|
const parentSchema = lib.getSchema({ pointer: parentPointer, data });
|
||||||
|
const required = parentSchema?.required?.includes(pointer.split("/").pop()!);
|
||||||
|
|
||||||
|
/*console.log("isRequired", {
|
||||||
|
pointer,
|
||||||
|
parentPointer,
|
||||||
|
parent: parentSchema ? JSON.parse(JSON.stringify(parentSchema)) : null,
|
||||||
|
required
|
||||||
|
});*/
|
||||||
|
|
||||||
|
return !!required;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TType = JSONSchemaType | JSONSchemaType[] | readonly JSONSchemaType[] | 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 function getLabel(name: string, schema: JSONSchema) {
|
||||||
|
if (typeof schema === "object" && "title" in schema) return schema.title;
|
||||||
|
const label = name.includes("/") ? (name.split("/").pop() ?? "") : name;
|
||||||
|
return autoFormatString(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMultiSchema(schema: JSONSchema): Exclude<JSONSchema, boolean>[] | undefined {
|
||||||
|
if (!schema || typeof schema !== "object") return;
|
||||||
|
return (schema.anyOf ?? schema.oneOf) as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMultiSchemaMatched(
|
||||||
|
schema: JsonSchema,
|
||||||
|
data: any
|
||||||
|
): [number, Exclude<JSONSchema, boolean>[], Exclude<JSONSchema, boolean> | undefined] {
|
||||||
|
const multiSchema = getMultiSchema(schema);
|
||||||
|
if (!multiSchema) return [-1, [], undefined];
|
||||||
|
const index = multiSchema.findIndex((subschema) => {
|
||||||
|
const lib = new Draft2019(subschema as any);
|
||||||
|
return lib.validate(data, subschema).length === 0;
|
||||||
|
});
|
||||||
|
if (index === -1) return [-1, multiSchema, undefined];
|
||||||
|
|
||||||
|
return [index, multiSchema, multiSchema[index]];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeKeyRecursively<Given extends object>(obj: Given, keyToRemove: string): Given {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map((item) => removeKeyRecursively(item, keyToRemove)) as any;
|
||||||
|
} else if (typeof obj === "object" && obj !== null) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(obj)
|
||||||
|
.filter(([key]) => key !== keyToRemove)
|
||||||
|
.map(([key, value]) => [key, removeKeyRecursively(value, keyToRemove)])
|
||||||
|
) as any;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
const schema = JSON.parse(JSON.stringify(_schema));
|
||||||
|
const data = _data ? JSON.parse(JSON.stringify(_data)) : undefined;
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...schema,
|
||||||
|
properties: omit(schema.properties, keys)
|
||||||
|
};
|
||||||
|
if (updated.required) {
|
||||||
|
updated.required = updated.required.filter((key) => !keys.includes(key as any));
|
||||||
|
}
|
||||||
|
|
||||||
|
const reducedConfig = omit(data, keys) as any;
|
||||||
|
|
||||||
|
return [updated, reducedConfig];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTypeSchema(schema?: JSONSchema): schema is Exclude<JSONSchema, boolean> {
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Modal, type ModalProps, Popover } from "@mantine/core";
|
import { Modal, type ModalProps, Popover } from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
|
||||||
import { IconBug } from "@tabler/icons-react";
|
import { IconBug } from "@tabler/icons-react";
|
||||||
|
import { ScrollArea } from "radix-ui";
|
||||||
import { Fragment, forwardRef, useImperativeHandle } from "react";
|
import { Fragment, forwardRef, useImperativeHandle } from "react";
|
||||||
import { TbX } from "react-icons/tb";
|
import { TbX } from "react-icons/tb";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import * as ReactScrollArea from "@radix-ui/react-scroll-area";
|
|
||||||
|
|
||||||
export const ScrollArea = ({ children, className }: any) => (
|
|
||||||
<ReactScrollArea.Root className={`${className} `}>
|
|
||||||
<ReactScrollArea.Viewport className="w-full h-full ">
|
|
||||||
{children}
|
|
||||||
</ReactScrollArea.Viewport>
|
|
||||||
<ReactScrollArea.Scrollbar
|
|
||||||
className="ScrollAreaScrollbar"
|
|
||||||
orientation="vertical"
|
|
||||||
>
|
|
||||||
<ReactScrollArea.Thumb className="ScrollAreaThumb" />
|
|
||||||
</ReactScrollArea.Scrollbar>
|
|
||||||
<ReactScrollArea.Scrollbar
|
|
||||||
className="ScrollAreaScrollbar"
|
|
||||||
orientation="horizontal"
|
|
||||||
>
|
|
||||||
<ReactScrollArea.Thumb className="ScrollAreaThumb" />
|
|
||||||
</ReactScrollArea.Scrollbar>
|
|
||||||
<ReactScrollArea.Corner className="ScrollAreaCorner" />
|
|
||||||
</ReactScrollArea.Root>
|
|
||||||
);
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import {
|
|
||||||
type ComponentProps,
|
|
||||||
type ComponentPropsWithRef,
|
|
||||||
type ComponentPropsWithoutRef,
|
|
||||||
type ElementRef,
|
|
||||||
type ElementType,
|
|
||||||
type ForwardedRef,
|
|
||||||
type PropsWithChildren,
|
|
||||||
type ReactElement,
|
|
||||||
forwardRef
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
export function extend<ComponentType extends ElementType, AdditionalProps = {}>(
|
|
||||||
Component: ComponentType,
|
|
||||||
applyAdditionalProps?: (
|
|
||||||
props: PropsWithChildren<ComponentPropsWithoutRef<ComponentType> & AdditionalProps> & {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
) => ComponentProps<ComponentType>
|
|
||||||
) {
|
|
||||||
return forwardRef<
|
|
||||||
ElementRef<ComponentType>,
|
|
||||||
ComponentPropsWithoutRef<ComponentType> & AdditionalProps
|
|
||||||
>((props, ref) => {
|
|
||||||
// Initialize newProps with a default empty object or the result of applyAdditionalProps
|
|
||||||
let newProps: ComponentProps<ComponentType> & AdditionalProps = applyAdditionalProps
|
|
||||||
? applyAdditionalProps(props as any)
|
|
||||||
: (props as any);
|
|
||||||
|
|
||||||
// Append className if it exists in both props and newProps
|
|
||||||
if (props.className && newProps.className) {
|
|
||||||
newProps = {
|
|
||||||
...newProps,
|
|
||||||
className: `${props.className} ${newProps.className}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-expect-error haven't figured out the correct typing
|
|
||||||
return <Component {...newProps} ref={ref} />;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
type RenderFunction<ComponentType extends React.ElementType, AdditionalProps = {}> = (
|
|
||||||
props: PropsWithChildren<ComponentPropsWithRef<ComponentType> & AdditionalProps> & {
|
|
||||||
className?: string;
|
|
||||||
},
|
|
||||||
ref: ForwardedRef<ElementRef<ComponentType>>
|
|
||||||
) => ReactElement;
|
|
||||||
|
|
||||||
export function extendComponent<ComponentType extends React.ElementType, AdditionalProps = {}>(
|
|
||||||
renderFunction: RenderFunction<ComponentType, AdditionalProps>
|
|
||||||
) {
|
|
||||||
// The extended component using forwardRef to forward the ref to the custom component
|
|
||||||
const ExtendedComponent = forwardRef<
|
|
||||||
ElementRef<ComponentType>,
|
|
||||||
ComponentPropsWithRef<ComponentType> & AdditionalProps
|
|
||||||
>((props, ref) => {
|
|
||||||
return renderFunction(props as any, ref);
|
|
||||||
});
|
|
||||||
|
|
||||||
return ExtendedComponent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
export const Content = forwardRef<
|
|
||||||
ElementRef<typeof DropdownMenu.Content>,
|
|
||||||
ComponentPropsWithoutRef<typeof DropdownMenu.Content>
|
|
||||||
>(({ className, ...props }, forwardedRef) => (
|
|
||||||
<DropdownMenu.Content
|
|
||||||
className={`flex flex-col ${className}`}
|
|
||||||
{...props}
|
|
||||||
ref={forwardedRef}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
|
|
||||||
export const Item = forwardRef<
|
|
||||||
ElementRef<typeof DropdownMenu.Item>,
|
|
||||||
ComponentPropsWithoutRef<typeof DropdownMenu.Item>
|
|
||||||
>(({ className, ...props }, forwardedRef) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
className={`flex flex-row flex-nowrap ${className}`}
|
|
||||||
{...props}
|
|
||||||
ref={forwardedRef}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
*/
|
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
import type { ValueError } from "@sinclair/typebox/value";
|
|
||||||
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
|
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { type TSchema, Type, Value } from "core/utils";
|
import { Type } from "core/utils";
|
||||||
import { Form, type Validator } from "json-schema-form-react";
|
import { Form } from "json-schema-form-react";
|
||||||
import { transform } from "lodash-es";
|
import { transform } from "lodash-es";
|
||||||
import type { ComponentPropsWithoutRef } from "react";
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import { Group, Input, Label } from "ui/components/form/Formy/components";
|
import { Group, Input, Label } from "ui/components/form/Formy/components";
|
||||||
import { SocialLink } from "./SocialLink";
|
import { SocialLink } from "./SocialLink";
|
||||||
|
|
||||||
|
import type { ValueError } from "@sinclair/typebox/value";
|
||||||
|
import { type TSchema, Value } from "core/utils";
|
||||||
|
import type { Validator } from "json-schema-form-react";
|
||||||
|
|
||||||
|
class TypeboxValidator implements Validator<ValueError> {
|
||||||
|
async validate(schema: TSchema, data: any) {
|
||||||
|
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
|
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
|
||||||
className?: string;
|
className?: string;
|
||||||
formData?: any;
|
formData?: any;
|
||||||
@@ -18,14 +27,7 @@ export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" |
|
|||||||
buttonLabel?: string;
|
buttonLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
class TypeboxValidator implements Validator<ValueError> {
|
|
||||||
async validate(schema: TSchema, data: any) {
|
|
||||||
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validator = new TypeboxValidator();
|
const validator = new TypeboxValidator();
|
||||||
|
|
||||||
const schema = Type.Object({
|
const schema = Type.Object({
|
||||||
email: Type.String({
|
email: Type.String({
|
||||||
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
||||||
@@ -82,7 +84,7 @@ export function AuthForm({
|
|||||||
<Form
|
<Form
|
||||||
method={method}
|
method={method}
|
||||||
action={password.action}
|
action={password.action}
|
||||||
{...props}
|
{...(props as any)}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
validator={validator}
|
validator={validator}
|
||||||
validationMode="change"
|
validationMode="change"
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { useClickOutside, useHotkeys } from "@mantine/hooks";
|
import { useClickOutside, useHotkeys } from "@mantine/hooks";
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
|
||||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||||
import { throttle } from "lodash-es";
|
import { throttle } from "lodash-es";
|
||||||
import { type ComponentProps, useEffect, useRef, useState } from "react";
|
import { ScrollArea } from "radix-ui";
|
||||||
|
import {
|
||||||
|
type ComponentProps,
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from "react";
|
||||||
import type { IconType } from "react-icons";
|
import type { IconType } from "react-icons";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
@@ -69,7 +75,7 @@ export function Content({ children, center }: { children: React.ReactNode; cente
|
|||||||
|
|
||||||
export function Main({ children }) {
|
export function Main({ children }) {
|
||||||
return (
|
return (
|
||||||
<div data-shell="main" className="flex flex-col flex-grow w-1">
|
<div data-shell="main" className="flex flex-col flex-grow w-1 flex-shrink-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -357,4 +363,8 @@ export const SectionHeaderAccordionItem = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
|
||||||
|
<hr {...props} className={twMerge("bg-primary/50 my-3", className)} />
|
||||||
|
);
|
||||||
|
|
||||||
export { Header } from "./Header";
|
export { Header } from "./Header";
|
||||||
|
|||||||
@@ -1,33 +1,17 @@
|
|||||||
import { IconPhoto } from "@tabler/icons-react";
|
import { IconAlertHexagon } from "@tabler/icons-react";
|
||||||
import { TbSettings } from "react-icons/tb";
|
import { TbSettings } from "react-icons/tb";
|
||||||
import { useBknd } from "ui/client/BkndProvider";
|
import { useBknd } from "ui/client/BkndProvider";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { Empty } from "ui/components/display/Empty";
|
|
||||||
import { Link } from "ui/components/wouter/Link";
|
import { Link } from "ui/components/wouter/Link";
|
||||||
import { Media } from "ui/elements";
|
import { Media } from "ui/elements";
|
||||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { useLocation } from "wouter";
|
|
||||||
|
|
||||||
export function MediaRoot({ children }) {
|
export function MediaRoot({ children }) {
|
||||||
const { app, config } = useBknd();
|
const { app, config } = useBknd();
|
||||||
const [, navigate] = useLocation();
|
const mediaDisabled = !config.media.enabled;
|
||||||
useBrowserTitle(["Media"]);
|
useBrowserTitle(["Media"]);
|
||||||
|
|
||||||
if (!config.media.enabled) {
|
|
||||||
return (
|
|
||||||
<Empty
|
|
||||||
Icon={IconPhoto}
|
|
||||||
title="Media not enabled"
|
|
||||||
description="Please enable media in the settings to continue."
|
|
||||||
primary={{
|
|
||||||
children: "Manage Settings",
|
|
||||||
onClick: () => navigate(app.getSettingsPath(["media"]))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppShell.Sidebar>
|
<AppShell.Sidebar>
|
||||||
@@ -42,31 +26,22 @@ export function MediaRoot({ children }) {
|
|||||||
</AppShell.SectionHeader>
|
</AppShell.SectionHeader>
|
||||||
<AppShell.Scrollable initialOffset={96}>
|
<AppShell.Scrollable initialOffset={96}>
|
||||||
<div className="flex flex-col flex-grow p-3 gap-3">
|
<div className="flex flex-col flex-grow p-3 gap-3">
|
||||||
{/*<div>
|
|
||||||
<SearchInput placeholder="Search buckets" />
|
|
||||||
</div>*/}
|
|
||||||
<nav className="flex flex-col flex-1 gap-1">
|
<nav className="flex flex-col flex-1 gap-1">
|
||||||
<AppShell.SidebarLink as={Link} href="/media" className="active">
|
<AppShell.SidebarLink
|
||||||
Main Bucket
|
as={Link}
|
||||||
|
href={"/"}
|
||||||
|
className="flex flex-row justify-between"
|
||||||
|
>
|
||||||
|
Main Bucket {mediaDisabled && <IconAlertHexagon className="size-5" />}
|
||||||
|
</AppShell.SidebarLink>
|
||||||
|
<AppShell.SidebarLink as={Link} href={"/settings"}>
|
||||||
|
Settings
|
||||||
</AppShell.SidebarLink>
|
</AppShell.SidebarLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</AppShell.Scrollable>
|
</AppShell.Scrollable>
|
||||||
</AppShell.Sidebar>
|
</AppShell.Sidebar>
|
||||||
<main className="flex flex-col flex-grow">{children}</main>
|
<AppShell.Main>{children}</AppShell.Main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// @todo: add infinite load
|
|
||||||
export function MediaEmpty() {
|
|
||||||
useBrowserTitle(["Media"]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell.Scrollable>
|
|
||||||
<div className="flex flex-1 p-3">
|
|
||||||
<Media.Dropzone />
|
|
||||||
</div>
|
|
||||||
</AppShell.Scrollable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Route } from "wouter";
|
import { 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() {
|
export default function MediaRoutes() {
|
||||||
return (
|
return (
|
||||||
<MediaRoot>
|
<MediaRoot>
|
||||||
<Route path="/" component={MediaEmpty} />
|
<Route path="/" component={MediaIndex} />
|
||||||
|
<Route path="/settings" component={MediaSettings} />
|
||||||
</MediaRoot>
|
</MediaRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
35
app/src/ui/routes/media/media.index.tsx
Normal file
35
app/src/ui/routes/media/media.index.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { IconPhoto } from "@tabler/icons-react";
|
||||||
|
import { useBknd } from "ui/client/BkndProvider";
|
||||||
|
import { Empty } from "ui/components/display/Empty";
|
||||||
|
import { Media } from "ui/elements";
|
||||||
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
|
||||||
|
export function MediaIndex() {
|
||||||
|
const { app, config } = useBknd();
|
||||||
|
const [, navigate] = useLocation();
|
||||||
|
useBrowserTitle(["Media"]);
|
||||||
|
|
||||||
|
if (!config.media.enabled) {
|
||||||
|
return (
|
||||||
|
<Empty
|
||||||
|
Icon={IconPhoto}
|
||||||
|
title="Media not enabled"
|
||||||
|
description="Please enable media in the settings to continue."
|
||||||
|
primary={{
|
||||||
|
children: "Manage Settings",
|
||||||
|
onClick: () => navigate("/settings")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell.Scrollable>
|
||||||
|
<div className="flex flex-1 p-3">
|
||||||
|
<Media.Dropzone />
|
||||||
|
</div>
|
||||||
|
</AppShell.Scrollable>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
app/src/ui/routes/media/media.settings.tsx
Normal file
153
app/src/ui/routes/media/media.settings.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { IconBrandAws, IconCloud, IconServer } from "@tabler/icons-react";
|
||||||
|
import { isDebug } from "core";
|
||||||
|
import { autoFormatString } from "core/utils";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { useBknd } from "ui/client/BkndProvider";
|
||||||
|
import { useBkndMedia } from "ui/client/schema/media/use-bknd-media";
|
||||||
|
import { Button } from "ui/components/buttons/Button";
|
||||||
|
import { Message } from "ui/components/display/Message";
|
||||||
|
import * as Formy from "ui/components/form/Formy";
|
||||||
|
import {
|
||||||
|
AnyOf,
|
||||||
|
Field,
|
||||||
|
Form,
|
||||||
|
FormContextOverride,
|
||||||
|
FormDebug,
|
||||||
|
ObjectField,
|
||||||
|
Subscribe
|
||||||
|
} from "ui/components/form/json-schema-form";
|
||||||
|
import { Media } from "ui/elements";
|
||||||
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
|
|
||||||
|
export function MediaSettings(props) {
|
||||||
|
useBrowserTitle(["Media", "Settings"]);
|
||||||
|
|
||||||
|
const { hasSecrets } = useBknd({ withSecrets: true });
|
||||||
|
if (!hasSecrets) {
|
||||||
|
return <Message.MissingPermission what="Media Settings" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MediaSettingsInternal {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formConfig = {
|
||||||
|
ignoreKeys: ["entity_name", "basepath"],
|
||||||
|
options: { debug: isDebug(), keepEmpty: true }
|
||||||
|
};
|
||||||
|
|
||||||
|
function MediaSettingsInternal() {
|
||||||
|
const { config, schema, actions } = useBkndMedia();
|
||||||
|
|
||||||
|
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>
|
||||||
|
{({ dirty, errors, submitting }) => (
|
||||||
|
<AppShell.SectionHeader
|
||||||
|
right={
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!dirty || errors.length > 0 || submitting}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</AppShell.SectionHeader>
|
||||||
|
)}
|
||||||
|
</Subscribe>
|
||||||
|
<AppShell.Scrollable>
|
||||||
|
<div className="flex flex-col gap-3 p-3">
|
||||||
|
<Field name="enabled" />
|
||||||
|
<div className="flex flex-col gap-3 relative">
|
||||||
|
<Overlay />
|
||||||
|
<Field name="storage.body_max_size" label="Storage Body Max Size" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AppShell.Separator />
|
||||||
|
<div className="flex flex-col gap-3 p-3">
|
||||||
|
<Overlay />
|
||||||
|
<AnyOf.Root path="adapter">
|
||||||
|
<Adapters />
|
||||||
|
</AnyOf.Root>
|
||||||
|
</div>
|
||||||
|
<FormDebug />
|
||||||
|
</AppShell.Scrollable>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icons = [IconBrandAws, IconCloud, IconServer];
|
||||||
|
|
||||||
|
const AdapterIcon = ({ index }: { index: number }) => {
|
||||||
|
const Icon = Icons[index];
|
||||||
|
if (!Icon) return null;
|
||||||
|
return <Icon />;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Adapters() {
|
||||||
|
const ctx = AnyOf.useContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formy.Group>
|
||||||
|
<Formy.Label className="flex flex-row items-center gap-1">
|
||||||
|
<span className="font-bold">Media Adapter:</span>
|
||||||
|
{ctx.selected === null && <span className="opacity-70"> (Choose one)</span>}
|
||||||
|
</Formy.Label>
|
||||||
|
<div className="flex flex-row gap-1 mb-2">
|
||||||
|
{ctx.schemas?.map((schema: any, i) => (
|
||||||
|
<Button
|
||||||
|
key={i}
|
||||||
|
onClick={() => ctx.select(i)}
|
||||||
|
variant={ctx.selected === i ? "primary" : "outline"}
|
||||||
|
className={twMerge(
|
||||||
|
"flex flex-row items-center justify-center gap-3 border",
|
||||||
|
ctx.selected === i && "border-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<AdapterIcon index={i} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start justify-center">
|
||||||
|
<span>{autoFormatString(schema.title)}</span>
|
||||||
|
{schema.description && (
|
||||||
|
<span className="text-xs opacity-70 text-left">{schema.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{ctx.selected !== null && (
|
||||||
|
<Formy.Group as="fieldset" error={ctx.errors.length > 0}>
|
||||||
|
<Formy.Label as="legend" className="font-mono px-2">
|
||||||
|
{autoFormatString(ctx.selectedSchema!.title!)}
|
||||||
|
</Formy.Label>
|
||||||
|
<FormContextOverride schema={ctx.selectedSchema} path={ctx.path} overrideData>
|
||||||
|
<Field name="type" hidden />
|
||||||
|
<ObjectField path="config" wrapperProps={{ label: false, wrapper: "group" }} />
|
||||||
|
</FormContextOverride>
|
||||||
|
</Formy.Group>
|
||||||
|
)}
|
||||||
|
</Formy.Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Overlay = () => (
|
||||||
|
<Subscribe>
|
||||||
|
{({ data }) =>
|
||||||
|
!data.enabled && (
|
||||||
|
<div className="absolute w-full h-full z-50 bg-background opacity-70 pointer-events-none" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Subscribe>
|
||||||
|
);
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test";
|
import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test";
|
||||||
import JsonSchemaFormReactTest from "ui/routes/test/tests/json-schema-form-react-test";
|
import JsonSchemaFormReactTest from "ui/routes/test/tests/json-schema-form-react-test";
|
||||||
|
|
||||||
|
import FormyTest from "ui/routes/test/tests/formy-test";
|
||||||
import SwaggerTest from "ui/routes/test/tests/swagger-test";
|
import SwaggerTest from "ui/routes/test/tests/swagger-test";
|
||||||
import SWRAndAPI from "ui/routes/test/tests/swr-and-api";
|
import SWRAndAPI from "ui/routes/test/tests/swr-and-api";
|
||||||
import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api";
|
import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api";
|
||||||
@@ -15,6 +17,7 @@ import DropdownTest from "./tests/dropdown-test";
|
|||||||
import DropzoneElementTest from "./tests/dropzone-element-test";
|
import DropzoneElementTest from "./tests/dropzone-element-test";
|
||||||
import EntityFieldsForm from "./tests/entity-fields-form";
|
import EntityFieldsForm from "./tests/entity-fields-form";
|
||||||
import FlowsTest from "./tests/flows-test";
|
import FlowsTest from "./tests/flows-test";
|
||||||
|
import JsonSchemaForm3 from "./tests/json-schema-form3";
|
||||||
import JsonFormTest from "./tests/jsonform-test";
|
import JsonFormTest from "./tests/jsonform-test";
|
||||||
import { LiquidJsTest } from "./tests/liquid-js-test";
|
import { LiquidJsTest } from "./tests/liquid-js-test";
|
||||||
import MantineTest from "./tests/mantine-test";
|
import MantineTest from "./tests/mantine-test";
|
||||||
@@ -45,7 +48,9 @@ const tests = {
|
|||||||
SWRAndAPI,
|
SWRAndAPI,
|
||||||
SwrAndDataApi,
|
SwrAndDataApi,
|
||||||
DropzoneElementTest,
|
DropzoneElementTest,
|
||||||
JsonSchemaFormReactTest
|
JsonSchemaFormReactTest,
|
||||||
|
JsonSchemaForm3,
|
||||||
|
FormyTest
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default function TestRoutes() {
|
export default function TestRoutes() {
|
||||||
|
|||||||
17
app/src/ui/routes/test/tests/formy-test.tsx
Normal file
17
app/src/ui/routes/test/tests/formy-test.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import * as Formy from "ui/components/form/Formy";
|
||||||
|
|
||||||
|
export default function FormyTest() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
formy
|
||||||
|
<Formy.Group>
|
||||||
|
<Formy.Label>label</Formy.Label>
|
||||||
|
<Formy.Switch onCheckedChange={console.log} />
|
||||||
|
</Formy.Group>
|
||||||
|
<Formy.Group>
|
||||||
|
<Formy.Label>label</Formy.Label>
|
||||||
|
<Formy.Input />
|
||||||
|
</Formy.Group>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,8 +23,8 @@ export default function JsonSchemaFormReactTest() {
|
|||||||
<>
|
<>
|
||||||
<Form
|
<Form
|
||||||
schema={schema}
|
schema={schema}
|
||||||
onChange={setData}
|
/*onChange={setData}
|
||||||
onSubmit={setData}
|
onSubmit={setData}*/
|
||||||
validator={validator}
|
validator={validator}
|
||||||
validationMode="change"
|
validationMode="change"
|
||||||
>
|
>
|
||||||
|
|||||||
206
app/src/ui/routes/test/tests/json-schema-form3.tsx
Normal file
206
app/src/ui/routes/test/tests/json-schema-form3.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useBknd } from "ui/client/bknd";
|
||||||
|
import { Button } from "ui/components/buttons/Button";
|
||||||
|
import {
|
||||||
|
AnyOf,
|
||||||
|
Field,
|
||||||
|
Form,
|
||||||
|
FormContextOverride,
|
||||||
|
FormDebug,
|
||||||
|
ObjectField
|
||||||
|
} from "ui/components/form/json-schema-form";
|
||||||
|
import { Scrollable } from "ui/layouts/AppShell/AppShell";
|
||||||
|
|
||||||
|
export default function JsonSchemaForm3() {
|
||||||
|
const { schema, config } = useBknd();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Scrollable>
|
||||||
|
<div className="flex flex-col p-3">
|
||||||
|
{/*<Form
|
||||||
|
schema={{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", default: "Peter" },
|
||||||
|
age: { type: "number" },
|
||||||
|
gender: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["male", "female", "uni"]
|
||||||
|
},
|
||||||
|
deep: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
nested: { type: "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["age"]
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<Field name="name" />
|
||||||
|
<Field name="age" />
|
||||||
|
<Field name="gender" />
|
||||||
|
<Field name="deep" />
|
||||||
|
</Form>*/}
|
||||||
|
|
||||||
|
{/*<Form
|
||||||
|
schema={{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
bla: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
age: { type: "number" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
start: { type: "string", enum: ["a", "b", "c"] },
|
||||||
|
end: { type: "number" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AutoForm />
|
||||||
|
</Form>*/}
|
||||||
|
|
||||||
|
{/*<Form
|
||||||
|
schema={{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: "string"
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
initialValues={{ tags: ["a", "b", "c"] }}
|
||||||
|
>
|
||||||
|
<AutoForm />
|
||||||
|
</Form>*/}
|
||||||
|
<Form
|
||||||
|
schema={{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: "string"
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
method: {
|
||||||
|
type: "array",
|
||||||
|
uniqueItems: true,
|
||||||
|
items: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["GET", "POST", "PUT", "DELETE"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
initialValues={{ tags: [0, 1] }}
|
||||||
|
options={{ debug: true }}
|
||||||
|
>
|
||||||
|
<Field name="" />
|
||||||
|
<FormDebug />
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{/*<Form
|
||||||
|
schema={{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: "string"
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: "string", title: "String" },
|
||||||
|
{ type: "number", title: "Number" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
initialValues={{ tags: [0, 1] }}
|
||||||
|
/>*/}
|
||||||
|
|
||||||
|
{/*<CustomMediaForm />*/}
|
||||||
|
{/*<Form schema={schema.media} initialValues={config.media} />*/}
|
||||||
|
|
||||||
|
{/*<Form
|
||||||
|
schema={removeKeyRecursively(schema.media, "pattern") as any}
|
||||||
|
initialValues={config.media}
|
||||||
|
>
|
||||||
|
<AutoForm />
|
||||||
|
</Form>*/}
|
||||||
|
|
||||||
|
{/*<Form
|
||||||
|
schema={removeKeyRecursively(schema.server, "pattern") as any}
|
||||||
|
initialValues={config.server}
|
||||||
|
>
|
||||||
|
<AutoForm />
|
||||||
|
</Form>*/}
|
||||||
|
</div>
|
||||||
|
</Scrollable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomMediaForm() {
|
||||||
|
const { schema, config } = useBknd();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form schema={schema.media} initialValues={config.media} className="flex flex-col gap-3">
|
||||||
|
<Field name="enabled" />
|
||||||
|
<Field name="basepath" />
|
||||||
|
<Field name="entity_name" />
|
||||||
|
<Field name="storage" />
|
||||||
|
<AnyOf.Root path="adapter">
|
||||||
|
<CustomMediaFormAdapter />
|
||||||
|
</AnyOf.Root>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomMediaFormAdapter() {
|
||||||
|
const ctx = AnyOf.useContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-row gap-1">
|
||||||
|
{ctx.schemas?.map((schema: any, i) => (
|
||||||
|
<Button
|
||||||
|
key={i}
|
||||||
|
onClick={() => ctx.select(i)}
|
||||||
|
variant={ctx.selected === i ? "primary" : "default"}
|
||||||
|
>
|
||||||
|
{schema.title ?? `Option ${i + 1}`}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ctx.selected !== null && (
|
||||||
|
<FormContextOverride schema={ctx.selectedSchema} path={ctx.path} overrideData>
|
||||||
|
<Field name="type" hidden />
|
||||||
|
<ObjectField path="config" wrapperProps={{ label: false, wrapper: "group" }} />
|
||||||
|
</FormContextOverride>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -62,11 +62,7 @@ body {
|
|||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
div[data-radix-scroll-area-viewport] > div:first-child {
|
/*div[data-radix-scroll-area-viewport] > div:first-child {}*/
|
||||||
display: block !important;
|
|
||||||
min-width: 100% !important;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* hide calendar icon on inputs */
|
/* hide calendar icon on inputs */
|
||||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
|
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
|
||||||
|
|||||||
Reference in New Issue
Block a user