form rerenders optimized

This commit is contained in:
dswbx
2025-02-07 16:11:21 +01:00
parent 02e7e1ca95
commit 324d641410
15 changed files with 546 additions and 339 deletions

View File

@@ -189,7 +189,7 @@ export const Switch = forwardRef<
>(({ type, ...props }, ref) => { >(({ type, ...props }, ref) => {
return ( return (
<RadixSwitch.Root <RadixSwitch.Root
className="relative h-7 w-12 p-[2px] cursor-pointer rounded-full bg-muted/50 border border-muted outline-none data-[state=checked]:bg-primary appearance-none transition-colors hover:bg-muted/80" className="relative h-7 w-12 p-[2px] cursor-pointer rounded-full bg-muted border border-primary/10 outline-none data-[state=checked]:bg-primary/75 appearance-none transition-colors hover:bg-muted/80"
onCheckedChange={(bool) => { onCheckedChange={(bool) => {
props.onChange?.({ target: { value: bool } }); props.onChange?.({ target: { value: bool } });
}} }}
@@ -229,7 +229,7 @@ export const Select = forwardRef<
<> <>
{!props.required && <option value="" />} {!props.required && <option value="" />}
{options {options
.map((o, i) => { .map((o) => {
if (typeof o !== "object") { if (typeof o !== "object") {
return { value: o, label: String(o) }; return { value: o, label: String(o) };
} }
@@ -246,7 +246,10 @@ export const Select = forwardRef<
)} )}
</select> </select>
{!props.multiple && ( {!props.multiple && (
<TbChevronDown className="absolute right-3 top-0 bottom-0 h-full opacity-70" size={18} /> <TbChevronDown
className="absolute right-3 top-0 bottom-0 h-full opacity-70 pointer-events-none"
size={18}
/>
)} )}
</div> </div>
)); ));

View File

@@ -1,28 +1,29 @@
import type { JsonError } from "json-schema-library"; import { atom, useAtom } from "jotai";
import type { JSONSchema } from "json-schema-to-ts"; import type { JsonError, JsonSchema } from "json-schema-library";
import { type ChangeEvent, type ReactNode, createContext, useContext, useState } from "react"; import { type ChangeEvent, type ReactNode, createContext, useContext, useMemo } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event";
import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field"; import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field";
import { FormContextOverride, useFieldContext } from "./Form"; import { FormContextOverride, useDerivedFieldContext } from "./Form";
import { getLabel, getMultiSchemaMatched } from "./utils"; import { getLabel, getMultiSchemaMatched } from "./utils";
export type AnyOfFieldRootProps = { export type AnyOfFieldRootProps = {
path?: string; path?: string;
schema?: Exclude<JSONSchema, boolean>; schema?: JsonSchema;
children: ReactNode; children: ReactNode;
}; };
export type AnyOfFieldContext = { export type AnyOfFieldContext = {
path: string; path: string;
schema: Exclude<JSONSchema, boolean>; schema: JsonSchema;
schemas?: JSONSchema[]; schemas?: JsonSchema[];
selectedSchema?: Exclude<JSONSchema, boolean>; selectedSchema?: JsonSchema;
selected: number | null; selected: number | null;
select: (index: number | null) => void; select: (index: number | null) => void;
options: string[]; options: string[];
errors: JsonError[]; errors: JsonError[];
selectSchema: JSONSchema; selectSchema: JsonSchema;
}; };
const AnyOfContext = createContext<AnyOfFieldContext>(undefined!); const AnyOfContext = createContext<AnyOfFieldContext>(undefined!);
@@ -31,61 +32,80 @@ export const useAnyOfContext = () => {
return useContext(AnyOfContext); return useContext(AnyOfContext);
}; };
const selectedAtom = atom<number | null>(null);
const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) => { const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) => {
const { setValue, pointer, lib, value, errors, ...ctx } = useFieldContext(path); const {
const schema = _schema ?? ctx.schema; setValue,
lib,
pointer,
errors,
value: { matchedIndex, schemas },
schema
} = useDerivedFieldContext(path, _schema, (ctx) => {
const [matchedIndex, schemas = []] = getMultiSchemaMatched(ctx.schema, ctx.value);
return { matchedIndex, schemas };
});
if (!schema) return `AnyOfField(${path}): no schema ${pointer}`; if (!schema) return `AnyOfField(${path}): no schema ${pointer}`;
const [matchedIndex, schemas = []] = getMultiSchemaMatched(schema, value); const [_selected, setSelected] = useAtom(selectedAtom);
const [selected, setSelected] = useState<number | null>(matchedIndex > -1 ? matchedIndex : null); const selected = _selected !== null ? _selected : matchedIndex > -1 ? matchedIndex : null;
const select = useEvent((index: number | null) => {
setValue(pointer, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined);
setSelected(index);
});
const context = useMemo(() => {
const options = schemas.map((s, i) => s.title ?? `Option ${i + 1}`); const options = schemas.map((s, i) => s.title ?? `Option ${i + 1}`);
const selectSchema = { const selectSchema = {
type: "string", type: "string",
enum: options enum: options
} satisfies JSONSchema; } satisfies JsonSchema;
//console.log("AnyOf:root", { value, matchedIndex, selected, schema });
const selectedSchema = const selectedSchema = selected !== null ? (schemas[selected] as JsonSchema) : undefined;
selected !== null ? (schemas[selected] as Exclude<JSONSchema, boolean>) : undefined;
function select(index: number | null) { return {
setValue(pointer, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined); options,
setSelected(index); selectSchema,
} selectedSchema,
schema,
schemas,
selected
};
}, [selected]);
return ( return (
<AnyOfContext.Provider <AnyOfContext.Provider
key={selected}
value={{ value={{
selected, ...context,
select, select,
options,
selectSchema,
path, path,
schema,
schemas,
selectedSchema,
errors errors
}} }}
> >
{/*<pre>{JSON.stringify({ value, selected, errors: errors.length }, null, 2)}</pre>*/}
{children} {children}
</AnyOfContext.Provider> </AnyOfContext.Provider>
); );
}; };
const Select = () => { const Select = () => {
const { selected, select, path, schema, selectSchema } = useAnyOfContext(); const { selected, select, path, schema, options, selectSchema } = useAnyOfContext();
function handleSelect(e: ChangeEvent<HTMLInputElement>) { function handleSelect(e: ChangeEvent<HTMLInputElement>) {
//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);
} }
return ( return (
<> <>
<Formy.Label>{getLabel(path, schema)}</Formy.Label> <Formy.Label>
{getLabel(path, schema)} {selected}
</Formy.Label>
<FieldComponent <FieldComponent
schema={selectSchema as any} schema={selectSchema as any}
/* @ts-ignore */
options={options.map((label, value) => ({ label, value }))}
onChange={handleSelect} onChange={handleSelect}
value={selected ?? undefined} value={selected ?? undefined}
className="h-8 py-1" className="h-8 py-1"
@@ -94,11 +114,11 @@ const Select = () => {
); );
}; };
const Field = ({ name, label, ...props }: Partial<FormFieldProps>) => { const Field = ({ name, label, schema, ...props }: Partial<FormFieldProps>) => {
const { selected, selectedSchema, path, errors } = 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 prefix={path} schema={selectedSchema}>
<div className={twMerge(errors.length > 0 && "bg-red-500/10")}> <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> </div>

View File

@@ -1,49 +1,33 @@
import { IconLibraryPlus, IconTrash } from "@tabler/icons-react"; import { IconLibraryPlus, IconTrash } from "@tabler/icons-react";
import type { JSONSchema } from "json-schema-to-ts"; import type { JsonSchema } from "json-schema-library";
import { isEqual } from "lodash-es";
import { memo, useMemo } from "react";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import * as Formy from "ui/components/form/Formy";
import { Dropdown } from "ui/components/overlay/Dropdown"; import { Dropdown } from "ui/components/overlay/Dropdown";
import { useEvent } from "ui/hooks/use-event";
import { FieldComponent } from "./Field"; import { FieldComponent } from "./Field";
import { FieldWrapper } from "./FieldWrapper"; import { FieldWrapper } from "./FieldWrapper";
import { useFieldContext } from "./Form"; import { useDerivedFieldContext, useFormContext, useFormValue } from "./Form";
import { coerce, getMultiSchema, getMultiSchemaMatched } from "./utils"; import { coerce, getMultiSchema, getMultiSchemaMatched } from "./utils";
export const ArrayField = ({ export const ArrayField = ({
path = "", path = "",
schema: _schema schema: _schema
}: { path?: string; schema?: Exclude<JSONSchema, boolean> }) => { }: { path?: string; schema?: JsonSchema }) => {
const { setValue, value, pointer, required, ...ctx } = useFieldContext(path); const { setValue, pointer, required, ...ctx } = useDerivedFieldContext(path, _schema);
const schema = _schema ?? ctx.schema; const schema = _schema ?? ctx.schema;
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`; 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 unique items with enum
if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) { if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) {
return ( return (
<FieldWrapper pointer={path} schema={schema} wrapper="fieldset"> <FieldWrapper name={path} schema={schema} wrapper="fieldset">
<FieldComponent <FieldComponent
required required
name={path}
schema={schema.items} schema={schema.items}
multiple multiple
value={value}
className="h-auto" className="h-auto"
onChange={(e: any) => { onChange={(e: any) => {
// @ts-ignore // @ts-ignore
@@ -56,32 +40,87 @@ export const ArrayField = ({
} }
return ( return (
<FieldWrapper pointer={path} schema={schema} wrapper="fieldset"> <FieldWrapper name={path} schema={schema} wrapper="fieldset">
{value?.map((v, index: number) => { <ArrayIterator name={path}>
const pointer = `${path}/${index}`.replace(/\/+/g, "/"); {({ value }) =>
value?.map((v, index: number) => (
<ArrayItem key={index} path={path} index={index} schema={schema} />
))
}
</ArrayIterator>
<div className="flex flex-row">
<ArrayAdd path={path} schema={schema} />
</div>
</FieldWrapper>
);
};
const ArrayItem = memo(({ path, index, schema }: any) => {
const { value, ...ctx } = useDerivedFieldContext(path, schema, (ctx) => {
return ctx.value?.[index];
});
const pointer = [path, index].join(".");
let subschema = schema.items; let subschema = schema.items;
const itemsMultiSchema = getMultiSchema(schema.items);
if (itemsMultiSchema) { if (itemsMultiSchema) {
const [, , _subschema] = getMultiSchemaMatched(schema.items, v); const [, , _subschema] = getMultiSchemaMatched(schema.items, value);
subschema = _subschema; subschema = _subschema;
} }
const handleUpdate = useEvent((pointer: string, value: any) => {
ctx.setValue(pointer, value);
});
const handleDelete = useEvent((pointer: string) => {
ctx.deleteValue(pointer);
});
const DeleteButton = useMemo(
() => <IconButton Icon={IconTrash} onClick={() => handleDelete(pointer)} size="sm" />,
[pointer]
);
return ( return (
<div key={pointer} className="flex flex-row gap-2"> <div key={pointer} className="flex flex-row gap-2">
<FieldComponent <FieldComponent
name={pointer} name={pointer}
schema={subschema!} schema={subschema!}
value={v} value={value}
onChange={(e) => { onChange={(e) => {
handleUpdate(pointer, coerce(e.target.value, subschema!)); handleUpdate(pointer, coerce(e.target.value, subschema!));
}} }}
className="w-full" className="w-full"
/> />
<IconButton Icon={IconTrash} onClick={handleDelete(pointer)} size="sm" /> {DeleteButton}
</div> </div>
); );
})} }, isEqual);
<div className="flex flex-row">
{itemsMultiSchema ? ( const ArrayIterator = memo(
({ name, children }: any) => {
return children(useFormValue(name));
},
(prev, next) => prev.value.length === next.value.length
);
const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
const {
setValue,
value: { currentIndex },
...ctx
} = useDerivedFieldContext(path, schema, (ctx) => {
return { currentIndex: ctx.value?.length ?? 0 };
});
const itemsMultiSchema = getMultiSchema(schema.items);
function handleAdd(template?: any) {
//const currentIndex = value?.length ?? 0;
const newPointer = `${path}/${currentIndex}`.replace(/\/+/g, "/");
setValue(newPointer, template ?? ctx.lib.getTemplate(undefined, schema!.items));
}
if (itemsMultiSchema) {
return (
<Dropdown <Dropdown
dropdownWrapperProps={{ dropdownWrapperProps={{
className: "min-w-0" className: "min-w-0"
@@ -94,10 +133,8 @@ export const ArrayField = ({
> >
<Button IconLeft={IconLibraryPlus}>Add</Button> <Button IconLeft={IconLibraryPlus}>Add</Button>
</Dropdown> </Dropdown>
) : (
<Button onClick={() => handleAdd()}>Add</Button>
)}
</div>
</FieldWrapper>
); );
}
return <Button onClick={() => handleAdd()}>Add</Button>;
}; };

View File

@@ -1,26 +1,30 @@
import type { JSONSchema } from "json-schema-to-ts"; import type { JsonSchema } from "json-schema-library";
import type { ChangeEvent, ComponentPropsWithoutRef } from "react"; import type { ChangeEvent, ComponentPropsWithoutRef } from "react";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
import { ArrayField } from "./ArrayField"; import { ArrayField } from "./ArrayField";
import { FieldWrapper } from "./FieldWrapper"; import { FieldWrapper } from "./FieldWrapper";
import { useFieldContext } from "./Form"; import { useDerivedFieldContext, useFormValue } from "./Form";
import { ObjectField } from "./ObjectField"; import { ObjectField } from "./ObjectField";
import { coerce, enumToOptions, isType, isTypeSchema } from "./utils"; import { coerce, isType, isTypeSchema } from "./utils";
export type FieldProps = { export type FieldProps = {
name: string; name: string;
schema?: Exclude<JSONSchema, boolean>; schema?: JsonSchema;
onChange?: (e: ChangeEvent<any>) => void; onChange?: (e: ChangeEvent<any>) => void;
label?: string | false; label?: string | false;
hidden?: boolean; hidden?: boolean;
}; };
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, setValue, required, ...ctx } = useDerivedFieldContext(name, _schema);
const schema = _schema ?? ctx.schema; const schema = _schema ?? ctx.schema;
if (!isTypeSchema(schema)) return <Pre>{pointer} has no schema</Pre>; if (!isTypeSchema(schema))
//console.log("field", name, schema); return (
<Pre>
[Field] {pointer} has no schema ({JSON.stringify(schema)})
</Pre>
);
if (isType(schema.type, "object")) { if (isType(schema.type, "object")) {
return <ObjectField path={name} schema={schema} />; return <ObjectField path={name} schema={schema} />;
@@ -31,39 +35,23 @@ export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }
} }
const disabled = schema.readOnly ?? "const" in schema ?? false; const disabled = schema.readOnly ?? "const" in schema ?? false;
//console.log("field", name, disabled, schema, ctx.schema, _schema);
const handleChange = useEvent((e: ChangeEvent<HTMLInputElement>) => { const handleChange = useEvent((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 }); 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) { if (typeof value === "undefined" && !required && ctx.options?.keepEmpty !== true) {
ctx.deleteValue(pointer); ctx.deleteValue(pointer);
} else { } else {
//console.log("setValue", pointer, value);
setValue(pointer, value); setValue(pointer, value);
} }
}); });
return ( return (
<FieldWrapper <FieldWrapper name={name} label={_label} required={required} schema={schema} hidden={hidden}>
pointer={pointer}
label={_label}
required={required}
errors={errors}
schema={schema}
debug={{ value }}
hidden={hidden}
>
<FieldComponent <FieldComponent
schema={schema} schema={schema}
name={pointer} name={name}
required={required} required={required}
disabled={disabled} disabled={disabled}
value={value}
onChange={onChange ?? handleChange} onChange={onChange ?? handleChange}
/> />
</FieldWrapper> </FieldWrapper>
@@ -71,19 +59,25 @@ export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }
}; };
export const Pre = ({ children }) => ( export const Pre = ({ children }) => (
<pre className="dark:bg-red-950 bg-red-100 rounded-md px-3 py-1.5">{children}</pre> <pre className="dark:bg-red-950 bg-red-100 rounded-md px-3 py-1.5 text-wrap whitespace-break-spaces break-all">
{children}
</pre>
); );
export const FieldComponent = ({ export const FieldComponent = ({
schema, schema,
...props ..._props
}: { schema: JSONSchema } & ComponentPropsWithoutRef<"input">) => { }: { schema: JsonSchema } & ComponentPropsWithoutRef<"input">) => {
const { value } = useFormValue(_props.name!);
if (!isTypeSchema(schema)) return null; if (!isTypeSchema(schema)) return null;
const props = {
..._props,
// allow override
value: typeof _props.value !== "undefined" ? _props.value : value
};
if (schema.enum) { if (schema.enum) {
return ( return <Formy.Select id={props.name} options={schema.enum} {...(props as any)} />;
<Formy.Select id={props.name} {...(props as any)} options={enumToOptions(schema.enum)} />
);
} }
if (isType(schema.type, ["number", "integer"])) { if (isType(schema.type, ["number", "integer"])) {
@@ -91,7 +85,7 @@ export const FieldComponent = ({
} }
if (isType(schema.type, "boolean")) { if (isType(schema.type, "boolean")) {
return <Formy.Switch id={props.name} {...(props as any)} checked={props.value as any} />; return <Formy.Switch id={props.name} {...(props as any)} checked={value as any} />;
} }
return <Formy.Input id={props.name} {...props} value={props.value ?? ""} />; return <Formy.Input id={props.name} {...props} value={props.value ?? ""} />;

View File

@@ -1,19 +1,18 @@
import { Popover } from "@mantine/core"; import { Popover } from "@mantine/core";
import { IconBug } from "@tabler/icons-react"; import { IconBug } from "@tabler/icons-react";
import type { JsonError } from "json-schema-library"; import type { JsonSchema } from "json-schema-library";
import type { JSONSchema } from "json-schema-to-ts";
import { Children, type ReactElement, type ReactNode, cloneElement, isValidElement } from "react"; import { Children, type ReactElement, type ReactNode, cloneElement, isValidElement } from "react";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { JsonViewer } from "ui/components/code/JsonViewer"; import { JsonViewer } from "ui/components/code/JsonViewer";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { useFormError } from "ui/components/form/json-schema-form/Form";
import { getLabel } from "./utils"; import { getLabel } from "./utils";
export type FieldwrapperProps = { export type FieldwrapperProps = {
pointer: string; name: string;
label?: string | false; label?: string | false;
required?: boolean; required?: boolean;
errors?: JsonError[]; schema?: JsonSchema;
schema?: Exclude<JSONSchema, boolean>;
debug?: object | boolean; debug?: object | boolean;
wrapper?: "group" | "fieldset"; wrapper?: "group" | "fieldset";
hidden?: boolean; hidden?: boolean;
@@ -21,21 +20,20 @@ export type FieldwrapperProps = {
}; };
export function FieldWrapper({ export function FieldWrapper({
pointer, name,
label: _label, label: _label,
required, required,
errors = [],
schema, schema,
debug, debug,
wrapper, wrapper,
hidden, hidden,
children children
}: FieldwrapperProps) { }: FieldwrapperProps) {
const errors = useFormError(name, { strict: true });
const examples = schema?.examples || []; const examples = schema?.examples || [];
const examplesId = `${pointer}-examples`; const examplesId = `${name}-examples`;
const description = schema?.description; const description = schema?.description;
const label = const label = typeof _label !== "undefined" ? _label : schema ? getLabel(name, schema) : name;
typeof _label !== "undefined" ? _label : schema ? getLabel(pointer, schema) : pointer;
return ( return (
<Formy.Group <Formy.Group
@@ -54,7 +52,7 @@ export function FieldWrapper({
<JsonViewer <JsonViewer
json={{ json={{
...(typeof debug === "object" ? debug : {}), ...(typeof debug === "object" ? debug : {}),
pointer, name,
required, required,
schema, schema,
errors errors
@@ -70,7 +68,7 @@ export function FieldWrapper({
{label && ( {label && (
<Formy.Label <Formy.Label
as={wrapper === "fieldset" ? "legend" : "label"} as={wrapper === "fieldset" ? "legend" : "label"}
htmlFor={pointer} htmlFor={name}
className="self-start" className="self-start"
> >
{label} {required && <span className="font-medium opacity-30">*</span>} {label} {required && <span className="font-medium opacity-30">*</span>}

View File

@@ -1,8 +1,14 @@
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; import {
type PrimitiveAtom,
atom,
getDefaultStore,
useAtom,
useAtomValue,
useSetAtom
} from "jotai";
import { selectAtom } from "jotai/utils"; import { selectAtom } from "jotai/utils";
import { Draft2019, type JsonError } from "json-schema-library"; import { Draft2019, type JsonError, type JsonSchema as LibJsonSchema } from "json-schema-library";
import type { TemplateOptions as LibTemplateOptions } from "json-schema-library/dist/lib/getTemplate"; import type { 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 type { JSONSchema as $JSONSchema, FromSchema } from "json-schema-to-ts";
import { get, isEqual } from "lodash-es"; import { get, isEqual } from "lodash-es";
import * as immutable from "object-path-immutable"; import * as immutable from "object-path-immutable";
@@ -15,13 +21,19 @@ import {
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef
useState
} from "react"; } from "react";
import { JsonViewer } from "ui/components/code/JsonViewer"; import { JsonViewer } from "ui/components/code/JsonViewer";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
import { Field } from "./Field"; import { Field } from "./Field";
import { isRequired, normalizePath, omitSchema, prefixPointer } from "./utils"; import {
isRequired,
normalizePath,
omitSchema,
pathToPointer,
prefixPath,
prefixPointer
} from "./utils";
type JSONSchema = Exclude<$JSONSchema, boolean>; type JSONSchema = Exclude<$JSONSchema, boolean>;
type FormState<Data = any> = { type FormState<Data = any> = {
@@ -31,13 +43,6 @@ type FormState<Data = any> = {
data: Data; data: Data;
}; };
const formStateAtom = atom<FormState>({
dirty: false,
submitting: false,
errors: [] as JsonError[],
data: {} as any
});
export type FormProps< export type FormProps<
Schema extends JSONSchema = JSONSchema, Schema extends JSONSchema = JSONSchema,
Data = Schema extends JSONSchema ? FromSchema<JSONSchema> : any Data = Schema extends JSONSchema ? FromSchema<JSONSchema> : any
@@ -68,9 +73,12 @@ export type FormContext<Data> = {
schema: JSONSchema; schema: JSONSchema;
lib: Draft2019; lib: Draft2019;
options: FormProps["options"]; options: FormProps["options"];
root: string;
_formStateAtom: PrimitiveAtom<FormState<Data>>;
}; };
const FormContext = createContext<FormContext<any>>(undefined!); const FormContext = createContext<FormContext<any>>(undefined!);
FormContext.displayName = "FormContext";
export function Form< export function Form<
Schema extends JSONSchema = JSONSchema, Schema extends JSONSchema = JSONSchema,
@@ -92,20 +100,28 @@ export function Form<
const [schema, initial] = omitSchema(_schema, ignoreKeys, _initialValues); const [schema, initial] = omitSchema(_schema, ignoreKeys, _initialValues);
const lib = useMemo(() => new Draft2019(schema), [JSON.stringify(schema)]); const lib = useMemo(() => new Draft2019(schema), [JSON.stringify(schema)]);
const initialValues = initial ?? lib.getTemplate(undefined, schema, initialOpts); const initialValues = initial ?? lib.getTemplate(undefined, schema, initialOpts);
const [formState, setFormState] = useAtom<FormState<Data>>(formStateAtom); const _formStateAtom = useMemo(() => {
return atom<FormState<Data>>({
dirty: false,
submitting: false,
errors: [] as JsonError[],
data: initialValues
});
}, [initialValues]);
const setFormState = useSetAtom(_formStateAtom);
const formRef = useRef<HTMLFormElement | null>(null); const formRef = useRef<HTMLFormElement | null>(null);
useEffect(() => { useEffect(() => {
console.log("setting data"); if (initialValues) {
setFormState((prev) => ({ ...prev, data: initialValues })); validate();
}, []); }
}, [initialValues]);
// @ts-ignore // @ts-ignore
async function handleSubmit(e: FormEvent<HTMLFormElement>) { async function handleSubmit(e: FormEvent<HTMLFormElement>) {
if (onSubmit) { if (onSubmit) {
e.preventDefault(); e.preventDefault();
setFormState((prev) => ({ ...prev, submitting: true })); setFormState((prev) => ({ ...prev, submitting: true }));
//setSubmitting(true);
try { try {
const { data, errors } = validate(); const { data, errors } = validate();
@@ -119,29 +135,20 @@ export function Form<
console.warn(e); console.warn(e);
} }
setFormState((prev) => ({ ...prev, submitting: false })); setFormState((prev) => ({ ...prev, submitting: false }));
//setSubmitting(false);
return false; return false;
} }
} }
const setValue = useEvent((pointer: string, value: any) => { const setValue = useEvent((pointer: string, value: any) => {
const normalized = normalizePath(pointer); const normalized = normalizePath(pointer);
//console.log("setValue", { pointer, normalized, value });
const key = normalized.substring(2).replace(/\//g, "."); const key = normalized.substring(2).replace(/\//g, ".");
setFormState((state) => { setFormState((state) => {
const prev = state.data; const prev = state.data;
const changed = immutable.set(prev, key, value); const changed = immutable.set(prev, key, value);
onChange?.(changed, key, value); onChange?.(changed, key, value);
//console.log("changed", prev, changed, { key, value });
return { ...state, data: changed }; return { ...state, data: changed };
}); });
/*setData((prev) => { check();
const changed = immutable.set(prev, key, value);
onChange?.(changed, key, value);
//console.log("changed", prev, changed, { key, value });
return changed;
});*/
}); });
const deleteValue = useEvent((pointer: string) => { const deleteValue = useEvent((pointer: string) => {
@@ -151,56 +158,49 @@ export function Form<
const prev = state.data; const prev = state.data;
const changed = immutable.del(prev, key); const changed = immutable.del(prev, key);
onChange?.(changed, key, undefined); onChange?.(changed, key, undefined);
//console.log("changed", prev, changed, { key, value });
return { ...state, data: changed }; return { ...state, data: changed };
}); });
/*setData((prev) => { check();
const changed = immutable.del(prev, key);
onChange?.(changed, key, undefined);
//console.log("changed", prev, changed, { key });
return changed;
});*/
}); });
useEffect(() => { const getCurrentState = useEvent(() => getDefaultStore().get(_formStateAtom));
//setDirty(!isEqual(initialValues, data));
//setFormState((prev => ({ ...prev, dirty: !isEqual(initialValues, data) }))); const check = useEvent(() => {
const state = getCurrentState();
setFormState((prev) => ({ ...prev, dirty: !isEqual(initialValues, state.data) }));
if (validateOn === "change") { if (validateOn === "change") {
validate(); validate();
} else if (formState?.errors?.length > 0) { } else if (state?.errors?.length > 0) {
validate(); validate();
} }
}, [formState?.data]); });
function validate(_data?: Partial<Data>) { const validate = useEvent((_data?: Partial<Data>) => {
const actual = _data ?? formState?.data; const actual = _data ?? getCurrentState()?.data;
const errors = lib.validate(actual, schema); const errors = lib.validate(actual, schema);
//console.log("errors", errors);
setFormState((prev) => ({ ...prev, errors })); setFormState((prev) => ({ ...prev, errors }));
//setErrors(errors);
return { data: actual, errors }; return { data: actual, errors };
} });
const context = useMemo( const context = useMemo(
() => ({ () => ({
_formStateAtom,
setValue, setValue,
deleteValue, deleteValue,
schema, schema,
lib, lib,
options options,
root: ""
}), }),
[] [schema, initialValues]
) as any; ) as any;
//console.log("context", context);
const Component = useMemo(() => {
return children ? children : <Field name="" />;
}, []);
return ( return (
<form {...props} ref={formRef} onSubmit={handleSubmit}> <form {...props} ref={formRef} onSubmit={handleSubmit}>
<FormContext.Provider value={context}>{Component}</FormContext.Provider> <FormContext.Provider value={context}>
{children ? children : <Field name="" />}
</FormContext.Provider>
{hiddenSubmit && ( {hiddenSubmit && (
<button style={{ visibility: "hidden" }} type="submit"> <button style={{ visibility: "hidden" }} type="submit">
Submit Submit
@@ -217,25 +217,21 @@ export function useFormContext() {
export function FormContextOverride({ export function FormContextOverride({
children, children,
overrideData, overrideData,
path, prefix,
...overrides ...overrides
}: Partial<FormContext<any>> & { children: ReactNode; path?: string; overrideData?: boolean }) { }: Partial<FormContext<any>> & { children: ReactNode; prefix?: string; overrideData?: boolean }) {
const ctx = useFormContext(); const ctx = useFormContext();
const additional: Partial<FormContext<any>> = {}; const additional: Partial<FormContext<any>> = {};
// this makes a local schema down the three // this makes a local schema down the three
// especially useful for AnyOf, since it doesn't need to fully validate (e.g. pattern) // especially useful for AnyOf, since it doesn't need to fully validate (e.g. pattern)
if (overrideData && path) { if (prefix) {
const pointer = normalizePath(path); additional.root = prefix;
const value =
pointer === "#/" ? ctx.data : get(ctx.data, pointer.substring(2).replace(/\//g, "."));
additional.data = value;
additional.setValue = (pointer: string, value: any) => { additional.setValue = (pointer: string, value: any) => {
ctx.setValue(prefixPointer(pointer, path), value); ctx.setValue(prefixPointer(pointer, prefix), value);
}; };
additional.deleteValue = (pointer: string) => { additional.deleteValue = (pointer: string) => {
ctx.deleteValue(prefixPointer(pointer, path)); ctx.deleteValue(prefixPointer(pointer, prefix));
}; };
} }
@@ -249,88 +245,128 @@ export function FormContextOverride({
} }
export function useFormValue(name: string) { export function useFormValue(name: string) {
const pointer = normalizePath(name); const { _formStateAtom, root } = useFormContext();
const isRootPointer = pointer === "#/";
const selected = selectAtom( const selected = selectAtom(
formStateAtom, _formStateAtom,
useCallback( useCallback(
(state) => { (state) => {
const data = state.data; const prefixedName = prefixPath(name, root);
console.log("data", data); const pointer = pathToPointer(prefixedName);
return isRootPointer ? data : get(data, pointer.substring(2).replace(/\//g, ".")); return {
value: get(state.data, prefixedName),
errors: state.errors.filter((error) => error.data.pointer.startsWith(pointer))
};
}, },
[pointer] [name]
), ),
isEqual isEqual
); );
return useAtom(selected)[0]; return useAtom(selected)[0];
} }
export function useFieldContext(name: string) { export function useFormError(name: string, opt?: { strict?: boolean }) {
const { lib, schema, errors: formErrors = [], ...rest } = useFormContext(); const { _formStateAtom, root } = useFormContext();
const pointer = normalizePath(name); const selected = selectAtom(
const isRootPointer = pointer === "#/"; _formStateAtom,
//console.log("pointer", pointer); useCallback(
const data = {}; (state) => {
const prefixedName = prefixPath(name, root);
const value = useFormValue(name); const pointer = pathToPointer(prefixedName);
console.log("value", pointer, value); return state.errors.filter((error) => {
//const value = isRootPointer ? data : get(data, pointer.substring(2).replace(/\//g, ".")); return opt?.strict
const errors = useMemo( ? error.data.pointer === pointer
() => formErrors.filter((error) => error.data.pointer.startsWith(pointer)), : error.data.pointer.startsWith(pointer);
});
},
[name] [name]
),
isEqual
); );
const fieldSchema = useMemo( return useAtom(selected)[0];
() => (isRootPointer ? (schema as LibJsonSchema) : lib.getSchema({ pointer, data, schema })), }
[name]
);
const required = false; // isRequired(pointer, schema, data);
const options = useMemo(() => ({}), []);
return useMemo( export function useFormStateSelector<Data = any, Reduced = Data>(
() => ({ selector: (state: FormState<Data>) => Reduced
...rest, ): Reduced {
dirty: false, const { _formStateAtom } = useFormContext();
submitting: false, const selected = selectAtom(_formStateAtom, useCallback(selector, []), isEqual);
options, return useAtom(selected)[0];
lib, }
value,
errors, type SelectorFn<Ctx = any, Refined = any> = (state: Ctx) => Refined;
schema: fieldSchema,
export function useDerivedFieldContext<Data = any, Reduced = undefined>(
path,
_schema?: LibJsonSchema,
deriveFn?: SelectorFn<
FormContext<Data> & {
pointer: string;
required: boolean;
errors: JsonError[];
value: any;
},
Reduced
>
): FormContext<Data> & { value: Reduced; pointer: string; required: boolean; errors: JsonError[] } {
const { _formStateAtom, root, lib, ...ctx } = useFormContext();
const schema = _schema ?? ctx.schema;
const selected = selectAtom(
_formStateAtom,
useCallback(
(state) => {
const pointer = pathToPointer(path);
const prefixedName = prefixPath(path, root);
const prefixedPointer = pathToPointer(prefixedName);
const value = get(state.data, prefixedName);
const errors = state.errors.filter((error) =>
error.data.pointer.startsWith(prefixedPointer)
);
const fieldSchema =
pointer === "#/"
? (schema as LibJsonSchema)
: lib.getSchema({ pointer, data: value, schema });
const required = isRequired(prefixedPointer, schema, state.data);
const context = {
...ctx,
root,
schema: fieldSchema as LibJsonSchema,
pointer, pointer,
required required,
}), errors
[JSON.stringify([value])] };
const derived = deriveFn?.({ ...context, _formStateAtom, lib, value });
return {
...context,
value: derived
};
},
[path, schema ?? {}, root]
),
isEqual
); );
} return {
useFieldContext.displayName = "useFieldContext"; ...useAtomValue(selected),
_formStateAtom,
export function Subscribe({ children }: { children: (ctx: FormContext<any>) => ReactNode }) { lib
const ctx = useFormContext(); } as any;
return children(ctx);
} }
export function FormDebug() { export function Subscribe<Data = any, Refined = Data>({
const { options, data, dirty, errors, submitting } = useFormContext(); children,
if (options?.debug !== true) return null; selector
}: {
return <JsonViewer json={{ dirty, submitting, data, errors }} expand={99} />; children: (state: Refined) => ReactNode;
selector?: SelectorFn<FormState<Data>, Refined>;
}) {
return children(useFormStateSelector(selector ?? ((state) => state as unknown as Refined)));
} }
function useFieldContext2(name: string) { export function FormDebug({ force = false }: { force?: boolean }) {
const ctx = useRef(useFormContext()); const { options } = useFormContext();
const pointer = normalizePath(name); if (options?.debug !== true && force !== true) return null;
const isRootPointer = pointer === "#/"; const ctx = useFormStateSelector((s) => s);
//console.log("pointer", pointer);
const data = {};
const options = useMemo(() => ({}), []);
const required = false;
const value = useFormValue(name); return <JsonViewer json={ctx} expand={99} />;
return { value, options, dirty: false, submitting: false, required, pointer };
}
export function FormDebug2({ name }: any) {
const { ...ctx } = useFieldContext2(name);
return <pre>{JSON.stringify({ ctx })}</pre>;
} }

View File

@@ -1,9 +1,9 @@
import type { JsonError } from "json-schema-library";
import type { JSONSchema } from "json-schema-to-ts"; import type { JSONSchema } from "json-schema-to-ts";
import { isTypeSchema } from "ui/components/form/json-schema-form/utils";
import { AnyOfField } from "./AnyOfField"; import { AnyOfField } from "./AnyOfField";
import { Field } from "./Field"; import { Field } from "./Field";
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper"; import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
import { useFieldContext } from "./Form"; import { useDerivedFieldContext } from "./Form";
export type ObjectFieldProps = { export type ObjectFieldProps = {
path?: string; path?: string;
@@ -18,29 +18,28 @@ export const ObjectField = ({
label: _label, label: _label,
wrapperProps = {} wrapperProps = {}
}: ObjectFieldProps) => { }: ObjectFieldProps) => {
const ctx = useFieldContext(path); const ctx = useDerivedFieldContext(path, _schema);
const schema = _schema ?? ctx.schema; const schema = _schema ?? ctx.schema;
if (!schema) return "ObjectField: no schema"; if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`;
const properties = schema.properties ?? {}; const properties = schema.properties ?? {};
return ( return (
<FieldWrapper <FieldWrapper
pointer={path} name={path}
errors={ctx.errors}
schema={{ ...schema, description: undefined }} schema={{ ...schema, description: undefined }}
wrapper="fieldset" wrapper="fieldset"
{...wrapperProps} {...wrapperProps}
> >
{Object.keys(properties).map((prop) => { {Object.keys(properties).map((prop) => {
const schema = properties[prop]; const schema = properties[prop];
const pointer = `${path}/${prop}`.replace(/\/+/g, "/"); const name = [path, prop].filter(Boolean).join(".");
if (!schema) return; if (typeof schema === "undefined" || typeof schema === "boolean") return;
if (schema.anyOf || schema.oneOf) { if (schema.anyOf || schema.oneOf) {
return <AnyOfField key={pointer} path={pointer} />; return <AnyOfField key={name} path={name} />;
} }
return <Field key={pointer} name={pointer} />; return <Field key={name} name={name} />;
})} })}
</FieldWrapper> </FieldWrapper>
); );

View File

@@ -50,7 +50,7 @@ export function flatten(obj: any, parentKey = "", result: any = {}): any {
// @todo: make sure it's in the right order // @todo: make sure it's in the right order
export function unflatten( export function unflatten(
obj: Record<string, string>, obj: Record<string, string>,
schema: JSONSchema, schema: JsonSchema,
selections?: Record<string, number | undefined> selections?: Record<string, number | undefined>
) { ) {
const result = {}; const result = {};
@@ -78,11 +78,7 @@ export function unflatten(
return result; return result;
} }
export function coerce( export function coerce(value: any, schema: JsonSchema, opts?: { required?: boolean }) {
value: any,
schema: Exclude<JSONSchema, boolean>,
opts?: { required?: boolean }
) {
if (!value && typeof opts?.required === "boolean" && !opts.required) { if (!value && typeof opts?.required === "boolean" && !opts.required) {
return undefined; return undefined;
} }
@@ -121,16 +117,25 @@ export function normalizePath(path: string) {
: `#/${path.replace(/#?\/?/, "").replace(/\./g, "/").replace(/\[/g, "/").replace(/\]/g, "")}`; : `#/${path.replace(/#?\/?/, "").replace(/\./g, "/").replace(/\[/g, "/").replace(/\]/g, "")}`;
} }
export function pathToPointer(path: string) {
return "#/" + (path.includes(".") ? path.split(".").join("/") : path);
}
export function prefixPointer(pointer: string, prefix: string) { export function prefixPointer(pointer: string, prefix: string) {
return pointer.replace("#/", `#/${prefix}/`).replace(/\/\//g, "/"); return pointer.replace("#/", `#/${prefix.length > 0 ? prefix + "/" : ""}`).replace(/\/\//g, "/");
}
export function prefixPath(path: string = "", prefix: string = "") {
const p = path.includes(".") ? path.split(".") : [path];
return [prefix, ...p].filter(Boolean).join(".");
} }
export function getParentPointer(pointer: string) { export function getParentPointer(pointer: string) {
return pointer.substring(0, pointer.lastIndexOf("/")); return pointer.substring(0, pointer.lastIndexOf("/"));
} }
export function isRequired(pointer: string, schema: JSONSchema, data?: any) { export function isRequired(pointer: string, schema: JsonSchema, data?: any) {
if (pointer === "#/") { if (pointer === "#/" || !schema) {
return false; return false;
} }
const lib = new Draft2019(schema as any); const lib = new Draft2019(schema as any);
@@ -144,13 +149,6 @@ export function isRequired(pointer: string, schema: JSONSchema, data?: any) {
const parentSchema = lib.getSchema({ pointer: parentPointer, data }); const parentSchema = lib.getSchema({ pointer: parentPointer, data });
const required = parentSchema?.required?.includes(pointer.split("/").pop()!); const required = parentSchema?.required?.includes(pointer.split("/").pop()!);
/*console.log("isRequired", {
pointer,
parentPointer,
parent: parentSchema ? JSON.parse(JSON.stringify(parentSchema)) : null,
required
});*/
return !!required; return !!required;
} }
@@ -162,13 +160,14 @@ export function isType(_type: TType, _compare: TType) {
return compare.some((t) => type.includes(t)); return compare.some((t) => type.includes(t));
} }
export function getLabel(name: string, schema: JSONSchema) { export function getLabel(name: string, schema: JsonSchema) {
if (typeof schema === "object" && "title" in schema) return schema.title; if (typeof schema === "object" && "title" in schema) return schema.title;
const label = name.includes("/") ? (name.split("/").pop() ?? "") : name; if (!name) return "";
const label = name.includes(".") ? (name.split(".").pop() ?? "") : name;
return autoFormatString(label); return autoFormatString(label);
} }
export function getMultiSchema(schema: JSONSchema): Exclude<JSONSchema, boolean>[] | undefined { export function getMultiSchema(schema: JsonSchema): JsonSchema[] | undefined {
if (!schema || typeof schema !== "object") return; if (!schema || typeof schema !== "object") return;
return (schema.anyOf ?? schema.oneOf) as any; return (schema.anyOf ?? schema.oneOf) as any;
} }
@@ -176,8 +175,9 @@ export function getMultiSchema(schema: JSONSchema): Exclude<JSONSchema, boolean>
export function getMultiSchemaMatched( export function getMultiSchemaMatched(
schema: JsonSchema, schema: JsonSchema,
data: any data: any
): [number, Exclude<JSONSchema, boolean>[], Exclude<JSONSchema, boolean> | undefined] { ): [number, JsonSchema[], JsonSchema | undefined] {
const multiSchema = getMultiSchema(schema); const multiSchema = getMultiSchema(schema);
//console.log("getMultiSchemaMatched", schema, data, multiSchema);
if (!multiSchema) return [-1, [], undefined]; if (!multiSchema) return [-1, [], undefined];
const index = multiSchema.findIndex((subschema) => { const index = multiSchema.findIndex((subschema) => {
const lib = new Draft2019(subschema as any); const lib = new Draft2019(subschema as any);
@@ -220,7 +220,7 @@ export function omitSchema<Given extends JSONSchema>(_schema: Given, keys: strin
return [updated, reducedConfig]; return [updated, reducedConfig];
} }
export function isTypeSchema(schema?: JSONSchema): schema is Exclude<JSONSchema, boolean> { export function isTypeSchema(schema?: JsonSchema): schema is JsonSchema {
return typeof schema === "object" && "type" in schema && !isType(schema.type, "error"); return typeof schema === "object" && "type" in schema && !isType(schema.type, "error");
} }

View File

@@ -12,8 +12,8 @@ import {
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";
import { useEvent } from "ui/hooks/use-event";
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell"; import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
import { useEvent } from "../../hooks/use-event";
export function Root({ children }) { export function Root({ children }) {
return ( return (
@@ -74,8 +74,15 @@ export function Content({ children, center }: { children: React.ReactNode; cente
} }
export function Main({ children }) { export function Main({ children }) {
const { sidebar } = useAppShell();
return ( return (
<div data-shell="main" className="flex flex-col flex-grow w-1 flex-shrink-0"> <div
data-shell="main"
className={twMerge(
"flex flex-col flex-grow w-1 flex-shrink-1",
sidebar.open && "max-w-[calc(100%-350px)]"
)}
>
{children} {children}
</div> </div>
); );

View File

@@ -73,18 +73,15 @@ export function HeaderNavigation() {
<> <>
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center"> <nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
{items.map((item) => ( {items.map((item) => (
<Tooltip <NavLink
key={item.label} key={item.href}
label={item.tooltip} as={Link}
disabled={typeof item.tooltip === "undefined"} href={item.href}
position="bottom" Icon={item.Icon}
disabled={item.disabled}
> >
<div>
<NavLink as={Link} href={item.href} Icon={item.Icon} disabled={item.disabled}>
{item.label} {item.label}
</NavLink> </NavLink>
</div>
</Tooltip>
))} ))}
</nav> </nav>
<nav className="flex md:hidden flex-row items-center"> <nav className="flex md:hidden flex-row items-center">

View File

@@ -48,14 +48,20 @@ function MediaSettingsInternal() {
return ( return (
<> <>
<Form schema={schema} initialValues={config as any} onSubmit={onSubmit} {...formConfig}> <Form schema={schema} initialValues={config as any} onSubmit={onSubmit} {...formConfig}>
<Subscribe> <Subscribe
selector={(state) => ({
dirty: state.dirty,
errors: state.errors.length > 0,
submitting: state.submitting
})}
>
{({ dirty, errors, submitting }) => ( {({ dirty, errors, submitting }) => (
<AppShell.SectionHeader <AppShell.SectionHeader
right={ right={
<Button <Button
variant="primary" variant="primary"
type="submit" type="submit"
disabled={!dirty || errors.length > 0 || submitting} disabled={!dirty || errors || submitting}
> >
Update Update
</Button> </Button>
@@ -132,7 +138,7 @@ function Adapters() {
<Formy.Label as="legend" className="font-mono px-2"> <Formy.Label as="legend" className="font-mono px-2">
{autoFormatString(ctx.selectedSchema!.title!)} {autoFormatString(ctx.selectedSchema!.title!)}
</Formy.Label> </Formy.Label>
<FormContextOverride schema={ctx.selectedSchema} path={ctx.path} overrideData> <FormContextOverride schema={ctx.selectedSchema} prefix={ctx.path}>
<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>
@@ -143,9 +149,9 @@ function Adapters() {
} }
const Overlay = () => ( const Overlay = () => (
<Subscribe> <Subscribe selector={(state) => ({ enabled: state.data.enabled })}>
{({ data }) => {({ enabled }) =>
!data.enabled && ( !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" />
) )
} }

View File

@@ -88,7 +88,7 @@ function TestRoot({ 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

@@ -2,11 +2,11 @@ import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { import {
AnyOf, AnyOf,
AnyOfField,
Field, Field,
Form, Form,
FormContextOverride, FormContextOverride,
FormDebug, FormDebug,
FormDebug2,
ObjectField ObjectField
} from "ui/components/form/json-schema-form"; } from "ui/components/form/json-schema-form";
import { Scrollable } from "ui/layouts/AppShell/AppShell"; import { Scrollable } from "ui/layouts/AppShell/AppShell";
@@ -33,16 +33,108 @@ const schema2 = {
export default function JsonSchemaForm3() { export default function JsonSchemaForm3() {
const { schema, config } = useBknd(); const { schema, config } = useBknd();
config.media.storage.body_max_size = 1;
schema.media.properties.storage.properties.body_max_size.minimum = 0;
return ( return (
<Scrollable> <Scrollable>
<div className="flex flex-col p-3"> <div className="flex flex-col p-3">
<Form schema={schema2} className="flex flex-col gap-3"> {/*<Form
schema={{
type: "object",
properties: {
name: { type: "string", default: "Peter", maxLength: 3 },
age: { type: "number" },
deep: {
type: "object",
properties: {
nested: { type: "string" }
}
}
},
required: ["age"]
}}
initialValues={{ name: "Peter", age: 20, deep: { nested: "hello" } }}
className="flex flex-col gap-3"
validateOn="change"
/>*/}
{/*<Form
schema={{
type: "object",
properties: {
name: { type: "string", default: "Peter", minLength: 3 },
age: { type: "number" },
deep: {
anyOf: [
{
type: "object",
properties: {
nested: { type: "string" }
}
},
{
type: "object",
properties: {
nested2: { type: "string" }
}
}
]
}
},
required: ["age"]
}}
className="flex flex-col gap-3"
validateOn="change"
>
<Field name="" />
<Subscribe2>
{(state) => (
<pre className="text-wrap whitespace-break-spaces break-all">
{JSON.stringify(state, null, 2)}
</pre>
)}
</Subscribe2>
</Form>*/}
{/*<Form
schema={{
type: "object",
properties: {
name: { type: "string", default: "Peter", maxLength: 3 },
age: { type: "number" },
gender: {
type: "string",
enum: ["male", "female", "uni"]
},
deep: {
type: "object",
properties: {
nested: { type: "string" }
}
}
},
required: ["age"]
}}
className="flex flex-col gap-3"
validateOn="change"
>
<div>random thing</div> <div>random thing</div>
<Field name="name" /> <Field name="name" />
<Field name="age" /> <Field name="age" />
<FormDebug /> <FormDebug />
<FormDebug2 name="name" /> <FormDebug2 name="name" />
</Form> <hr />
<Subscribe2
selector={(state) => ({ dirty: state.dirty, submitting: state.submitting })}
>
{(state) => (
<pre className="text-wrap whitespace-break-spaces break-all">
{JSON.stringify(state)}
</pre>
)}
</Subscribe2>
</Form>*/}
{/*<Form {/*<Form
schema={{ schema={{
@@ -114,7 +206,7 @@ export default function JsonSchemaForm3() {
} }
} }
}} }}
initialValues={{ tags: [0, 1] }} initialValues={{ tags: [0, 1], method: ["GET"] }}
options={{ debug: true }} options={{ debug: true }}
> >
<Field name="" /> <Field name="" />
@@ -140,10 +232,15 @@ export default function JsonSchemaForm3() {
} }
}} }}
initialValues={{ tags: [0, 1] }} initialValues={{ tags: [0, 1] }}
/>*/} >
<Field name="" />
<FormDebug force />
</Form>*/}
{/*<CustomMediaForm />*/} <CustomMediaForm />
{/*<Form schema={schema.media} initialValues={config.media} />*/} {/*<Form schema={schema.media} initialValues={config.media} validateOn="change">
<Field name="" />
</Form>*/}
{/*<Form {/*<Form
schema={removeKeyRecursively(schema.media, "pattern") as any} schema={removeKeyRecursively(schema.media, "pattern") as any}
@@ -166,8 +263,16 @@ export default function JsonSchemaForm3() {
function CustomMediaForm() { function CustomMediaForm() {
const { schema, config } = useBknd(); const { schema, config } = useBknd();
config.media.storage.body_max_size = 1;
schema.media.properties.storage.properties.body_max_size.minimum = 0;
return ( return (
<Form schema={schema.media} initialValues={config.media} className="flex flex-col gap-3"> <Form
schema={schema.media}
initialValues={config.media}
className="flex flex-col gap-3"
validateOn="change"
>
<Field name="enabled" /> <Field name="enabled" />
<Field name="basepath" /> <Field name="basepath" />
<Field name="entity_name" /> <Field name="entity_name" />
@@ -175,6 +280,7 @@ function CustomMediaForm() {
<AnyOf.Root path="adapter"> <AnyOf.Root path="adapter">
<CustomMediaFormAdapter /> <CustomMediaFormAdapter />
</AnyOf.Root> </AnyOf.Root>
<FormDebug force />
</Form> </Form>
); );
} }
@@ -197,7 +303,7 @@ function CustomMediaFormAdapter() {
</div> </div>
{ctx.selected !== null && ( {ctx.selected !== null && (
<FormContextOverride schema={ctx.selectedSchema} path={ctx.path} overrideData> <FormContextOverride schema={ctx.selectedSchema} prefix={ctx.path}>
<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>

View File

@@ -34,7 +34,10 @@ 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 {
min-width: auto !important;
display: block !important;
}
/* 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,

View File

@@ -26,6 +26,7 @@ type BkndContextProps = {
}; };
const BkndContextContext = createContext<BkndGlobalContextProps>({} as any); const BkndContextContext = createContext<BkndGlobalContextProps>({} as any);
BkndContextContext.displayName = "BkndContext";
export const BkndContext = ({ export const BkndContext = ({
children, children,