improved media settings implementation

This commit is contained in:
dswbx
2025-02-05 16:11:53 +01:00
parent 4b3493a6f5
commit 8418231c43
25 changed files with 291 additions and 210 deletions

View File

@@ -54,6 +54,7 @@
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2", "object-path-immutable": "^4.1.2",
"radix-ui": "^1.1.2",
"swr": "^2.2.5" "swr": "^2.2.5"
}, },
"devDependencies": { "devDependencies": {
@@ -64,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",

View File

@@ -81,7 +81,7 @@ 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) { export function autoFormatString(str: string) {

View File

@@ -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."
}) })
) )

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -25,7 +25,8 @@ export const s3AdapterConfig = Type.Object(
}) })
}, },
{ {
title: "S3" title: "AWS S3",
description: "AWS S3 or compatible storage"
} }
); );

View File

@@ -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/50 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"
@@ -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} />}
</> </>
) )

View File

@@ -1,6 +1,9 @@
import { getBrowser } from "core/utils"; import { getBrowser } from "core/utils";
import type { Field } from "data"; import type { Field } from "data";
import { Switch as RadixSwitch } from "radix-ui";
import { import {
type ChangeEventHandler,
type ComponentPropsWithoutRef,
type ElementType, type ElementType,
forwardRef, forwardRef,
useEffect, useEffect,
@@ -26,6 +29,7 @@ export const Group = <E extends ElementType = "div">({
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" && "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
)} )}
@@ -171,6 +175,39 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
} }
); );
export type SwitchValue = boolean | 1 | 0 | "true" | "false" | "on" | "off";
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< export const Select = forwardRef<
HTMLSelectElement, HTMLSelectElement,
React.ComponentProps<"select"> & { React.ComponentProps<"select"> & {

View File

@@ -1,5 +1,7 @@
import type { JsonError } from "json-schema-library";
import type { JSONSchema } from "json-schema-to-ts"; import type { JSONSchema } from "json-schema-to-ts";
import { type ChangeEvent, type ReactNode, createContext, useContext, useState } from "react"; import { type ChangeEvent, type ReactNode, createContext, useContext, useState } from "react";
import { twMerge } from "tailwind-merge";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field"; import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field";
import { FormContextOverride, useFieldContext } from "./Form"; import { FormContextOverride, useFieldContext } from "./Form";
@@ -19,19 +21,18 @@ export type AnyOfFieldContext = {
selected: number | null; selected: number | null;
select: (index: number | null) => void; select: (index: number | null) => void;
options: string[]; options: string[];
errors: JsonError[];
selectSchema: any; selectSchema: any;
}; };
const AnyOfContext = createContext<AnyOfFieldContext>(undefined!); const AnyOfContext = createContext<AnyOfFieldContext>(undefined!);
export const useAnyOfContext = () => { export const useAnyOfContext = () => {
const ctx = useContext(AnyOfContext); return useContext(AnyOfContext);
if (!ctx) throw new Error("useAnyOfContext: no context");
return ctx;
}; };
const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) => { const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) => {
const { setValue, pointer, lib, value, ...ctx } = useFieldContext(path); const { setValue, pointer, lib, value, errors, ...ctx } = useFieldContext(path);
const schema = _schema ?? ctx.schema; const schema = _schema ?? ctx.schema;
if (!schema) return `AnyOfField(${path}): no schema ${pointer}`; if (!schema) return `AnyOfField(${path}): no schema ${pointer}`;
const [matchedIndex, schemas = []] = getMultiSchemaMatched(schema, value); const [matchedIndex, schemas = []] = getMultiSchemaMatched(schema, value);
@@ -40,6 +41,7 @@ const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) =>
const selectSchema = { const selectSchema = {
enum: options enum: options
}; };
//console.log("AnyOf:root", { value, matchedIndex, selected, schema });
const selectedSchema = const selectedSchema =
selected !== null ? (schemas[selected] as Exclude<JSONSchema, boolean>) : undefined; selected !== null ? (schemas[selected] as Exclude<JSONSchema, boolean>) : undefined;
@@ -51,8 +53,19 @@ const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) =>
return ( return (
<AnyOfContext.Provider <AnyOfContext.Provider
value={{ selected, select, options, selectSchema, path, schema, schemas, selectedSchema }} value={{
selected,
select,
options,
selectSchema,
path,
schema,
schemas,
selectedSchema,
errors
}}
> >
{/*<pre>{JSON.stringify({ value, selected, errors: errors.length }, null, 2)}</pre>*/}
{children} {children}
</AnyOfContext.Provider> </AnyOfContext.Provider>
); );
@@ -62,7 +75,7 @@ const Select = () => {
const { selected, select, path, schema, selectSchema } = useAnyOfContext(); const { selected, select, path, schema, selectSchema } = useAnyOfContext();
function handleSelect(e: ChangeEvent<HTMLInputElement>) { function handleSelect(e: ChangeEvent<HTMLInputElement>) {
console.log("selected", e.target.value); //console.log("selected", e.target.value);
const i = e.target.value ? Number(e.target.value) : null; const i = e.target.value ? Number(e.target.value) : null;
select(i); select(i);
} }
@@ -81,11 +94,13 @@ const Select = () => {
}; };
const Field = ({ name, label, ...props }: Partial<FormFieldProps>) => { const Field = ({ name, label, ...props }: Partial<FormFieldProps>) => {
const { selected, selectedSchema, path } = useAnyOfContext(); const { selected, selectedSchema, path, errors } = useAnyOfContext();
if (selected === null) return null; if (selected === null) return null;
return ( return (
<FormContextOverride path={path} schema={selectedSchema} overrideData> <FormContextOverride path={path} schema={selectedSchema} overrideData>
<div className={twMerge(errors.length > 0 && "bg-red-500/10")}>
<FormField key={`${path}_${selected}`} name={""} label={false} {...props} /> <FormField key={`${path}_${selected}`} name={""} label={false} {...props} />
</div>
</FormContextOverride> </FormContextOverride>
); );
}; };

