mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 12:37:20 +00:00
public commit
This commit is contained in:
55
app/src/ui/components/form/FloatingSelect/FloatingSelect.tsx
Normal file
55
app/src/ui/components/form/FloatingSelect/FloatingSelect.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { FloatingIndicator, Input, UnstyledButton } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type FloatingSelectProps = {
|
||||
data: string[];
|
||||
description?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function FloatingSelect({ data, label, description }: FloatingSelectProps) {
|
||||
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
||||
const [controlsRefs, setControlsRefs] = useState<Record<string, HTMLButtonElement | null>>({});
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
const setControlRef = (index: number) => (node: HTMLButtonElement) => {
|
||||
controlsRefs[index] = node;
|
||||
setControlsRefs(controlsRefs);
|
||||
};
|
||||
|
||||
const controls = data.map((item, index) => (
|
||||
<button
|
||||
key={item}
|
||||
className={twMerge(
|
||||
"transition-colors duration-100 px-2.5 py-2 leading-none rounded-lg text-md",
|
||||
active === index && "text-white"
|
||||
)}
|
||||
ref={setControlRef(index)}
|
||||
onClick={() => setActive(index)}
|
||||
>
|
||||
<span className="relative z-[1]">{item}</span>
|
||||
</button>
|
||||
));
|
||||
|
||||
return (
|
||||
<Input.Wrapper className="flex flex-col gap-1">
|
||||
{label && (
|
||||
<div className="flex flex-col">
|
||||
<Input.Label>{label}</Input.Label>
|
||||
{description && <Input.Description>{description}</Input.Description>}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-fit bg-primary/5 px-1.5 py-1 rounded-lg" ref={setRootRef}>
|
||||
{controls}
|
||||
|
||||
<FloatingIndicator
|
||||
target={controlsRefs[active]}
|
||||
parent={rootRef}
|
||||
className="bg-primary rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
{/*<Input.Error>Input error</Input.Error>*/}
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
176
app/src/ui/components/form/Formy.tsx
Normal file
176
app/src/ui/components/form/Formy.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Switch } from "@mantine/core";
|
||||
import { getBrowser } from "core/utils";
|
||||
import type { Field } from "data";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
import { IconButton } from "../buttons/IconButton";
|
||||
|
||||
export const Group: React.FC<React.ComponentProps<"div"> & { error?: boolean }> = ({
|
||||
error,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col gap-1.5",
|
||||
|
||||
error && "text-red-500",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
export const formElementFactory = (element: string, props: any) => {
|
||||
switch (element) {
|
||||
case "date":
|
||||
return DateInput;
|
||||
case "boolean":
|
||||
return BooleanInput;
|
||||
case "textarea":
|
||||
return Textarea;
|
||||
default:
|
||||
return Input;
|
||||
}
|
||||
};
|
||||
|
||||
export const Label: React.FC<React.ComponentProps<"label">> = (props) => <label {...props} />;
|
||||
|
||||
export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field }> = ({
|
||||
field,
|
||||
...props
|
||||
}) => {
|
||||
const desc = field.getDescription();
|
||||
return (
|
||||
<Label {...props} title={desc} className="flex flex-row gap-2 items-center">
|
||||
{field.getLabel()}
|
||||
{desc && <TbInfoCircle className="opacity-50" />}
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>((props, ref) => {
|
||||
const disabledOrReadonly = props.disabled || props.readOnly;
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none",
|
||||
disabledOrReadonly && "bg-muted/50 text-primary/50",
|
||||
!disabledOrReadonly &&
|
||||
"focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
|
||||
(props, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
rows={3}
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"bg-muted/40 min-h-11 rounded-md py-2.5 px-4 focus:bg-muted outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const DateInput = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
(props, ref) => {
|
||||
const innerRef = useRef<HTMLInputElement>(null);
|
||||
const browser = getBrowser();
|
||||
useImperativeHandle(ref, () => innerRef.current!);
|
||||
|
||||
const handleClick = useEvent(() => {
|
||||
if (innerRef?.current) {
|
||||
innerRef.current.focus();
|
||||
if (["Safari"].includes(browser)) {
|
||||
innerRef.current.click();
|
||||
} else {
|
||||
innerRef.current.showPicker();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="absolute h-full right-3 top-0 bottom-0 flex items-center">
|
||||
<IconButton Icon={TbCalendar} onClick={handleClick} />
|
||||
</div>
|
||||
<Input
|
||||
{...props}
|
||||
type={props.type ?? "date"}
|
||||
ref={innerRef}
|
||||
className="w-full appearance-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
(props, ref) => {
|
||||
const [checked, setChecked] = useState(Boolean(props.value));
|
||||
|
||||
useEffect(() => {
|
||||
setChecked(Boolean(props.value));
|
||||
}, [props.value]);
|
||||
|
||||
function handleCheck(e) {
|
||||
setChecked(e.target.checked);
|
||||
props.onChange?.(e.target.checked);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<Switch
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
onChange={handleCheck}
|
||||
disabled={props.disabled}
|
||||
id={props.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
/*return (
|
||||
<div className="h-11 flex items-center">
|
||||
<input
|
||||
{...props}
|
||||
type="checkbox"
|
||||
ref={ref}
|
||||
className="outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-all disabled:opacity-70 scale-150 ml-1"
|
||||
checked={checked}
|
||||
onChange={handleCheck}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</div>
|
||||
);*/
|
||||
}
|
||||
);
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, React.ComponentProps<"select">>(
|
||||
(props, ref) => (
|
||||
<div className="flex w-full relative">
|
||||
<select
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
||||
"appearance-none h-11 w-full",
|
||||
"border-r-8 border-r-transparent",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
<TbChevronDown className="absolute right-3 top-0 bottom-0 h-full opacity-70" size={18} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
16
app/src/ui/components/form/SearchInput.tsx
Normal file
16
app/src/ui/components/form/SearchInput.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ElementProps } from "@mantine/core";
|
||||
import { TbSearch } from "react-icons/tb";
|
||||
|
||||
export const SearchInput = (props: ElementProps<"input">) => (
|
||||
<div className="w-full relative shadow-sm">
|
||||
<div className="absolute h-full flex items-center px-3 mt-[0.5px] text-zinc-500">
|
||||
<TbSearch size={18} />
|
||||
</div>
|
||||
<input
|
||||
className="bg-transparent border-muted border rounded-md py-2 pl-10 pr-3 w-full outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all duration-200 ease-in-out"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
24
app/src/ui/components/form/SegmentedControl.tsx
Normal file
24
app/src/ui/components/form/SegmentedControl.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
Input,
|
||||
SegmentedControl as MantineSegmentedControl,
|
||||
type SegmentedControlProps as MantineSegmentedControlProps
|
||||
} from "@mantine/core";
|
||||
|
||||
type SegmentedControlProps = MantineSegmentedControlProps & {
|
||||
label?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function SegmentedControl({ label, description, size, ...props }: SegmentedControlProps) {
|
||||
return (
|
||||
<Input.Wrapper className="relative">
|
||||
{label && (
|
||||
<div className="flex flex-col">
|
||||
<Input.Label size={size}>{label}</Input.Label>
|
||||
{description && <Input.Description size={size}>{description}</Input.Description>}
|
||||
</div>
|
||||
)}
|
||||
<MantineSegmentedControl {...props} size={size} />
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
NumberInput as $NumberInput,
|
||||
type NumberInputProps as $NumberInputProps
|
||||
} from "@mantine/core";
|
||||
import { type FieldValues, type UseControllerProps, useController } from "react-hook-form";
|
||||
|
||||
export type MantineNumberInputProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<$NumberInputProps, "value" | "defaultValue">;
|
||||
|
||||
export function MantineNumberInput<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
...props
|
||||
}: MantineNumberInputProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field },
|
||||
fieldState
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<$NumberInput
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
fieldOnChange(e);
|
||||
onChange?.(e);
|
||||
}}
|
||||
error={fieldState.error?.message}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Radio as $Radio,
|
||||
RadioGroup as $RadioGroup,
|
||||
type RadioGroupProps as $RadioGroupProps,
|
||||
type RadioProps as $RadioProps
|
||||
} from "@mantine/core";
|
||||
import { type FieldValues, type UseControllerProps, useController } from "react-hook-form";
|
||||
|
||||
export type RadioProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<$RadioProps, "value" | "defaultValue">;
|
||||
|
||||
export function MantineRadio<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
...props
|
||||
}: RadioProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field }
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<$Radio
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
fieldOnChange(e);
|
||||
onChange?.(e);
|
||||
}}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type RadioGroupProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<$RadioGroupProps, "value" | "defaultValue">;
|
||||
|
||||
function RadioGroup<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
...props
|
||||
}: RadioGroupProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field },
|
||||
fieldState
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<$RadioGroup
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
fieldOnChange(e);
|
||||
onChange?.(e);
|
||||
}}
|
||||
error={fieldState.error?.message}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
MantineRadio.Group = RadioGroup;
|
||||
MantineRadio.Item = $Radio;
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
SegmentedControl as $SegmentedControl,
|
||||
type SegmentedControlProps as $SegmentedControlProps,
|
||||
Input
|
||||
} from "@mantine/core";
|
||||
import { type FieldValues, type UseControllerProps, useController } from "react-hook-form";
|
||||
|
||||
export type MantineSegmentedControlProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<$SegmentedControlProps, "values" | "defaultValues"> & {
|
||||
label?: string;
|
||||
description?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function MantineSegmentedControl<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
label,
|
||||
size,
|
||||
description,
|
||||
error,
|
||||
...props
|
||||
}: MantineSegmentedControlProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field }
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<Input.Wrapper className="relative">
|
||||
{label && (
|
||||
<div className="flex flex-col">
|
||||
<Input.Label size={size}>{label}</Input.Label>
|
||||
{description && <Input.Description size={size}>{description}</Input.Description>}
|
||||
</div>
|
||||
)}
|
||||
<$SegmentedControl
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
fieldOnChange(e);
|
||||
onChange?.(e);
|
||||
}}
|
||||
size={size}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Select, type SelectProps } from "@mantine/core";
|
||||
import { type FieldValues, type UseControllerProps, useController } from "react-hook-form";
|
||||
|
||||
export type MantineSelectProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<SelectProps, "value" | "defaultValue">;
|
||||
|
||||
// @todo: change is not triggered correctly
|
||||
export function MantineSelect<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
...props
|
||||
}: MantineSelectProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field },
|
||||
fieldState
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={async (e) => {
|
||||
//console.log("change1", name, field.name, e);
|
||||
await fieldOnChange({
|
||||
...new Event("change", { bubbles: true, cancelable: true }),
|
||||
target: {
|
||||
value: e,
|
||||
name: field.name
|
||||
}
|
||||
});
|
||||
// @ts-ignore
|
||||
onChange?.(e);
|
||||
}}
|
||||
error={fieldState.error?.message}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Switch as $Switch, type SwitchProps as $SwitchProps } from "@mantine/core";
|
||||
import { type FieldValues, type UseControllerProps, useController } from "react-hook-form";
|
||||
|
||||
export type SwitchProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<$SwitchProps, "value" | "checked" | "defaultValue">;
|
||||
|
||||
export function MantineSwitch<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
...props
|
||||
}: SwitchProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field },
|
||||
fieldState
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<$Switch
|
||||
value={value}
|
||||
checked={value}
|
||||
onChange={(e) => {
|
||||
fieldOnChange(e);
|
||||
onChange?.(e);
|
||||
}}
|
||||
error={fieldState.error?.message}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
159
app/src/ui/components/form/json-schema/JsonSchemaForm.tsx
Normal file
159
app/src/ui/components/form/json-schema/JsonSchemaForm.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { Schema } from "@cfworker/json-schema";
|
||||
import Form from "@rjsf/core";
|
||||
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||
import { forwardRef, useId, useImperativeHandle, useRef, useState } from "react";
|
||||
//import { JsonSchemaValidator } from "./JsonSchemaValidator";
|
||||
import { fields as Fields } from "./fields";
|
||||
import { templates as Templates } from "./templates";
|
||||
import { widgets as Widgets } from "./widgets";
|
||||
import "./styles.css";
|
||||
import { filterKeys } from "core/utils";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { RJSFTypeboxValidator } from "./typebox/RJSFTypeboxValidator";
|
||||
|
||||
const validator = new RJSFTypeboxValidator();
|
||||
|
||||
// @todo: don't import FormProps, instead, copy it here instead of "any"
|
||||
export type JsonSchemaFormProps = any & {
|
||||
schema: RJSFSchema | Schema;
|
||||
uiSchema?: any;
|
||||
direction?: "horizontal" | "vertical";
|
||||
onChange?: (value: any) => void;
|
||||
};
|
||||
|
||||
export type JsonSchemaFormRef = {
|
||||
formData: () => any;
|
||||
validateForm: () => boolean;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
direction = "vertical",
|
||||
schema,
|
||||
onChange,
|
||||
uiSchema,
|
||||
templates,
|
||||
fields,
|
||||
widgets,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const formRef = useRef<Form<any, RJSFSchema, any>>(null);
|
||||
const id = useId();
|
||||
const [value, setValue] = useState<any>(props.formData);
|
||||
|
||||
const onSubmit = ({ formData }: any, e) => {
|
||||
e.preventDefault();
|
||||
console.log("Data submitted: ", formData);
|
||||
props.onSubmit?.(formData);
|
||||
return false;
|
||||
};
|
||||
const handleChange = ({ formData }: any, e) => {
|
||||
const clean = JSON.parse(JSON.stringify(formData));
|
||||
//console.log("Data changed: ", clean, JSON.stringify(formData, null, 2));
|
||||
onChange?.(clean);
|
||||
setValue(clean);
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
formData: () => value,
|
||||
validateForm: () => formRef.current!.validateForm(),
|
||||
cancel: () => formRef.current!.reset()
|
||||
}),
|
||||
[value]
|
||||
);
|
||||
|
||||
const _uiSchema: UiSchema = {
|
||||
...uiSchema,
|
||||
"ui:globalOptions": {
|
||||
...uiSchema?.["ui:globalOptions"],
|
||||
enableMarkdownInDescription: true
|
||||
},
|
||||
"ui:submitButtonOptions": {
|
||||
norender: true
|
||||
}
|
||||
};
|
||||
const _fields: any = {
|
||||
...Fields,
|
||||
...fields
|
||||
};
|
||||
const _templates: any = {
|
||||
...Templates,
|
||||
...templates
|
||||
};
|
||||
const _widgets: any = {
|
||||
...Widgets,
|
||||
...widgets
|
||||
};
|
||||
//console.log("schema", schema, removeTitleFromSchema(schema));
|
||||
|
||||
return (
|
||||
<Form
|
||||
tagName="div"
|
||||
idSeparator="--"
|
||||
idPrefix={id}
|
||||
{...props}
|
||||
ref={formRef}
|
||||
className={["json-form", direction, className].join(" ")}
|
||||
showErrorList={false}
|
||||
schema={schema as RJSFSchema}
|
||||
fields={_fields}
|
||||
templates={_templates}
|
||||
widgets={_widgets}
|
||||
uiSchema={_uiSchema}
|
||||
onChange={handleChange}
|
||||
onSubmit={onSubmit}
|
||||
validator={validator as any}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
function removeTitleFromSchema(schema: any): any {
|
||||
// Create a deep copy of the schema using lodash
|
||||
const newSchema = cloneDeep(schema);
|
||||
|
||||
function removeTitle(schema: any): void {
|
||||
if (typeof schema !== "object" || schema === null) return;
|
||||
|
||||
// Remove title if present
|
||||
// biome-ignore lint/performance/noDelete: <explanation>
|
||||
delete schema.title;
|
||||
|
||||
// Check nested schemas in anyOf, allOf, and oneOf
|
||||
const nestedKeywords = ["anyOf", "allOf", "oneOf"];
|
||||
nestedKeywords.forEach((keyword) => {
|
||||
if (Array.isArray(schema[keyword])) {
|
||||
schema[keyword].forEach((nestedSchema: any) => {
|
||||
removeTitle(nestedSchema);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Recursively remove title from properties
|
||||
if (schema.properties && typeof schema.properties === "object") {
|
||||
Object.values(schema.properties).forEach((propertySchema: any) => {
|
||||
removeTitle(propertySchema);
|
||||
});
|
||||
}
|
||||
|
||||
// Recursively remove title from items
|
||||
if (schema.items) {
|
||||
if (Array.isArray(schema.items)) {
|
||||
schema.items.forEach((itemSchema: any) => {
|
||||
removeTitle(itemSchema);
|
||||
});
|
||||
} else {
|
||||
removeTitle(schema.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeTitle(newSchema);
|
||||
return newSchema;
|
||||
}
|
||||
121
app/src/ui/components/form/json-schema/JsonSchemaValidator.ts
Normal file
121
app/src/ui/components/form/json-schema/JsonSchemaValidator.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { type OutputUnit, Validator } from "@cfworker/json-schema";
|
||||
import type {
|
||||
CustomValidator,
|
||||
ErrorSchema,
|
||||
ErrorTransformer,
|
||||
FormContextType,
|
||||
RJSFSchema,
|
||||
RJSFValidationError,
|
||||
StrictRJSFSchema,
|
||||
UiSchema,
|
||||
ValidationData,
|
||||
ValidatorType
|
||||
} from "@rjsf/utils";
|
||||
import { toErrorSchema } from "@rjsf/utils";
|
||||
import get from "lodash-es/get";
|
||||
|
||||
function removeUndefinedKeys(obj: any): any {
|
||||
if (!obj) return obj;
|
||||
|
||||
if (typeof obj === "object") {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (obj[key] === undefined) {
|
||||
delete obj[key];
|
||||
} else if (typeof obj[key] === "object") {
|
||||
removeUndefinedKeys(obj[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.filter((item) => item !== undefined);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
function onlyKeepMostSpecific(errors: OutputUnit[]) {
|
||||
const mostSpecific = errors.filter((error) => {
|
||||
return !errors.some((other) => {
|
||||
return error !== other && other.instanceLocation.startsWith(error.instanceLocation);
|
||||
});
|
||||
});
|
||||
return mostSpecific;
|
||||
}
|
||||
|
||||
const debug = true;
|
||||
const validate = true;
|
||||
|
||||
export class JsonSchemaValidator<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
> implements ValidatorType
|
||||
{
|
||||
// @ts-ignore
|
||||
rawValidation<Result extends OutputUnit = OutputUnit>(schema: S, formData?: T) {
|
||||
if (!validate) return { errors: [], validationError: null };
|
||||
|
||||
debug && console.log("JsonSchemaValidator.rawValidation", schema, formData);
|
||||
const validator = new Validator(schema as any);
|
||||
const validation = validator.validate(removeUndefinedKeys(formData));
|
||||
const specificErrors = onlyKeepMostSpecific(validation.errors);
|
||||
|
||||
return { errors: specificErrors, validationError: null as any };
|
||||
}
|
||||
|
||||
validateFormData(
|
||||
formData: T | undefined,
|
||||
schema: S,
|
||||
customValidate?: CustomValidator,
|
||||
transformErrors?: ErrorTransformer,
|
||||
uiSchema?: UiSchema
|
||||
): ValidationData<T> {
|
||||
if (!validate) return { errors: [], errorSchema: {} as any };
|
||||
|
||||
debug &&
|
||||
console.log(
|
||||
"JsonSchemaValidator.validateFormData",
|
||||
formData,
|
||||
schema,
|
||||
customValidate,
|
||||
transformErrors,
|
||||
uiSchema
|
||||
);
|
||||
const { errors } = this.rawValidation(schema, formData);
|
||||
debug && console.log("errors", { errors });
|
||||
|
||||
const transformedErrors = errors
|
||||
//.filter((error) => error.keyword !== "properties")
|
||||
.map((error) => {
|
||||
const schemaLocation = error.keywordLocation.replace(/^#\/?/, "").split("/").join(".");
|
||||
const propertyError = get(schema, schemaLocation);
|
||||
const errorText = `${error.error.replace(/\.$/, "")}${propertyError ? ` "${propertyError}"` : ""}`;
|
||||
//console.log(error, schemaLocation, get(schema, schemaLocation));
|
||||
return {
|
||||
name: error.keyword,
|
||||
message: errorText,
|
||||
property: "." + error.instanceLocation.replace(/^#\/?/, "").split("/").join("."),
|
||||
schemaPath: error.keywordLocation,
|
||||
stack: error.error
|
||||
};
|
||||
});
|
||||
debug && console.log("transformed", transformedErrors);
|
||||
|
||||
return {
|
||||
errors: transformedErrors,
|
||||
errorSchema: toErrorSchema(transformedErrors)
|
||||
} as any;
|
||||
}
|
||||
|
||||
toErrorList(errorSchema?: ErrorSchema<T>, fieldPath?: string[]): RJSFValidationError[] {
|
||||
debug && console.log("JsonSchemaValidator.toErrorList", errorSchema, fieldPath);
|
||||
return [];
|
||||
}
|
||||
|
||||
isValid(schema: S, formData: T | undefined, rootSchema: S): boolean {
|
||||
if (!validate) return true;
|
||||
debug && console.log("JsonSchemaValidator.isValid", schema, formData, rootSchema);
|
||||
return this.rawValidation(schema, formData).errors.length === 0;
|
||||
}
|
||||
}
|
||||
32
app/src/ui/components/form/json-schema/fields/JsonField.tsx
Normal file
32
app/src/ui/components/form/json-schema/fields/JsonField.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { FieldProps } from "@rjsf/utils";
|
||||
import { JsonEditor } from "../../../code/JsonEditor";
|
||||
import { Label } from "../templates/FieldTemplate";
|
||||
|
||||
// @todo: move editor to lazy loading component
|
||||
export default function JsonField({
|
||||
formData,
|
||||
onChange,
|
||||
disabled,
|
||||
readonly,
|
||||
...props
|
||||
}: FieldProps) {
|
||||
const value = JSON.stringify(formData, null, 2);
|
||||
|
||||
function handleChange(data) {
|
||||
try {
|
||||
onChange(JSON.parse(data));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = disabled || readonly;
|
||||
const id = props.idSchema.$id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label label={props.name} id={id} />
|
||||
<JsonEditor value={value} editable={!isDisabled} onChange={handleChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { FieldProps } from "@rjsf/utils";
|
||||
import { LiquidJsEditor } from "../../../code/LiquidJsEditor";
|
||||
import { Label } from "../templates/FieldTemplate";
|
||||
|
||||
// @todo: move editor to lazy loading component
|
||||
export default function LiquidJsField({
|
||||
formData,
|
||||
onChange,
|
||||
disabled,
|
||||
readonly,
|
||||
...props
|
||||
}: FieldProps) {
|
||||
function handleChange(data) {
|
||||
onChange(data);
|
||||
}
|
||||
|
||||
const isDisabled = disabled || readonly;
|
||||
const id = props.idSchema.$id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label label={props.name} id={id} />
|
||||
<LiquidJsEditor value={formData} editable={!isDisabled} onChange={handleChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
ANY_OF_KEY,
|
||||
ERRORS_KEY,
|
||||
type FieldProps,
|
||||
type FormContextType,
|
||||
ONE_OF_KEY,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
TranslatableString,
|
||||
type UiSchema,
|
||||
deepEquals,
|
||||
getDiscriminatorFieldFromSchema,
|
||||
getUiOptions,
|
||||
getWidget,
|
||||
mergeSchemas
|
||||
} from "@rjsf/utils";
|
||||
import get from "lodash-es/get";
|
||||
import isEmpty from "lodash-es/isEmpty";
|
||||
import omit from "lodash-es/omit";
|
||||
import { Component } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Label } from "../templates/FieldTemplate";
|
||||
|
||||
/** Type used for the state of the `AnyOfField` component */
|
||||
type AnyOfFieldState<S extends StrictRJSFSchema = RJSFSchema> = {
|
||||
/** The currently selected option */
|
||||
selectedOption: number;
|
||||
/** The option schemas after retrieving all $refs */
|
||||
retrievedOptions: S[];
|
||||
};
|
||||
|
||||
/** The `AnyOfField` component is used to render a field in the schema that is an `anyOf`, `allOf` or `oneOf`. It tracks
|
||||
* the currently selected option and cleans up any irrelevant data in `formData`.
|
||||
*
|
||||
* @param props - The `FieldProps` for this template
|
||||
*/
|
||||
class MultiSchemaField<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
> extends Component<FieldProps<T, S, F>, AnyOfFieldState<S>> {
|
||||
/** Constructs an `AnyOfField` with the given `props` to initialize the initially selected option in state
|
||||
*
|
||||
* @param props - The `FieldProps` for this template
|
||||
*/
|
||||
constructor(props: FieldProps<T, S, F>) {
|
||||
super(props);
|
||||
|
||||
const {
|
||||
formData,
|
||||
options,
|
||||
registry: { schemaUtils }
|
||||
} = this.props;
|
||||
// cache the retrieved options in state in case they have $refs to save doing it later
|
||||
//console.log("multi schema", { formData, options, props });
|
||||
const retrievedOptions = options.map((opt: S) => schemaUtils.retrieveSchema(opt, formData));
|
||||
|
||||
this.state = {
|
||||
retrievedOptions,
|
||||
selectedOption: this.getMatchingOption(0, formData, retrievedOptions)
|
||||
};
|
||||
}
|
||||
|
||||
/** React lifecycle method that is called when the props and/or state for this component is updated. It recomputes the
|
||||
* currently selected option based on the overall `formData`
|
||||
*
|
||||
* @param prevProps - The previous `FieldProps` for this template
|
||||
* @param prevState - The previous `AnyOfFieldState` for this template
|
||||
*/
|
||||
override componentDidUpdate(
|
||||
prevProps: Readonly<FieldProps<T, S, F>>,
|
||||
prevState: Readonly<AnyOfFieldState>
|
||||
) {
|
||||
const { formData, options, idSchema } = this.props;
|
||||
const { selectedOption } = this.state;
|
||||
let newState = this.state;
|
||||
if (!deepEquals(prevProps.options, options)) {
|
||||
const {
|
||||
registry: { schemaUtils }
|
||||
} = this.props;
|
||||
// re-cache the retrieved options in state in case they have $refs to save doing it later
|
||||
const retrievedOptions = options.map((opt: S) =>
|
||||
schemaUtils.retrieveSchema(opt, formData)
|
||||
);
|
||||
newState = { selectedOption, retrievedOptions };
|
||||
}
|
||||
if (!deepEquals(formData, prevProps.formData) && idSchema.$id === prevProps.idSchema.$id) {
|
||||
const { retrievedOptions } = newState;
|
||||
const matchingOption = this.getMatchingOption(selectedOption, formData, retrievedOptions);
|
||||
|
||||
if (prevState && matchingOption !== selectedOption) {
|
||||
newState = { selectedOption: matchingOption, retrievedOptions };
|
||||
}
|
||||
}
|
||||
if (newState !== this.state) {
|
||||
this.setState(newState);
|
||||
}
|
||||
}
|
||||
|
||||
/** Determines the best matching option for the given `formData` and `options`.
|
||||
*
|
||||
* @param formData - The new formData
|
||||
* @param options - The list of options to choose from
|
||||
* @return - The index of the `option` that best matches the `formData`
|
||||
*/
|
||||
getMatchingOption(selectedOption: number, formData: T | undefined, options: S[]) {
|
||||
const {
|
||||
schema,
|
||||
registry: { schemaUtils }
|
||||
} = this.props;
|
||||
|
||||
const discriminator = getDiscriminatorFieldFromSchema<S>(schema);
|
||||
const option = schemaUtils.getClosestMatchingOption(
|
||||
formData,
|
||||
options,
|
||||
selectedOption,
|
||||
discriminator
|
||||
);
|
||||
return option;
|
||||
}
|
||||
|
||||
/** Callback handler to remember what the currently selected option is. In addition to that the `formData` is updated
|
||||
* to remove properties that are not part of the newly selected option schema, and then the updated data is passed to
|
||||
* the `onChange` handler.
|
||||
*
|
||||
* @param option - The new option value being selected
|
||||
*/
|
||||
onOptionChange = (option?: string) => {
|
||||
const { selectedOption, retrievedOptions } = this.state;
|
||||
const { formData, onChange, registry } = this.props;
|
||||
console.log("onOptionChange", { state: { selectedOption, retrievedOptions }, option });
|
||||
|
||||
const { schemaUtils } = registry;
|
||||
const intOption = option !== undefined ? Number.parseInt(option, 10) : -1;
|
||||
if (intOption === selectedOption) {
|
||||
return;
|
||||
}
|
||||
const newOption = intOption >= 0 ? retrievedOptions[intOption] : undefined;
|
||||
const oldOption = selectedOption >= 0 ? retrievedOptions[selectedOption] : undefined;
|
||||
|
||||
let newFormData = schemaUtils.sanitizeDataForNewSchema(newOption, oldOption, formData);
|
||||
if (newFormData && newOption) {
|
||||
// Call getDefaultFormState to make sure defaults are populated on change. Pass "excludeObjectChildren"
|
||||
// so that only the root objects themselves are created without adding undefined children properties
|
||||
newFormData = schemaUtils.getDefaultFormState(
|
||||
newOption,
|
||||
newFormData,
|
||||
"excludeObjectChildren"
|
||||
) as T;
|
||||
}
|
||||
onChange(newFormData, undefined, this.getFieldId());
|
||||
|
||||
this.setState({ selectedOption: intOption });
|
||||
};
|
||||
|
||||
getFieldId() {
|
||||
const { idSchema, schema } = this.props;
|
||||
return `${idSchema.$id}${schema.oneOf ? "__oneof_select" : "__anyof_select"}`;
|
||||
}
|
||||
|
||||
/** Renders the `AnyOfField` selector along with a `SchemaField` for the value of the `formData`
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
name,
|
||||
disabled = false,
|
||||
errorSchema = {},
|
||||
formContext,
|
||||
onBlur,
|
||||
onFocus,
|
||||
registry,
|
||||
schema,
|
||||
uiSchema,
|
||||
readonly
|
||||
} = this.props;
|
||||
|
||||
const { widgets, fields, translateString, globalUiOptions, schemaUtils } = registry;
|
||||
const { SchemaField: _SchemaField } = fields;
|
||||
const { selectedOption, retrievedOptions } = this.state;
|
||||
const {
|
||||
widget = "select",
|
||||
placeholder,
|
||||
autofocus,
|
||||
autocomplete,
|
||||
title = schema.title,
|
||||
flexDirection,
|
||||
wrap,
|
||||
...uiOptions
|
||||
} = getUiOptions<T, S, F>(uiSchema, globalUiOptions);
|
||||
/* console.log("multi schema", {
|
||||
name,
|
||||
schema,
|
||||
uiSchema,
|
||||
uiOptions,
|
||||
globalUiOptions,
|
||||
disabled,
|
||||
flexDirection,
|
||||
props: this.props
|
||||
}); */
|
||||
const Widget = getWidget<T, S, F>({ type: "number" }, widget, widgets);
|
||||
const rawErrors = get(errorSchema, ERRORS_KEY, []);
|
||||
const fieldErrorSchema = omit(errorSchema, [ERRORS_KEY]);
|
||||
const displayLabel = schemaUtils.getDisplayLabel(schema, uiSchema, globalUiOptions);
|
||||
|
||||
const option = selectedOption >= 0 ? retrievedOptions[selectedOption] || null : null;
|
||||
let optionSchema: S | undefined | null;
|
||||
|
||||
if (option) {
|
||||
// merge top level required field
|
||||
const { required } = schema;
|
||||
// Merge in all the non-oneOf/anyOf properties and also skip the special ADDITIONAL_PROPERTY_FLAG property
|
||||
optionSchema = required ? (mergeSchemas({ required }, option) as S) : option;
|
||||
}
|
||||
|
||||
// First we will check to see if there is an anyOf/oneOf override for the UI schema
|
||||
let optionsUiSchema: UiSchema<T, S, F>[] = [];
|
||||
if (ONE_OF_KEY in schema && uiSchema && ONE_OF_KEY in uiSchema) {
|
||||
if (Array.isArray(uiSchema[ONE_OF_KEY])) {
|
||||
optionsUiSchema = uiSchema[ONE_OF_KEY];
|
||||
} else {
|
||||
console.warn(`uiSchema.oneOf is not an array for "${title || name}"`);
|
||||
}
|
||||
} else if (ANY_OF_KEY in schema && uiSchema && ANY_OF_KEY in uiSchema) {
|
||||
if (Array.isArray(uiSchema[ANY_OF_KEY])) {
|
||||
optionsUiSchema = uiSchema[ANY_OF_KEY];
|
||||
} else {
|
||||
console.warn(`uiSchema.anyOf is not an array for "${title || name}"`);
|
||||
}
|
||||
}
|
||||
// Then we pick the one that matches the selected option index, if one exists otherwise default to the main uiSchema
|
||||
let optionUiSchema = uiSchema;
|
||||
if (selectedOption >= 0 && optionsUiSchema.length > selectedOption) {
|
||||
optionUiSchema = optionsUiSchema[selectedOption];
|
||||
}
|
||||
|
||||
const translateEnum: TranslatableString = title
|
||||
? TranslatableString.TitleOptionPrefix
|
||||
: TranslatableString.OptionPrefix;
|
||||
const translateParams = title ? [title] : [];
|
||||
const enumOptions = retrievedOptions.map((opt: { title?: string }, index: number) => {
|
||||
// Also see if there is an override title in the uiSchema for each option, otherwise use the title from the option
|
||||
const { title: uiTitle = opt.title } = getUiOptions<T, S, F>(optionsUiSchema[index]);
|
||||
return {
|
||||
label:
|
||||
uiTitle || translateString(translateEnum, translateParams.concat(String(index + 1))),
|
||||
value: index
|
||||
};
|
||||
});
|
||||
|
||||
//console.log("sub component", { optionSchema, props: this.props, optionUiSchema });
|
||||
const SubComponent = optionSchema && (
|
||||
// @ts-ignore
|
||||
<_SchemaField
|
||||
{...this.props}
|
||||
schema={optionSchema}
|
||||
uiSchema={{
|
||||
...optionUiSchema,
|
||||
"ui:options": {
|
||||
...optionUiSchema?.["ui:options"],
|
||||
hideLabel: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"panel multischema flex",
|
||||
flexDirection === "row" ? "flex-row gap-3" : "flex-col gap-2"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center panel-select">
|
||||
<Label
|
||||
label={this.props.name}
|
||||
required={this.props.required}
|
||||
id={this.getFieldId()}
|
||||
/>
|
||||
<Widget
|
||||
id={this.getFieldId()}
|
||||
name={`${name}${schema.oneOf ? "__oneof_select" : "__anyof_select"}`}
|
||||
schema={{ type: "number", default: 0 } as S}
|
||||
onChange={this.onOptionChange}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
disabled={disabled || isEmpty(enumOptions) || readonly}
|
||||
multiple={false}
|
||||
rawErrors={rawErrors}
|
||||
errorSchema={fieldErrorSchema}
|
||||
value={selectedOption >= 0 ? selectedOption : undefined}
|
||||
options={{ enumOptions, ...uiOptions }}
|
||||
registry={registry}
|
||||
formContext={formContext}
|
||||
placeholder={placeholder}
|
||||
autocomplete={autocomplete}
|
||||
autofocus={autofocus}
|
||||
label={""}
|
||||
hideLabel={!displayLabel}
|
||||
/>
|
||||
</div>
|
||||
{wrap ? <fieldset>{SubComponent}</fieldset> : SubComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MultiSchemaField;
|
||||
10
app/src/ui/components/form/json-schema/fields/index.ts
Normal file
10
app/src/ui/components/form/json-schema/fields/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import JsonField from "./JsonField";
|
||||
import LiquidJsField from "./LiquidJsField";
|
||||
import MultiSchemaField from "./MultiSchemaField";
|
||||
|
||||
export const fields = {
|
||||
AnyOfField: MultiSchemaField,
|
||||
OneOfField: MultiSchemaField,
|
||||
JsonField,
|
||||
LiquidJsField
|
||||
};
|
||||
264
app/src/ui/components/form/json-schema/styles.css
Normal file
264
app/src/ui/components/form/json-schema/styles.css
Normal file
@@ -0,0 +1,264 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.json-form {
|
||||
@apply flex flex-col flex-grow;
|
||||
|
||||
/* dirty fix preventing the first fieldset to wrap */
|
||||
&.mute-root {
|
||||
& > div > div > div > fieldset:first-child {
|
||||
@apply border-none p-0;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.fieldset-alternative) {
|
||||
fieldset {
|
||||
@apply flex flex-grow flex-col gap-3.5 border border-solid border-muted p-3 rounded;
|
||||
|
||||
.title-field {
|
||||
@apply bg-primary/10 px-3 text-sm font-medium py-1 rounded-full;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* alternative */
|
||||
&.fieldset-alternative {
|
||||
fieldset {
|
||||
@apply flex flex-grow flex-col gap-3.5;
|
||||
&:has(> legend) {
|
||||
@apply mt-3 border-l-4 border-solid border-muted/50 p-3 pb-0 pt-0;
|
||||
}
|
||||
|
||||
.title-field {
|
||||
@apply bg-muted/50 text-sm font-medium py-1 table ml-[-14px] pl-4 pr-3 mb-3 mt-3;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.multischema {
|
||||
@apply mt-3;
|
||||
|
||||
fieldset {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.hide-required-mark {
|
||||
.control-label span.required {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@apply flex flex-col gap-1;
|
||||
&:not(.field) {
|
||||
@apply flex-grow;
|
||||
}
|
||||
|
||||
/* hide empty description if markdown is enabled */
|
||||
.field-description:has(> span:empty) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.control-label span.required {
|
||||
@apply ml-1 opacity-50;
|
||||
}
|
||||
|
||||
&.field.has-error {
|
||||
@apply text-red-500;
|
||||
|
||||
.control-label {
|
||||
@apply font-bold;
|
||||
}
|
||||
.error-detail:not(:only-child) {
|
||||
@apply font-bold list-disc pl-6;
|
||||
}
|
||||
.error-detail:only-child {
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field-description {
|
||||
@apply text-primary/70 text-sm;
|
||||
}
|
||||
|
||||
/* input but not radio */
|
||||
input:not([type="radio"]):not([type="checkbox"]) {
|
||||
@apply flex bg-muted/40 h-11 rounded-md outline-none;
|
||||
@apply py-2.5 px-4;
|
||||
width: 100%;
|
||||
|
||||
&:not([disabled]):not([readonly]) {
|
||||
@apply focus:outline-none focus:ring-2 focus:bg-muted focus:ring-zinc-500 focus:border-transparent transition-all;
|
||||
}
|
||||
&[disabled], &[readonly] {
|
||||
@apply bg-muted/50 text-primary/50 cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
@apply flex bg-muted/40 focus:bg-muted rounded-md outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50;
|
||||
@apply py-2.5 px-4;
|
||||
width: 100%;
|
||||
}
|
||||
.checkbox {
|
||||
label, label > span {
|
||||
@apply flex flex-row gap-2;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
@apply bg-muted/40 focus:bg-muted rounded-md py-2.5 pr-4 pl-2.5 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all;
|
||||
@apply disabled:bg-muted/70 disabled:text-primary/70;
|
||||
@apply w-full border-r-8 border-r-transparent;
|
||||
|
||||
&:not([multiple]) {
|
||||
@apply h-11;
|
||||
}
|
||||
|
||||
&[multiple] {
|
||||
option {
|
||||
@apply py-1.5 px-2.5 bg-transparent;
|
||||
&:checked {
|
||||
@apply bg-primary/20;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply w-5 h-5 bg-amber-500;
|
||||
}
|
||||
|
||||
.field-radio-group {
|
||||
@apply flex flex-row gap-2;
|
||||
}
|
||||
|
||||
&.noborder-first-fieldset {
|
||||
fieldset#root {
|
||||
@apply border-none p-0;
|
||||
}
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
.form-group {
|
||||
@apply flex-row gap-2;
|
||||
}
|
||||
.form-control, .panel {
|
||||
@apply flex-grow;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
@apply w-32 flex h-11 items-center;
|
||||
}
|
||||
input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
fieldset#root {
|
||||
@apply gap-6;
|
||||
}
|
||||
|
||||
fieldset.object-field {
|
||||
@apply gap-2;
|
||||
}
|
||||
|
||||
.additional-children {
|
||||
.checkbox {
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.hide-multi-labels {
|
||||
.control-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.multischema {
|
||||
.form-control {
|
||||
@apply flex-shrink;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
/*@apply flex flex-col gap-2;*/
|
||||
|
||||
/*.control-label { display: none; }*/
|
||||
|
||||
& > .field-radio-group {
|
||||
@apply flex flex-row gap-3;
|
||||
|
||||
.radio, .radio-inline {
|
||||
@apply text-sm border-b border-b-transparent;
|
||||
@apply font-mono text-primary/70;
|
||||
|
||||
input {
|
||||
@apply appearance-none;
|
||||
}
|
||||
&.checked {
|
||||
@apply border-b-primary/70 text-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* :not(.panel-select) .control-label {
|
||||
display: none;
|
||||
} */
|
||||
|
||||
.panel-select select {
|
||||
@apply py-1 pr-1 pl-1.5 text-sm;
|
||||
@apply h-auto w-auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.legacy {
|
||||
/* first fieldset */
|
||||
& > .form-group.field-object>div>fieldset {
|
||||
@apply border-none p-0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
}
|
||||
.col-xs-5 {
|
||||
display: flex;
|
||||
width: 50%;
|
||||
}
|
||||
.form-additional {
|
||||
fieldset {
|
||||
/* padding: 0;
|
||||
border: none; */
|
||||
|
||||
/* legend {
|
||||
display: none;
|
||||
} */
|
||||
}
|
||||
&.additional-start {
|
||||
> label {
|
||||
display: none;
|
||||
}
|
||||
/* > label + div > fieldset:first-child {
|
||||
display: none;
|
||||
} */
|
||||
}
|
||||
}
|
||||
.field-object + .field-object {
|
||||
@apply mt-3 pt-4 border-t border-muted;
|
||||
}
|
||||
.panel>.field-object>label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
ArrayFieldTemplateItemType,
|
||||
FormContextType,
|
||||
RJSFSchema,
|
||||
StrictRJSFSchema,
|
||||
} from "@rjsf/utils";
|
||||
import { type CSSProperties, Children, cloneElement, isValidElement } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/** The `ArrayFieldItemTemplate` component is the template used to render an items of an array.
|
||||
*
|
||||
* @param props - The `ArrayFieldTemplateItemType` props for the component
|
||||
*/
|
||||
export default function ArrayFieldItemTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any,
|
||||
>(props: ArrayFieldTemplateItemType<T, S, F>) {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
hasToolbar,
|
||||
hasMoveDown,
|
||||
hasMoveUp,
|
||||
hasRemove,
|
||||
hasCopy,
|
||||
index,
|
||||
onCopyIndexClick,
|
||||
onDropIndexClick,
|
||||
onReorderClick,
|
||||
readonly,
|
||||
registry,
|
||||
uiSchema,
|
||||
} = props;
|
||||
const { CopyButton, MoveDownButton, MoveUpButton, RemoveButton } =
|
||||
registry.templates.ButtonTemplates;
|
||||
|
||||
return (
|
||||
<div className={twMerge("flex flex-row w-full overflow-hidden", className)}>
|
||||
{hasToolbar && (
|
||||
<div className="flex flex-col gap-1 p-1 mr-2">
|
||||
{(hasMoveUp || hasMoveDown) && (
|
||||
<MoveUpButton
|
||||
disabled={disabled || readonly || !hasMoveUp}
|
||||
onClick={onReorderClick(index, index - 1)}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
{(hasMoveUp || hasMoveDown) && (
|
||||
<MoveDownButton
|
||||
disabled={disabled || readonly || !hasMoveDown}
|
||||
onClick={onReorderClick(index, index + 1)}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
{hasCopy && (
|
||||
<CopyButton
|
||||
disabled={disabled || readonly}
|
||||
onClick={onCopyIndexClick(index)}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
{hasRemove && (
|
||||
<RemoveButton
|
||||
disabled={disabled || readonly}
|
||||
onClick={onDropIndexClick(index)}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col flex-grow">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
type ArrayFieldTemplateItemType,
|
||||
type ArrayFieldTemplateProps,
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
getTemplate,
|
||||
getUiOptions
|
||||
} from "@rjsf/utils";
|
||||
import { cloneElement } from "react";
|
||||
|
||||
/** The `ArrayFieldTemplate` component is the template used to render all items in an array.
|
||||
*
|
||||
* @param props - The `ArrayFieldTemplateItemType` props for the component
|
||||
*/
|
||||
export default function ArrayFieldTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: ArrayFieldTemplateProps<T, S, F>) {
|
||||
const {
|
||||
canAdd,
|
||||
className,
|
||||
disabled,
|
||||
idSchema,
|
||||
uiSchema,
|
||||
items,
|
||||
onAddClick,
|
||||
readonly,
|
||||
registry,
|
||||
required,
|
||||
schema,
|
||||
title
|
||||
} = props;
|
||||
const uiOptions = getUiOptions<T, S, F>(uiSchema);
|
||||
const ArrayFieldDescriptionTemplate = getTemplate<"ArrayFieldDescriptionTemplate", T, S, F>(
|
||||
"ArrayFieldDescriptionTemplate",
|
||||
registry,
|
||||
uiOptions
|
||||
);
|
||||
const ArrayFieldItemTemplate = getTemplate<"ArrayFieldItemTemplate", T, S, F>(
|
||||
"ArrayFieldItemTemplate",
|
||||
registry,
|
||||
uiOptions
|
||||
);
|
||||
const ArrayFieldTitleTemplate = getTemplate<"ArrayFieldTitleTemplate", T, S, F>(
|
||||
"ArrayFieldTitleTemplate",
|
||||
registry,
|
||||
uiOptions
|
||||
);
|
||||
// Button templates are not overridden in the uiSchema
|
||||
const {
|
||||
ButtonTemplates: { AddButton }
|
||||
} = registry.templates;
|
||||
return (
|
||||
<fieldset className={className} id={idSchema.$id}>
|
||||
<ArrayFieldTitleTemplate
|
||||
idSchema={idSchema}
|
||||
title={uiOptions.title || title}
|
||||
required={required}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
<ArrayFieldDescriptionTemplate
|
||||
idSchema={idSchema}
|
||||
description={uiOptions.description || schema.description}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
{items && items.length > 0 && (
|
||||
<div className="flex flex-col gap-3 array-items">
|
||||
{items.map(
|
||||
({ key, children, ...itemProps }: ArrayFieldTemplateItemType<T, S, F>) => {
|
||||
const newChildren = cloneElement(children, {
|
||||
...children.props,
|
||||
name: undefined,
|
||||
title: undefined
|
||||
});
|
||||
|
||||
return (
|
||||
<ArrayFieldItemTemplate key={key} {...itemProps} children={newChildren} />
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canAdd && (
|
||||
<AddButton
|
||||
className="array-item-add"
|
||||
onClick={onAddClick}
|
||||
disabled={disabled || readonly}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
type BaseInputTemplateProps,
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
ariaDescribedByIds,
|
||||
examplesId,
|
||||
getInputProps
|
||||
} from "@rjsf/utils";
|
||||
import { type ChangeEvent, type FocusEvent, useCallback } from "react";
|
||||
import { Label } from "./FieldTemplate";
|
||||
|
||||
/** The `BaseInputTemplate` is the template to use to render the basic `<input>` component for the `core` theme.
|
||||
* It is used as the template for rendering many of the <input> based widgets that differ by `type` and callbacks only.
|
||||
* It can be customized/overridden for other themes or individual implementations as needed.
|
||||
*
|
||||
* @param props - The `WidgetProps` for this template
|
||||
*/
|
||||
export default function BaseInputTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: BaseInputTemplateProps<T, S, F>) {
|
||||
const {
|
||||
id,
|
||||
name, // remove this from ...rest
|
||||
value,
|
||||
readonly,
|
||||
disabled,
|
||||
autofocus,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onChange,
|
||||
onChangeOverride,
|
||||
options,
|
||||
schema,
|
||||
uiSchema,
|
||||
formContext,
|
||||
registry,
|
||||
rawErrors,
|
||||
type,
|
||||
hideLabel, // remove this from ...rest
|
||||
hideError, // remove this from ...rest
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
// Note: since React 15.2.0 we can't forward unknown element attributes, so we
|
||||
// exclude the "options" and "schema" ones here.
|
||||
if (!id) {
|
||||
console.log("No id for", props);
|
||||
throw new Error(`no id for props ${JSON.stringify(props)}`);
|
||||
}
|
||||
const inputProps = {
|
||||
...rest,
|
||||
...getInputProps<T, S, F>(schema, type, options)
|
||||
};
|
||||
|
||||
let inputValue;
|
||||
if (inputProps.type === "number" || inputProps.type === "integer") {
|
||||
inputValue = value || value === 0 ? value : "";
|
||||
} else {
|
||||
inputValue = value == null ? "" : value;
|
||||
}
|
||||
|
||||
const _onChange = useCallback(
|
||||
({ target: { value } }: ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(value === "" ? options.emptyValue : value),
|
||||
[onChange, options]
|
||||
);
|
||||
const _onBlur = useCallback(
|
||||
({ target }: FocusEvent<HTMLInputElement>) => onBlur(id, target && target.value),
|
||||
[onBlur, id]
|
||||
);
|
||||
const _onFocus = useCallback(
|
||||
({ target }: FocusEvent<HTMLInputElement>) => onFocus(id, target && target.value),
|
||||
[onFocus, id]
|
||||
);
|
||||
|
||||
const shouldHideLabel =
|
||||
!props.label ||
|
||||
// @ts-ignore
|
||||
uiSchema["ui:options"]?.hideLabel ||
|
||||
props.options?.hideLabel ||
|
||||
props.hideLabel;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!shouldHideLabel && <Label label={props.label} required={props.required} id={id} />}
|
||||
<input
|
||||
id={id}
|
||||
name={id}
|
||||
className="form-control"
|
||||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
autoFocus={autofocus}
|
||||
value={inputValue}
|
||||
{...inputProps}
|
||||
placeholder={props.label}
|
||||
list={schema.examples ? examplesId<T>(id) : undefined}
|
||||
onChange={onChangeOverride || _onChange}
|
||||
onBlur={_onBlur}
|
||||
onFocus={_onFocus}
|
||||
aria-describedby={ariaDescribedByIds<T>(id, !!schema.examples)}
|
||||
/>
|
||||
{Array.isArray(schema.examples) && (
|
||||
<datalist key={`datalist_${id}`} id={examplesId<T>(id)}>
|
||||
{(schema.examples as string[])
|
||||
.concat(
|
||||
schema.default && !schema.examples.includes(schema.default)
|
||||
? ([schema.default] as string[])
|
||||
: []
|
||||
)
|
||||
.map((example: any) => {
|
||||
return <option key={example} value={example} />;
|
||||
})}
|
||||
</datalist>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { TbArrowDown, TbArrowUp, TbPlus, TbTrash } from "react-icons/tb";
|
||||
import { Button } from "../../../buttons/Button";
|
||||
import { IconButton } from "../../../buttons/IconButton";
|
||||
|
||||
export const AddButton = ({ onClick, disabled, ...rest }) => (
|
||||
<div className="flex flex-row">
|
||||
<Button onClick={onClick} disabled={disabled} IconLeft={TbPlus}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const RemoveButton = ({ onClick, disabled, ...rest }) => (
|
||||
<div className="flex flex-row">
|
||||
<IconButton onClick={onClick} disabled={disabled} Icon={TbTrash} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MoveUpButton = ({ onClick, disabled, ...rest }) => (
|
||||
<div className="flex flex-row">
|
||||
<IconButton onClick={onClick} disabled={disabled} Icon={TbArrowUp} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MoveDownButton = ({ onClick, disabled, ...rest }) => (
|
||||
<div className="flex flex-row">
|
||||
<IconButton onClick={onClick} disabled={disabled} Icon={TbArrowDown} />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
type FieldTemplateProps,
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
getTemplate,
|
||||
getUiOptions
|
||||
} from "@rjsf/utils";
|
||||
import { ucFirstAll, ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
const REQUIRED_FIELD_SYMBOL = "*";
|
||||
|
||||
export type LabelProps = {
|
||||
/** The label for the field */
|
||||
label?: string;
|
||||
/** A boolean value stating if the field is required */
|
||||
required?: boolean;
|
||||
/** The id of the input field being labeled */
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/** Renders a label for a field
|
||||
*
|
||||
* @param props - The `LabelProps` for this component
|
||||
*/
|
||||
export function Label(props: LabelProps) {
|
||||
const { label, required, id } = props;
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<label className="control-label" htmlFor={id}>
|
||||
{ucFirstAllSnakeToPascalWithSpaces(label)}
|
||||
{required && <span className="required">{REQUIRED_FIELD_SYMBOL}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** The `FieldTemplate` component is the template used by `SchemaField` to render any field. It renders the field
|
||||
* content, (label, description, children, errors and help) inside of a `WrapIfAdditional` component.
|
||||
*
|
||||
* @param props - The `FieldTemplateProps` for this component
|
||||
*/
|
||||
export function FieldTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: FieldTemplateProps<T, S, F>) {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
children,
|
||||
errors,
|
||||
help,
|
||||
description,
|
||||
hidden,
|
||||
required,
|
||||
displayLabel,
|
||||
registry,
|
||||
uiSchema
|
||||
} = props;
|
||||
const uiOptions = getUiOptions(uiSchema, registry.globalUiOptions);
|
||||
//console.log("field---", uiOptions);
|
||||
const WrapIfAdditionalTemplate = getTemplate<"WrapIfAdditionalTemplate", T, S, F>(
|
||||
"WrapIfAdditionalTemplate",
|
||||
registry,
|
||||
uiOptions
|
||||
);
|
||||
if (hidden) {
|
||||
return <div className="hidden">{children}</div>;
|
||||
}
|
||||
//console.log("FieldTemplate", props);
|
||||
|
||||
return (
|
||||
<WrapIfAdditionalTemplate {...props}>
|
||||
{/*<Label label={label} required={required} id={id} />*/}
|
||||
<div className="flex flex-col flex-grow gap-2 additional">
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-grow additional-children",
|
||||
uiOptions.flexDirection === "row"
|
||||
? "flex-row items-center gap-3"
|
||||
: "flex-col flex-grow gap-2"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{displayLabel && description ? description : null}
|
||||
</div>
|
||||
{errors}
|
||||
{help}
|
||||
</WrapIfAdditionalTemplate>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
type FormContextType,
|
||||
type ObjectFieldTemplatePropertyType,
|
||||
type ObjectFieldTemplateProps,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
canExpand,
|
||||
descriptionId,
|
||||
getTemplate,
|
||||
getUiOptions,
|
||||
titleId
|
||||
} from "@rjsf/utils";
|
||||
|
||||
/** The `ObjectFieldTemplate` is the template to use to render all the inner properties of an object along with the
|
||||
* title and description if available. If the object is expandable, then an `AddButton` is also rendered after all
|
||||
* the properties.
|
||||
*
|
||||
* @param props - The `ObjectFieldTemplateProps` for this component
|
||||
*/
|
||||
export default function ObjectFieldTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: ObjectFieldTemplateProps<T, S, F>) {
|
||||
const {
|
||||
description,
|
||||
disabled,
|
||||
formData,
|
||||
idSchema,
|
||||
onAddClick,
|
||||
properties,
|
||||
readonly,
|
||||
registry,
|
||||
required,
|
||||
schema,
|
||||
title,
|
||||
uiSchema
|
||||
} = props;
|
||||
const options = getUiOptions<T, S, F>(uiSchema);
|
||||
const TitleFieldTemplate = getTemplate<"TitleFieldTemplate", T, S, F>(
|
||||
"TitleFieldTemplate",
|
||||
registry,
|
||||
options
|
||||
);
|
||||
const DescriptionFieldTemplate = getTemplate<"DescriptionFieldTemplate", T, S, F>(
|
||||
"DescriptionFieldTemplate",
|
||||
registry,
|
||||
options
|
||||
);
|
||||
|
||||
/* if (properties.length === 0) {
|
||||
return null;
|
||||
} */
|
||||
const _canExpand = canExpand(schema, uiSchema, formData);
|
||||
if (properties.length === 0 && !_canExpand) {
|
||||
return null;
|
||||
}
|
||||
//console.log("multi:properties", uiSchema, props, options);
|
||||
|
||||
// Button templates are not overridden in the uiSchema
|
||||
const {
|
||||
ButtonTemplates: { AddButton }
|
||||
} = registry.templates;
|
||||
|
||||
return (
|
||||
<>
|
||||
<fieldset id={idSchema.$id} className="object-field">
|
||||
{title && (
|
||||
<TitleFieldTemplate
|
||||
id={titleId<T>(idSchema)}
|
||||
title={title}
|
||||
required={required}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
{description && (
|
||||
<DescriptionFieldTemplate
|
||||
id={descriptionId<T>(idSchema)}
|
||||
description={description}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{properties.map((prop: ObjectFieldTemplatePropertyType) => prop.content)}
|
||||
{_canExpand && (
|
||||
<AddButton
|
||||
className="object-property-expand"
|
||||
onClick={onAddClick(schema)}
|
||||
disabled={disabled || readonly}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FormContextType, RJSFSchema, StrictRJSFSchema, TitleFieldProps } from "@rjsf/utils";
|
||||
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||
|
||||
const REQUIRED_FIELD_SYMBOL = "*";
|
||||
|
||||
/** The `TitleField` is the template to use to render the title of a field
|
||||
*
|
||||
* @param props - The `TitleFieldProps` for this component
|
||||
*/
|
||||
export default function TitleField<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: TitleFieldProps<T, S, F>) {
|
||||
const { id, title, required } = props;
|
||||
return (
|
||||
<legend id={id} className="title-field">
|
||||
{ucFirstAllSnakeToPascalWithSpaces(title)}
|
||||
{/*{required && <span className="required">{REQUIRED_FIELD_SYMBOL}</span>}*/}
|
||||
</legend>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
ADDITIONAL_PROPERTY_FLAG,
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
TranslatableString,
|
||||
type WrapIfAdditionalTemplateProps
|
||||
} from "@rjsf/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
/** The `WrapIfAdditional` component is used by the `FieldTemplate` to rename, or remove properties that are
|
||||
* part of an `additionalProperties` part of a schema.
|
||||
*
|
||||
* @param props - The `WrapIfAdditionalProps` for this component
|
||||
*/
|
||||
export default function WrapIfAdditionalTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: WrapIfAdditionalTemplateProps<T, S, F>) {
|
||||
const {
|
||||
id,
|
||||
classNames,
|
||||
style,
|
||||
disabled,
|
||||
label,
|
||||
onKeyChange,
|
||||
onDropPropertyClick,
|
||||
readonly,
|
||||
required,
|
||||
schema,
|
||||
children,
|
||||
uiSchema,
|
||||
registry
|
||||
} = props;
|
||||
const { templates, translateString } = registry;
|
||||
// Button templates are not overridden in the uiSchema
|
||||
const { RemoveButton } = templates.ButtonTemplates;
|
||||
const keyLabel = translateString(TranslatableString.KeyLabel, [label]);
|
||||
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
if (!additional) {
|
||||
return (
|
||||
<div className={classNames} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames} style={style}>
|
||||
<div className="flex flex-col">
|
||||
<fieldset>
|
||||
<legend className="flex flex-row justify-between gap-3">
|
||||
<RemoveButton
|
||||
className="array-item-remove btn-block"
|
||||
style={{ border: "0" }}
|
||||
disabled={disabled || readonly}
|
||||
onClick={onDropPropertyClick(label)}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control"
|
||||
type="text"
|
||||
id={`${id}-key`}
|
||||
onBlur={(event) => onKeyChange(event.target.value)}
|
||||
defaultValue={label}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => setExpanded((prev) => !prev)}>
|
||||
{expanded ? "collapse" : "expand"}
|
||||
</button>
|
||||
</legend>
|
||||
{expanded && (
|
||||
<div className="form-additional additional-start form-group">{children}</div>
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
app/src/ui/components/form/json-schema/templates/index.ts
Normal file
19
app/src/ui/components/form/json-schema/templates/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import ArrayFieldItemTemplate from "./ArrayFieldItemTemplate";
|
||||
import ArrayFieldTemplate from "./ArrayFieldTemplate";
|
||||
import BaseInputTemplate from "./BaseInputTemplate";
|
||||
import * as ButtonTemplates from "./ButtonTemplates";
|
||||
import { FieldTemplate } from "./FieldTemplate";
|
||||
import ObjectFieldTemplate from "./ObjectFieldTemplate";
|
||||
import TitleFieldTemplate from "./TitleFieldTemplate";
|
||||
import WrapIfAdditionalTemplate from "./WrapIfAdditionalTemplate";
|
||||
|
||||
export const templates = {
|
||||
ButtonTemplates,
|
||||
ArrayFieldItemTemplate,
|
||||
ArrayFieldTemplate,
|
||||
FieldTemplate,
|
||||
TitleFieldTemplate,
|
||||
ObjectFieldTemplate,
|
||||
BaseInputTemplate,
|
||||
WrapIfAdditionalTemplate
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Check, Errors } from "core/utils";
|
||||
import { FromSchema } from "./from-schema";
|
||||
|
||||
import type {
|
||||
CustomValidator,
|
||||
ErrorTransformer,
|
||||
RJSFSchema,
|
||||
RJSFValidationError,
|
||||
StrictRJSFSchema,
|
||||
UiSchema,
|
||||
ValidationData,
|
||||
ValidatorType
|
||||
} from "@rjsf/utils";
|
||||
import { toErrorSchema } from "@rjsf/utils";
|
||||
|
||||
const validate = true;
|
||||
|
||||
export class RJSFTypeboxValidator<T = any, S extends StrictRJSFSchema = RJSFSchema>
|
||||
implements ValidatorType
|
||||
{
|
||||
// @ts-ignore
|
||||
rawValidation(schema: S, formData?: T) {
|
||||
if (!validate) {
|
||||
return { errors: [], validationError: null as any };
|
||||
}
|
||||
const tbSchema = FromSchema(schema as unknown);
|
||||
|
||||
//console.log("--validation", tbSchema, formData);
|
||||
|
||||
if (Check(tbSchema, formData)) {
|
||||
return { errors: [], validationError: null as any };
|
||||
}
|
||||
|
||||
return {
|
||||
errors: [...Errors(tbSchema, formData)],
|
||||
validationError: null as any
|
||||
};
|
||||
}
|
||||
|
||||
validateFormData(
|
||||
formData: T | undefined,
|
||||
schema: S,
|
||||
customValidate?: CustomValidator,
|
||||
transformErrors?: ErrorTransformer,
|
||||
uiSchema?: UiSchema
|
||||
): ValidationData<T> {
|
||||
const { errors } = this.rawValidation(schema, formData);
|
||||
|
||||
const transformedErrors = errors.map((error) => {
|
||||
const schemaLocation = error.path.substring(1).split("/").join(".");
|
||||
|
||||
return {
|
||||
name: "any",
|
||||
message: error.message,
|
||||
property: "." + schemaLocation,
|
||||
schemaPath: error.path,
|
||||
stack: error.message
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
errors: transformedErrors,
|
||||
errorSchema: toErrorSchema(transformedErrors)
|
||||
} as any;
|
||||
}
|
||||
|
||||
isValid(schema: S, formData: T | undefined, rootSchema: S): boolean {
|
||||
const validation = this.rawValidation(schema, formData);
|
||||
|
||||
return validation.errors.length === 0;
|
||||
}
|
||||
|
||||
toErrorList(): RJSFValidationError[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
299
app/src/ui/components/form/json-schema/typebox/from-schema.ts
Normal file
299
app/src/ui/components/form/json-schema/typebox/from-schema.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/*--------------------------------------------------------------------------
|
||||
|
||||
@sinclair/typebox/prototypes
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2017-2024 Haydn Paterson (sinclair) <haydn.developer@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
---------------------------------------------------------------------------*/
|
||||
|
||||
import * as Type from "@sinclair/typebox";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Schematics
|
||||
// ------------------------------------------------------------------
|
||||
const IsExact = (value: unknown, expect: unknown) => value === expect;
|
||||
const IsSValue = (value: unknown): value is SValue =>
|
||||
Type.ValueGuard.IsString(value) ||
|
||||
Type.ValueGuard.IsNumber(value) ||
|
||||
Type.ValueGuard.IsBoolean(value);
|
||||
const IsSEnum = (value: unknown): value is SEnum =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
Type.ValueGuard.IsArray(value.enum) &&
|
||||
value.enum.every((value) => IsSValue(value));
|
||||
const IsSAllOf = (value: unknown): value is SAllOf =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.allOf);
|
||||
const IsSAnyOf = (value: unknown): value is SAnyOf =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.anyOf);
|
||||
const IsSOneOf = (value: unknown): value is SOneOf =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.oneOf);
|
||||
const IsSTuple = (value: unknown): value is STuple =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
IsExact(value.type, "array") &&
|
||||
Type.ValueGuard.IsArray(value.items);
|
||||
const IsSArray = (value: unknown): value is SArray =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
IsExact(value.type, "array") &&
|
||||
!Type.ValueGuard.IsArray(value.items) &&
|
||||
Type.ValueGuard.IsObject(value.items);
|
||||
const IsSConst = (value: unknown): value is SConst =>
|
||||
// biome-ignore lint: reason
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsObject(value["const"]);
|
||||
const IsSString = (value: unknown): value is SString =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "string");
|
||||
const IsSNumber = (value: unknown): value is SNumber =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "number");
|
||||
const IsSInteger = (value: unknown): value is SInteger =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "integer");
|
||||
const IsSBoolean = (value: unknown): value is SBoolean =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "boolean");
|
||||
const IsSNull = (value: unknown): value is SBoolean =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "null");
|
||||
const IsSProperties = (value: unknown): value is SProperties => Type.ValueGuard.IsObject(value);
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
const IsSObject = (value: unknown): value is SObject => Type.ValueGuard.IsObject(value) && IsExact(value.type, 'object') && IsSProperties(value.properties) && (value.required === undefined || Type.ValueGuard.IsArray(value.required) && value.required.every((value: unknown) => Type.ValueGuard.IsString(value)))
|
||||
type SValue = string | number | boolean;
|
||||
type SEnum = Readonly<{ enum: readonly SValue[] }>;
|
||||
type SAllOf = Readonly<{ allOf: readonly unknown[] }>;
|
||||
type SAnyOf = Readonly<{ anyOf: readonly unknown[] }>;
|
||||
type SOneOf = Readonly<{ oneOf: readonly unknown[] }>;
|
||||
type SProperties = Record<PropertyKey, unknown>;
|
||||
type SObject = Readonly<{
|
||||
type: "object";
|
||||
properties: SProperties;
|
||||
required?: readonly string[];
|
||||
}>;
|
||||
type STuple = Readonly<{ type: "array"; items: readonly unknown[] }>;
|
||||
type SArray = Readonly<{ type: "array"; items: unknown }>;
|
||||
type SConst = Readonly<{ const: SValue }>;
|
||||
type SString = Readonly<{ type: "string" }>;
|
||||
type SNumber = Readonly<{ type: "number" }>;
|
||||
type SInteger = Readonly<{ type: "integer" }>;
|
||||
type SBoolean = Readonly<{ type: "boolean" }>;
|
||||
type SNull = Readonly<{ type: "null" }>;
|
||||
// ------------------------------------------------------------------
|
||||
// FromRest
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromRest<T extends readonly unknown[], Acc extends Type.TSchema[] = []> = (
|
||||
// biome-ignore lint: reason
|
||||
T extends readonly [infer L extends unknown, ...infer R extends unknown[]]
|
||||
? TFromSchema<L> extends infer S extends Type.TSchema
|
||||
? TFromRest<R, [...Acc, S]>
|
||||
: TFromRest<R, [...Acc]>
|
||||
: Acc
|
||||
)
|
||||
function FromRest<T extends readonly unknown[]>(T: T): TFromRest<T> {
|
||||
return T.map((L) => FromSchema(L)) as never;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// FromEnumRest
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromEnumRest<T extends readonly SValue[], Acc extends Type.TSchema[] = []> = (
|
||||
T extends readonly [infer L extends SValue, ...infer R extends SValue[]]
|
||||
? TFromEnumRest<R, [...Acc, Type.TLiteral<L>]>
|
||||
: Acc
|
||||
)
|
||||
function FromEnumRest<T extends readonly SValue[]>(T: T): TFromEnumRest<T> {
|
||||
return T.map((L) => Type.Literal(L)) as never;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// AllOf
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromAllOf<T extends SAllOf> = (
|
||||
TFromRest<T['allOf']> extends infer Rest extends Type.TSchema[]
|
||||
? Type.TIntersectEvaluated<Rest>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromAllOf<T extends SAllOf>(T: T): TFromAllOf<T> {
|
||||
return Type.IntersectEvaluated(FromRest(T.allOf), T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// AnyOf
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromAnyOf<T extends SAnyOf> = (
|
||||
TFromRest<T['anyOf']> extends infer Rest extends Type.TSchema[]
|
||||
? Type.TUnionEvaluated<Rest>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromAnyOf<T extends SAnyOf>(T: T): TFromAnyOf<T> {
|
||||
return Type.UnionEvaluated(FromRest(T.anyOf), T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// OneOf
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromOneOf<T extends SOneOf> = (
|
||||
TFromRest<T['oneOf']> extends infer Rest extends Type.TSchema[]
|
||||
? Type.TUnionEvaluated<Rest>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromOneOf<T extends SOneOf>(T: T): TFromOneOf<T> {
|
||||
return Type.UnionEvaluated(FromRest(T.oneOf), T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Enum
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromEnum<T extends SEnum> = (
|
||||
TFromEnumRest<T['enum']> extends infer Elements extends Type.TSchema[]
|
||||
? Type.TUnionEvaluated<Elements>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromEnum<T extends SEnum>(T: T): TFromEnum<T> {
|
||||
return Type.UnionEvaluated(FromEnumRest(T.enum));
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Tuple
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromTuple<T extends STuple> = (
|
||||
TFromRest<T['items']> extends infer Elements extends Type.TSchema[]
|
||||
? Type.TTuple<Elements>
|
||||
: Type.TTuple<[]>
|
||||
)
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
function FromTuple<T extends STuple>(T: T): TFromTuple<T> {
|
||||
return Type.Tuple(FromRest(T.items), T) as never
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Array
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromArray<T extends SArray> = (
|
||||
TFromSchema<T['items']> extends infer Items extends Type.TSchema
|
||||
? Type.TArray<Items>
|
||||
: Type.TArray<Type.TUnknown>
|
||||
)
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
function FromArray<T extends SArray>(T: T): TFromArray<T> {
|
||||
return Type.Array(FromSchema(T.items), T) as never
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Const
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromConst<T extends SConst> = (
|
||||
Type.Ensure<Type.TLiteral<T['const']>>
|
||||
)
|
||||
function FromConst<T extends SConst>(T: T) {
|
||||
return Type.Literal(T.const, T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Object
|
||||
// ------------------------------------------------------------------
|
||||
type TFromPropertiesIsOptional<
|
||||
K extends PropertyKey,
|
||||
R extends string | unknown
|
||||
> = unknown extends R ? true : K extends R ? false : true;
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromProperties<T extends SProperties, R extends string | unknown> = Type.Evaluate<{
|
||||
-readonly [K in keyof T]: TFromPropertiesIsOptional<K, R> extends true
|
||||
? Type.TOptional<TFromSchema<T[K]>>
|
||||
: TFromSchema<T[K]>
|
||||
}>
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromObject<T extends SObject> = (
|
||||
TFromProperties<T['properties'], Exclude<T['required'], undefined>[number]> extends infer Properties extends Type.TProperties
|
||||
? Type.TObject<Properties>
|
||||
: Type.TObject<{}>
|
||||
)
|
||||
function FromObject<T extends SObject>(T: T): TFromObject<T> {
|
||||
const properties = globalThis.Object.getOwnPropertyNames(T.properties).reduce((Acc, K) => {
|
||||
return {
|
||||
// biome-ignore lint:
|
||||
...Acc,
|
||||
[K]:
|
||||
// biome-ignore lint: reason
|
||||
T.required && T.required.includes(K)
|
||||
? FromSchema(T.properties[K])
|
||||
: Type.Optional(FromSchema(T.properties[K]))
|
||||
};
|
||||
}, {} as Type.TProperties);
|
||||
|
||||
if ("additionalProperties" in T) {
|
||||
return Type.Object(properties, {
|
||||
additionalProperties: FromSchema(T.additionalProperties)
|
||||
}) as never;
|
||||
}
|
||||
|
||||
return Type.Object(properties, T) as never;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// FromSchema
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
export type TFromSchema<T> = (
|
||||
T extends SAllOf ? TFromAllOf<T> :
|
||||
T extends SAnyOf ? TFromAnyOf<T> :
|
||||
T extends SOneOf ? TFromOneOf<T> :
|
||||
T extends SEnum ? TFromEnum<T> :
|
||||
T extends SObject ? TFromObject<T> :
|
||||
T extends STuple ? TFromTuple<T> :
|
||||
T extends SArray ? TFromArray<T> :
|
||||
T extends SConst ? TFromConst<T> :
|
||||
T extends SString ? Type.TString :
|
||||
T extends SNumber ? Type.TNumber :
|
||||
T extends SInteger ? Type.TInteger :
|
||||
T extends SBoolean ? Type.TBoolean :
|
||||
T extends SNull ? Type.TNull :
|
||||
Type.TUnknown
|
||||
)
|
||||
/** Parses a TypeBox type from raw JsonSchema */
|
||||
export function FromSchema<T>(T: T): TFromSchema<T> {
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
return (
|
||||
IsSAllOf(T) ? FromAllOf(T) :
|
||||
IsSAnyOf(T) ? FromAnyOf(T) :
|
||||
IsSOneOf(T) ? FromOneOf(T) :
|
||||
IsSEnum(T) ? FromEnum(T) :
|
||||
IsSObject(T) ? FromObject(T) :
|
||||
IsSTuple(T) ? FromTuple(T) :
|
||||
IsSArray(T) ? FromArray(T) :
|
||||
IsSConst(T) ? FromConst(T) :
|
||||
IsSString(T) ? Type.String(T) :
|
||||
IsSNumber(T) ? Type.Number(T) :
|
||||
IsSInteger(T) ? Type.Integer(T) :
|
||||
IsSBoolean(T) ? Type.Boolean(T) :
|
||||
IsSNull(T) ? Type.Null(T) :
|
||||
Type.Unknown(T || {})
|
||||
) as never
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Switch } from "@mantine/core";
|
||||
import type { FormContextType, RJSFSchema, StrictRJSFSchema, WidgetProps } from "@rjsf/utils";
|
||||
import { type ChangeEvent, useCallback } from "react";
|
||||
|
||||
export function CheckboxWidget<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>({
|
||||
schema,
|
||||
uiSchema,
|
||||
options,
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
label,
|
||||
hideLabel,
|
||||
autofocus = false,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onChange,
|
||||
registry,
|
||||
...props
|
||||
}: WidgetProps<T, S, F>) {
|
||||
/*console.log("addprops", value, props, label, {
|
||||
label,
|
||||
name: props.name,
|
||||
hideLabel,
|
||||
label_lower: label.toLowerCase(),
|
||||
name_lower: props.name.toLowerCase(),
|
||||
equals: label.toLowerCase() === props.name.toLowerCase()
|
||||
});*/
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => onChange(event.target.checked),
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Switch
|
||||
id={id}
|
||||
onChange={handleChange}
|
||||
defaultChecked={value}
|
||||
disabled={disabled || readonly}
|
||||
label={label.toLowerCase() === props.name.toLowerCase() ? undefined : label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
type WidgetProps,
|
||||
ariaDescribedByIds,
|
||||
enumOptionsDeselectValue,
|
||||
enumOptionsIsSelected,
|
||||
enumOptionsSelectValue,
|
||||
enumOptionsValueForIndex,
|
||||
optionId
|
||||
} from "@rjsf/utils";
|
||||
import { type ChangeEvent, type FocusEvent, useCallback } from "react";
|
||||
|
||||
/** The `CheckboxesWidget` is a widget for rendering checkbox groups.
|
||||
* It is typically used to represent an array of enums.
|
||||
*
|
||||
* @param props - The `WidgetProps` for this component
|
||||
*/
|
||||
function CheckboxesWidget<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>({
|
||||
id,
|
||||
disabled,
|
||||
options: { inline = false, enumOptions, enumDisabled, emptyValue },
|
||||
value,
|
||||
autofocus = false,
|
||||
readonly,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus
|
||||
}: WidgetProps<T, S, F>) {
|
||||
const checkboxesValues = Array.isArray(value) ? value : [value];
|
||||
|
||||
const handleBlur = useCallback(
|
||||
({ target }: FocusEvent<HTMLInputElement>) =>
|
||||
onBlur(id, enumOptionsValueForIndex<S>(target?.value, enumOptions, emptyValue)),
|
||||
[onBlur, id]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
({ target }: FocusEvent<HTMLInputElement>) =>
|
||||
onFocus(id, enumOptionsValueForIndex<S>(target?.value, enumOptions, emptyValue)),
|
||||
[onFocus, id]
|
||||
);
|
||||
return (
|
||||
<div className="checkboxes" id={id}>
|
||||
{Array.isArray(enumOptions) &&
|
||||
enumOptions.map((option, index) => {
|
||||
const checked = enumOptionsIsSelected<S>(option.value, checkboxesValues);
|
||||
const itemDisabled =
|
||||
Array.isArray(enumDisabled) && enumDisabled.indexOf(option.value) !== -1;
|
||||
const disabledCls = disabled || itemDisabled || readonly ? "disabled" : "";
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.checked) {
|
||||
onChange(enumOptionsSelectValue<S>(index, checkboxesValues, enumOptions));
|
||||
} else {
|
||||
onChange(enumOptionsDeselectValue<S>(index, checkboxesValues, enumOptions));
|
||||
}
|
||||
};
|
||||
|
||||
const checkbox = (
|
||||
// biome-ignore lint/correctness/useJsxKeyInIterable: it's wrapped
|
||||
<span>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={optionId(id, index)}
|
||||
name={id}
|
||||
checked={checked}
|
||||
value={String(index)}
|
||||
disabled={disabled || itemDisabled || readonly}
|
||||
autoFocus={autofocus && index === 0}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
aria-describedby={ariaDescribedByIds<T>(id)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
);
|
||||
return inline ? (
|
||||
<label key={index} className={`checkbox-inline ${disabledCls}`}>
|
||||
{checkbox}
|
||||
</label>
|
||||
) : (
|
||||
<div key={index} className={`checkbox ${disabledCls}`}>
|
||||
<label>{checkbox}</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckboxesWidget;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function JsonWidget({ value, onChange, disabled, readonly, ...props }: WidgetProps) {
|
||||
const [val, setVal] = useState(JSON.stringify(value, null, 2));
|
||||
|
||||
function handleChange(e) {
|
||||
setVal(e.target.value);
|
||||
try {
|
||||
onChange(JSON.parse(e.target.value));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea value={val} rows={10} disabled={disabled || readonly} onChange={handleChange} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
type WidgetProps,
|
||||
ariaDescribedByIds,
|
||||
enumOptionsIsSelected,
|
||||
enumOptionsValueForIndex,
|
||||
optionId
|
||||
} from "@rjsf/utils";
|
||||
import { type FocusEvent, useCallback } from "react";
|
||||
|
||||
/** The `RadioWidget` is a widget for rendering a radio group.
|
||||
* It is typically used with a string property constrained with enum options.
|
||||
*
|
||||
* @param props - The `WidgetProps` for this component
|
||||
*/
|
||||
function RadioWidget<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>({
|
||||
options,
|
||||
value,
|
||||
required,
|
||||
disabled,
|
||||
readonly,
|
||||
autofocus = false,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onChange,
|
||||
id
|
||||
}: WidgetProps<T, S, F>) {
|
||||
const { enumOptions, enumDisabled, inline, emptyValue } = options;
|
||||
|
||||
const handleBlur = useCallback(
|
||||
({ target: { value } }: FocusEvent<HTMLInputElement>) =>
|
||||
onBlur(id, enumOptionsValueForIndex<S>(value, enumOptions, emptyValue)),
|
||||
[onBlur, id]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
({ target: { value } }: FocusEvent<HTMLInputElement>) =>
|
||||
onFocus(id, enumOptionsValueForIndex<S>(value, enumOptions, emptyValue)),
|
||||
[onFocus, id]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="field-radio-group" id={id}>
|
||||
{Array.isArray(enumOptions) &&
|
||||
enumOptions.map((option, i) => {
|
||||
const checked = enumOptionsIsSelected<S>(option.value, value);
|
||||
const itemDisabled =
|
||||
Array.isArray(enumDisabled) && enumDisabled.indexOf(option.value) !== -1;
|
||||
const disabledCls = disabled || itemDisabled || readonly ? "disabled" : "";
|
||||
|
||||
const handleChange = () => onChange(option.value);
|
||||
|
||||
const radio = (
|
||||
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
|
||||
<span>
|
||||
<input
|
||||
type="radio"
|
||||
id={optionId(id, i)}
|
||||
checked={checked}
|
||||
name={id}
|
||||
required={required}
|
||||
value={String(i)}
|
||||
disabled={disabled || itemDisabled || readonly}
|
||||
autoFocus={autofocus && i === 0}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
aria-describedby={ariaDescribedByIds<T>(id)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return inline ? (
|
||||
<label
|
||||
key={i}
|
||||
className={`radio-inline ${checked ? "checked" : ""} ${disabledCls}`}
|
||||
>
|
||||
{radio}
|
||||
</label>
|
||||
) : (
|
||||
<div key={i} className={`radio ${checked ? "checked" : ""} ${disabledCls}`}>
|
||||
<label>{radio}</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RadioWidget;
|
||||
105
app/src/ui/components/form/json-schema/widgets/SelectWidget.tsx
Normal file
105
app/src/ui/components/form/json-schema/widgets/SelectWidget.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
type WidgetProps,
|
||||
ariaDescribedByIds,
|
||||
enumOptionsIndexForValue,
|
||||
enumOptionsValueForIndex
|
||||
} from "@rjsf/utils";
|
||||
import { type ChangeEvent, type FocusEvent, type SyntheticEvent, useCallback } from "react";
|
||||
|
||||
function getValue(event: SyntheticEvent<HTMLSelectElement>, multiple: boolean) {
|
||||
if (multiple) {
|
||||
return Array.from((event.target as HTMLSelectElement).options)
|
||||
.slice()
|
||||
.filter((o) => o.selected)
|
||||
.map((o) => o.value);
|
||||
}
|
||||
return (event.target as HTMLSelectElement).value;
|
||||
}
|
||||
|
||||
/** The `SelectWidget` is a widget for rendering dropdowns.
|
||||
* It is typically used with string properties constrained with enum options.
|
||||
*
|
||||
* @param props - The `WidgetProps` for this component
|
||||
*/
|
||||
function SelectWidget<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>({
|
||||
schema,
|
||||
id,
|
||||
options,
|
||||
value,
|
||||
required,
|
||||
disabled,
|
||||
readonly,
|
||||
multiple = false,
|
||||
autofocus = false,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder
|
||||
}: WidgetProps<T, S, F>) {
|
||||
const { enumOptions, enumDisabled, emptyValue: optEmptyVal } = options;
|
||||
const emptyValue = multiple ? [] : "";
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(event: FocusEvent<HTMLSelectElement>) => {
|
||||
const newValue = getValue(event, multiple);
|
||||
return onFocus(id, enumOptionsValueForIndex<S>(newValue, enumOptions, optEmptyVal));
|
||||
},
|
||||
[onFocus, id, schema, multiple, enumOptions, optEmptyVal]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event: FocusEvent<HTMLSelectElement>) => {
|
||||
const newValue = getValue(event, multiple);
|
||||
return onBlur(id, enumOptionsValueForIndex<S>(newValue, enumOptions, optEmptyVal));
|
||||
},
|
||||
[onBlur, id, schema, multiple, enumOptions, optEmptyVal]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLSelectElement>) => {
|
||||
const newValue = getValue(event, multiple);
|
||||
return onChange(enumOptionsValueForIndex<S>(newValue, enumOptions, optEmptyVal));
|
||||
},
|
||||
[onChange, schema, multiple, enumOptions, optEmptyVal]
|
||||
);
|
||||
|
||||
const selectedIndexes = enumOptionsIndexForValue<S>(value, enumOptions, multiple);
|
||||
const showPlaceholderOption = !multiple && schema.default === undefined;
|
||||
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
name={id}
|
||||
multiple={multiple}
|
||||
className="form-control"
|
||||
value={typeof selectedIndexes === "undefined" ? emptyValue : selectedIndexes}
|
||||
required={required}
|
||||
disabled={disabled || readonly}
|
||||
autoFocus={autofocus}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onChange={handleChange}
|
||||
aria-describedby={ariaDescribedByIds<T>(id)}
|
||||
>
|
||||
{showPlaceholderOption && <option value="">{placeholder}</option>}
|
||||
{Array.isArray(enumOptions) &&
|
||||
enumOptions.map(({ value, label }, i) => {
|
||||
const disabled = enumDisabled && enumDisabled.indexOf(value) !== -1;
|
||||
return (
|
||||
<option key={i} value={String(i)} disabled={disabled}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectWidget;
|
||||
30
app/src/ui/components/form/json-schema/widgets/index.tsx
Normal file
30
app/src/ui/components/form/json-schema/widgets/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Label } from "../templates/FieldTemplate";
|
||||
import { CheckboxWidget } from "./CheckboxWidget";
|
||||
import CheckboxesWidget from "./CheckboxesWidget";
|
||||
import JsonWidget from "./JsonWidget";
|
||||
import RadioWidget from "./RadioWidget";
|
||||
import SelectWidget from "./SelectWidget";
|
||||
|
||||
const WithLabel = (WrappedComponent, kind?: string) => {
|
||||
return (props) => {
|
||||
const hideLabel =
|
||||
!props.label ||
|
||||
props.uiSchema["ui:options"]?.hideLabel ||
|
||||
props.options?.hideLabel ||
|
||||
props.hideLabel;
|
||||
return (
|
||||
<>
|
||||
{!hideLabel && <Label label={props.label} required={props.required} id={props.id} />}
|
||||
<WrappedComponent {...props} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const widgets = {
|
||||
RadioWidget: RadioWidget,
|
||||
CheckboxWidget: WithLabel(CheckboxWidget),
|
||||
SelectWidget: WithLabel(SelectWidget, "select"),
|
||||
CheckboxesWidget: WithLabel(CheckboxesWidget),
|
||||
JsonWidget: WithLabel(JsonWidget)
|
||||
};
|
||||
Reference in New Issue
Block a user