View File

@@ -5,7 +5,7 @@ import { ArrayField } from "./ArrayField";
import { FieldWrapper } from "./FieldWrapper"; import { FieldWrapper } from "./FieldWrapper";
import { useFieldContext } from "./Form"; import { useFieldContext } from "./Form";
import { ObjectField } from "./ObjectField"; import { ObjectField } from "./ObjectField";
import { coerce, isType } from "./utils"; import { coerce, isType, isTypeSchema } from "./utils";
export type FieldProps = { export type FieldProps = {
name: string; name: string;
@@ -18,7 +18,8 @@ export type FieldProps = {
export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }: FieldProps) => { export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }: FieldProps) => {
const { pointer, value, errors, setValue, required, ...ctx } = useFieldContext(name); const { pointer, value, errors, setValue, required, ...ctx } = useFieldContext(name);
const schema = _schema ?? ctx.schema; const schema = _schema ?? ctx.schema;
if (!schema) return `"${name}" (${pointer}) has no schema`; if (!isTypeSchema(schema)) return <Pre>{pointer} has no schema</Pre>;
//console.log("field", name, schema);
if (isType(schema.type, "object")) { if (isType(schema.type, "object")) {
return <ObjectField path={name} schema={schema} />; return <ObjectField path={name} schema={schema} />;
@@ -38,7 +39,7 @@ export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }
const value = coerce(e.target.value, schema as any, { required }); const value = coerce(e.target.value, schema as any, { required });
//console.log("handleChange", pointer, e.target.value, { value }); //console.log("handleChange", pointer, e.target.value, { value });
if (!value && !required) { if (typeof value === "undefined" && !required) {
ctx.deleteValue(pointer); ctx.deleteValue(pointer);
} else { } else {
setValue(pointer, value); setValue(pointer, value);
@@ -58,7 +59,6 @@ export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }
<FieldComponent <FieldComponent
schema={schema} schema={schema}
name={pointer} name={pointer}
placeholder={pointer}
required={required} required={required}
disabled={disabled} disabled={disabled}
value={value} value={value}
@@ -68,6 +68,10 @@ export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }
); );
}; };
export const Pre = ({ children }) => (
<pre className="dark:bg-red-950 bg-red-100 rounded-md px-3 py-1.5">{children}</pre>
);
export const FieldComponent = ({ export const FieldComponent = ({
schema, schema,
...props ...props
@@ -82,12 +86,16 @@ export const FieldComponent = ({
options = schema.enum.map((v, i) => ({ value: i, label: v })); options = schema.enum.map((v, i) => ({ value: i, label: v }));
} }
return <Formy.Select {...(props as any)} options={options} />; return <Formy.Select id={props.name} {...(props as any)} options={options} />;
} }
if (isType(schema.type, ["number", "integer"])) { if (isType(schema.type, ["number", "integer"])) {
return <Formy.Input type="number" {...props} value={props.value ?? ""} />; return <Formy.Input type="number" id={props.name} {...props} value={props.value ?? ""} />;
} }
return <Formy.Input {...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 ?? ""} />;
}; };

View File

@@ -43,7 +43,7 @@ export function FieldWrapper({
as={wrapper === "fieldset" ? "fieldset" : "div"} as={wrapper === "fieldset" ? "fieldset" : "div"}
className={hidden ? "hidden" : "relative"} className={hidden ? "hidden" : "relative"}
> >
<div className="absolute right-0 top-0"> {/*<div className="absolute right-0 top-0">
<Popover> <Popover>
<Popover.Target> <Popover.Target>
<IconButton Icon={IconBug} size="xs" className="opacity-30" /> <IconButton Icon={IconBug} size="xs" className="opacity-30" />
@@ -56,11 +56,11 @@ export function FieldWrapper({
/> />
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
</div> </div>*/}
{label && ( {label && (
<Formy.Label as={wrapper === "fieldset" ? "legend" : "label"}> <Formy.Label as={wrapper === "fieldset" ? "legend" : "label"} htmlFor={pointer}>
{label} {required ? "*" : ""} {label} {required && <span className="font-medium opacity-30">*</span>}
</Formy.Label> </Formy.Label>
)} )}
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">

View File

@@ -15,7 +15,7 @@ import {
useState useState
} from "react"; } from "react";
import { Field } from "./Field"; import { Field } from "./Field";
import { isRequired, normalizePath, prefixPointer } from "./utils"; import { isRequired, normalizePath, omitSchema, prefixPointer } from "./utils";
type JSONSchema = Exclude<$JSONSchema, boolean>; type JSONSchema = Exclude<$JSONSchema, boolean>;
@@ -27,7 +27,10 @@ export type FormProps<
validateOn?: "change" | "submit"; validateOn?: "change" | "submit";
initialValues?: Partial<Data>; initialValues?: Partial<Data>;
initialOpts?: LibTemplateOptions; initialOpts?: LibTemplateOptions;
ignoreKeys?: string[];
onChange?: (data: Partial<Data>, name: string, value: any) => void; 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; hiddenSubmit?: boolean;
}; };
@@ -38,6 +41,7 @@ export type FormContext<Data> = {
deleteValue: (pointer: string) => void; deleteValue: (pointer: string) => void;
errors: JsonError[]; errors: JsonError[];
dirty: boolean; dirty: boolean;
submitting: boolean;
schema: JSONSchema; schema: JSONSchema;
lib: Draft2019; lib: Draft2019;
}; };
@@ -48,28 +52,49 @@ export function Form<
Schema extends JSONSchema = JSONSchema, Schema extends JSONSchema = JSONSchema,
Data = Schema extends JSONSchema ? FromSchema<JSONSchema> : any Data = Schema extends JSONSchema ? FromSchema<JSONSchema> : any
>({ >({
schema, schema: _schema,
initialValues: _initialValues, initialValues: _initialValues,
initialOpts, initialOpts,
children, children,
onChange, onChange,
onSubmit,
onInvalidSubmit,
validateOn = "submit", validateOn = "submit",
hiddenSubmit = true, hiddenSubmit = true,
ignoreKeys = [],
...props ...props
}: FormProps<Schema, Data>) { }: FormProps<Schema, Data>) {
const [schema, initial] = omitSchema(_schema, ignoreKeys, _initialValues);
const lib = new Draft2019(schema); const lib = new Draft2019(schema);
const initialValues = _initialValues ?? lib.getTemplate(undefined, schema, initialOpts); const initialValues = initial ?? lib.getTemplate(undefined, schema, initialOpts);
const [data, setData] = useState<Partial<Data>>(initialValues); const [data, setData] = useState<Partial<Data>>(initialValues);
const [dirty, setDirty] = useState<boolean>(false); const [dirty, setDirty] = useState<boolean>(false);
const formRef = useRef<HTMLFormElement | null>(null);
const [errors, setErrors] = useState<JsonError[]>([]); const [errors, setErrors] = useState<JsonError[]>([]);
const [submitting, setSubmitting] = useState<boolean>(false);
const formRef = useRef<HTMLFormElement | null>(null);
async function handleChange(e: FormEvent<HTMLFormElement>) {} // @ts-ignore
async function handleSubmit(e: FormEvent<HTMLFormElement>) { async function handleSubmit(e: FormEvent<HTMLFormElement>) {
if (onSubmit) {
e.preventDefault(); 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; return false;
} }
}
function setValue(pointer: string, value: any) { function setValue(pointer: string, value: any) {
const normalized = normalizePath(pointer); const normalized = normalizePath(pointer);
@@ -99,6 +124,8 @@ export function Form<
if (validateOn === "change") { if (validateOn === "change") {
validate(); validate();
} else if (errors.length > 0) {
validate();
} }
}, [data]); }, [data]);
@@ -113,6 +140,7 @@ export function Form<
const context = { const context = {
data: data ?? {}, data: data ?? {},
dirty, dirty,
submitting,
setData, setData,
setValue, setValue,
deleteValue, deleteValue,
@@ -123,8 +151,7 @@ export function Form<
//console.log("context", context); //console.log("context", context);
return ( return (
<> <form {...props} ref={formRef} onSubmit={handleSubmit}>
<form {...props} ref={formRef} onChange={handleChange} onSubmit={handleSubmit}>
<FormContext.Provider value={context}> <FormContext.Provider value={context}>
{children ? children : <Field name="" />} {children ? children : <Field name="" />}
</FormContext.Provider> </FormContext.Provider>
@@ -134,9 +161,6 @@ export function Form<
</button> </button>
)} )}
</form> </form>
<pre>{JSON.stringify(data, null, 2)}</pre>
<pre>{JSON.stringify(errors, null, 2)}</pre>
</>
); );
} }

View File

@@ -1,3 +1,4 @@
import type { JsonError } from "json-schema-library";
import type { JSONSchema } from "json-schema-to-ts"; import type { JSONSchema } from "json-schema-to-ts";
import { AnyOfField } from "./AnyOfField"; import { AnyOfField } from "./AnyOfField";
import { Field } from "./Field"; import { Field } from "./Field";
@@ -17,7 +18,7 @@ export const ObjectField = ({
label: _label, label: _label,
wrapperProps = {} wrapperProps = {}
}: ObjectFieldProps) => { }: ObjectFieldProps) => {
const { errors, ...ctx } = useFieldContext(path); const ctx = useFieldContext(path);
const schema = _schema ?? ctx.schema; const schema = _schema ?? ctx.schema;
if (!schema) return "ObjectField: no schema"; if (!schema) return "ObjectField: no schema";
const properties = schema.properties ?? {}; const properties = schema.properties ?? {};
@@ -25,8 +26,8 @@ export const ObjectField = ({
return ( return (
<FieldWrapper <FieldWrapper
pointer={path} pointer={path}
errors={errors} errors={ctx.errors}
schema={schema} schema={{ ...schema, description: undefined }}
wrapper="fieldset" wrapper="fieldset"
{...wrapperProps} {...wrapperProps}
> >

View File

@@ -2,7 +2,7 @@ import { autoFormatString } from "core/utils";
import { Draft2019, type JsonSchema } from "json-schema-library"; import { Draft2019, type JsonSchema } from "json-schema-library";
import type { JSONSchema } from "json-schema-to-ts"; import type { JSONSchema } from "json-schema-to-ts";
import type { JSONSchemaType } from "json-schema-to-ts/lib/types/definitions/jsonSchema"; import type { JSONSchemaType } from "json-schema-to-ts/lib/types/definitions/jsonSchema";
import { set } from "lodash-es"; import { omit, set } from "lodash-es";
import type { FormEvent } from "react"; import type { FormEvent } from "react";
export function getFormTarget(e: FormEvent<HTMLFormElement>) { export function getFormTarget(e: FormEvent<HTMLFormElement>) {
@@ -94,7 +94,7 @@ export function coerce(
case "number": case "number":
return Number(value); return Number(value);
case "boolean": case "boolean":
return ["true", "1", 1, "on"].includes(value); return ["true", "1", 1, "on", true].includes(value);
case "null": case "null":
return null; return null;
} }
@@ -154,7 +154,7 @@ export function isRequired(pointer: string, schema: JSONSchema, data?: any) {
return !!required; return !!required;
} }
type TType = JSONSchemaType | JSONSchemaType[] | readonly JSONSchemaType[] | undefined; type TType = JSONSchemaType | JSONSchemaType[] | readonly JSONSchemaType[] | string | undefined;
export function isType(_type: TType, _compare: TType) { export function isType(_type: TType, _compare: TType) {
if (!_type || !_compare) return false; if (!_type || !_compare) return false;
const type = Array.isArray(_type) ? _type : [_type]; const type = Array.isArray(_type) ? _type : [_type];
@@ -200,3 +200,26 @@ export function removeKeyRecursively<Given extends object>(obj: Given, keyToRemo
} }
return obj; 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");
}

View File

@@ -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";

View File

@@ -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>
);

View File

@@ -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}
/>
));
*/

View File

@@ -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";

View File

@@ -56,7 +56,7 @@ export function MediaRoot({ children }) {
</div> </div>
</AppShell.Scrollable> </AppShell.Scrollable>
</AppShell.Sidebar> </AppShell.Sidebar>
<main className="flex flex-col flex-grow">{children}</main> <AppShell.Main>{children}</AppShell.Main>
</> </>
); );
} }

View File

@@ -1,3 +1,11 @@
import { IconBrandAws, IconCloud, IconServer } from "@tabler/icons-react";
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 { import {
AnyOf, AnyOf,
Field, Field,
@@ -6,17 +14,6 @@ import {
ObjectField, ObjectField,
Subscribe Subscribe
} from "ui/components/form/json-schema-form"; } from "ui/components/form/json-schema-form";
import { useState } from "react";
import { useBknd } from "ui/client/BkndProvider";
import { useBkndMedia } from "ui/client/schema/media/use-bknd-media";
import { Button } from "ui/components/buttons/Button";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { Message } from "ui/components/display/Message";
import * as Formy from "ui/components/form/Formy";
import type { ValueError } from "@sinclair/typebox/value";
import { type TSchema, Value } from "core/utils";
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";
@@ -32,19 +29,33 @@ export function MediaSettings(props) {
return <MediaSettingsInternal {...props} />; return <MediaSettingsInternal {...props} />;
} }
const ignore = ["entity_name", "basepath"];
function MediaSettingsInternal() { function MediaSettingsInternal() {
const { config, schema } = useBkndMedia(); const { config, schema } = useBkndMedia();
const [data, setData] = useState<any>(config);
console.log("data", data); async function onSubmit(data: any) {
console.log("submit", data);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
return ( return (
<> <>
<Form schema={schema} onChange={setData} initialValues={config as any}> <Form
schema={schema}
initialValues={config as any}
ignoreKeys={ignore}
onSubmit={onSubmit}
noValidate
>
<Subscribe> <Subscribe>
{({ dirty }) => ( {({ dirty, errors, submitting }) => (
<AppShell.SectionHeader <AppShell.SectionHeader
right={ right={
<Button variant="primary" disabled={!dirty /* || submitting*/}> <Button
variant="primary"
type="submit"
disabled={!dirty || errors.length > 0 || submitting}
>
Update Update
</Button> </Button>
} }
@@ -57,52 +68,89 @@ function MediaSettingsInternal() {
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3">
<Field name="enabled" /> <Field name="enabled" />
<div className="flex flex-col gap-3 relative"> <div className="flex flex-col gap-3 relative">
<Overlay visible={!data.enabled} /> <Overlay />
<Field name="entity_name" />
<Field name="storage.body_max_size" label="Storage Body Max Size" /> <Field name="storage.body_max_size" label="Storage Body Max Size" />
</div> </div>
</div> </div>
<div className="flex flex-col gap-3 p-3 mt-3 border-t border-muted"> <AppShell.Separator />
<Overlay visible={!data.enabled} /> <div className="flex flex-col gap-3 p-3">
<Overlay />
<AnyOf.Root path="adapter"> <AnyOf.Root path="adapter">
<Adapters /> <Adapters />
</AnyOf.Root> </AnyOf.Root>
</div> </div>
<JsonViewer json={JSON.parse(JSON.stringify(data))} expand={999} /> {/*<Subscribe>
{({ data, errors }) => (
<JsonViewer json={JSON.parse(JSON.stringify({ data, errors }))} expand={999} />
)}
</Subscribe>*/}
</AppShell.Scrollable> </AppShell.Scrollable>
</Form> </Form>
</> </>
); );
} }
const Icons = [IconBrandAws, IconCloud, IconServer];
const AdapterIcon = ({ index }: { index: number }) => {
const Icon = Icons[index];
if (!Icon) return null;
return <Icon />;
};
function Adapters() { function Adapters() {
const ctx = AnyOf.useContext(); const ctx = AnyOf.useContext();
return ( return (
<> <Formy.Group>
<div className="flex flex-row gap-1"> <Formy.Label className="flex flex-row items-center gap-1">
<span className="font-bold">Media Adapter:</span>
{!ctx.selected && <span className="opacity-70"> (Choose one)</span>}
</Formy.Label>
<div className="flex flex-row gap-1 mb-2">
{ctx.schemas?.map((schema: any, i) => ( {ctx.schemas?.map((schema: any, i) => (
<Button <Button
key={i} key={i}
onClick={() => ctx.select(i)} onClick={() => ctx.select(i)}
variant={ctx.selected === i ? "primary" : "default"} variant={ctx.selected === i ? "primary" : "outline"}
className={twMerge(
"flex flex-row items-center justify-center gap-3 border",
ctx.selected === i && "border-primary"
)}
> >
{schema.title ?? `Option ${i + 1}`} <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> </Button>
))} ))}
</div> </div>
{ctx.selected !== null && ( {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> <FormContextOverride schema={ctx.selectedSchema} path={ctx.path} overrideData>
<Field name="type" hidden /> <Field name="type" hidden />
<ObjectField path="config" wrapperProps={{ label: false, wrapper: "group" }} /> <ObjectField path="config" wrapperProps={{ label: false, wrapper: "group" }} />
</FormContextOverride> </FormContextOverride>
</Formy.Group>
)} )}
</> </Formy.Group>
); );
} }
const Overlay = ({ visible = false }) => const Overlay = () => (
visible && ( <Subscribe>
{({ data }) =>
!data.enabled && (
<div className="absolute w-full h-full z-50 bg-background opacity-70 pointer-events-none" /> <div className="absolute w-full h-full z-50 bg-background opacity-70 pointer-events-none" />
)
}
</Subscribe>
); );

View File

@@ -1,6 +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";
@@ -48,7 +49,8 @@ const tests = {
SwrAndDataApi, SwrAndDataApi,
DropzoneElementTest, DropzoneElementTest,
JsonSchemaFormReactTest, JsonSchemaFormReactTest,
JsonSchemaForm3 JsonSchemaForm3,
FormyTest
} as const; } as const;
export default function TestRoutes() { export default function TestRoutes() {

View 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>
);
}

View File

@@ -138,7 +138,7 @@ export default function JsonSchemaForm3() {
initialValues={{ tags: [0, 1] }} initialValues={{ tags: [0, 1] }}
/>*/} />*/}
<CustomMediaForm /> {/*<CustomMediaForm />*/}
<Form schema={schema.media} initialValues={config.media} /> <Form schema={schema.media} initialValues={config.media} />
{/*<Form {/*<Form

View File

@@ -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,

BIN
bun.lockb

Binary file not shown.