UI/modules: dynamic rendering of public module interfaces
This commit is contained in:
128
pkgs/webview-ui/app/src/Form/base/index.tsx
Normal file
128
pkgs/webview-ui/app/src/Form/base/index.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
||||||
|
import type {
|
||||||
|
ComputePositionConfig,
|
||||||
|
ComputePositionReturn,
|
||||||
|
ReferenceElement,
|
||||||
|
} from "@floating-ui/dom";
|
||||||
|
import { computePosition } from "@floating-ui/dom";
|
||||||
|
|
||||||
|
export interface UseFloatingOptions<
|
||||||
|
R extends ReferenceElement,
|
||||||
|
F extends HTMLElement,
|
||||||
|
> extends Partial<ComputePositionConfig> {
|
||||||
|
whileElementsMounted?: (
|
||||||
|
reference: R,
|
||||||
|
floating: F,
|
||||||
|
update: () => void,
|
||||||
|
) => // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||||
|
void | (() => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFloatingState extends Omit<ComputePositionReturn, "x" | "y"> {
|
||||||
|
x?: number | null;
|
||||||
|
y?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseFloatingResult extends UseFloatingState {
|
||||||
|
update(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
|
||||||
|
reference: () => R | undefined | null,
|
||||||
|
floating: () => F | undefined | null,
|
||||||
|
options?: UseFloatingOptions<R, F>,
|
||||||
|
): UseFloatingResult {
|
||||||
|
const placement = () => options?.placement ?? "bottom";
|
||||||
|
const strategy = () => options?.strategy ?? "absolute";
|
||||||
|
|
||||||
|
const [data, setData] = createSignal<UseFloatingState>({
|
||||||
|
x: null,
|
||||||
|
y: null,
|
||||||
|
placement: placement(),
|
||||||
|
strategy: strategy(),
|
||||||
|
middlewareData: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [error, setError] = createSignal<{ value: unknown } | undefined>();
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const currentError = error();
|
||||||
|
if (currentError) {
|
||||||
|
throw currentError.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const version = createMemo(() => {
|
||||||
|
reference();
|
||||||
|
floating();
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
const currentReference = reference();
|
||||||
|
const currentFloating = floating();
|
||||||
|
|
||||||
|
if (currentReference && currentFloating) {
|
||||||
|
const capturedVersion = version();
|
||||||
|
computePosition(currentReference, currentFloating, {
|
||||||
|
middleware: options?.middleware,
|
||||||
|
placement: placement(),
|
||||||
|
strategy: strategy(),
|
||||||
|
}).then(
|
||||||
|
(currentData) => {
|
||||||
|
// Check if it's still valid
|
||||||
|
if (capturedVersion === version()) {
|
||||||
|
setData(currentData);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
setError(err);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const currentReference = reference();
|
||||||
|
const currentFloating = floating();
|
||||||
|
|
||||||
|
options?.middleware;
|
||||||
|
placement();
|
||||||
|
strategy();
|
||||||
|
|
||||||
|
if (currentReference && currentFloating) {
|
||||||
|
if (options?.whileElementsMounted) {
|
||||||
|
const cleanup = options.whileElementsMounted(
|
||||||
|
currentReference,
|
||||||
|
currentFloating,
|
||||||
|
update,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cleanup) {
|
||||||
|
onCleanup(cleanup);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
get x() {
|
||||||
|
return data().x;
|
||||||
|
},
|
||||||
|
get y() {
|
||||||
|
return data().y;
|
||||||
|
},
|
||||||
|
get placement() {
|
||||||
|
return data().placement;
|
||||||
|
},
|
||||||
|
get strategy() {
|
||||||
|
return data().strategy;
|
||||||
|
},
|
||||||
|
get middlewareData() {
|
||||||
|
return data().middlewareData;
|
||||||
|
},
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
}
|
||||||
15
pkgs/webview-ui/app/src/Form/base/label.tsx
Normal file
15
pkgs/webview-ui/app/src/Form/base/label.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { JSX } from "solid-js";
|
||||||
|
interface LabelProps {
|
||||||
|
label: JSX.Element;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
export const Label = (props: LabelProps) => (
|
||||||
|
<span
|
||||||
|
class="label-text block"
|
||||||
|
classList={{
|
||||||
|
"after:ml-0.5 after:text-primary after:content-['*']": props.required,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
8
pkgs/webview-ui/app/src/Form/fields/FormSection.tsx
Normal file
8
pkgs/webview-ui/app/src/Form/fields/FormSection.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { JSX } from "solid-js";
|
||||||
|
|
||||||
|
interface FormSectionProps {
|
||||||
|
children: JSX.Element;
|
||||||
|
}
|
||||||
|
export const FormSection = (props: FormSectionProps) => {
|
||||||
|
return <div class="p-2">{props.children}</div>;
|
||||||
|
};
|
||||||
226
pkgs/webview-ui/app/src/Form/fields/Select.tsx
Normal file
226
pkgs/webview-ui/app/src/Form/fields/Select.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import {
|
||||||
|
createUniqueId,
|
||||||
|
createSignal,
|
||||||
|
Show,
|
||||||
|
type JSX,
|
||||||
|
For,
|
||||||
|
createMemo,
|
||||||
|
} from "solid-js";
|
||||||
|
import { Portal } from "solid-js/web";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { Label } from "../base/label";
|
||||||
|
import { useFloating } from "../base";
|
||||||
|
import { autoUpdate, flip, hide, shift, size } from "@floating-ui/dom";
|
||||||
|
|
||||||
|
export type Option = { value: string; label: string };
|
||||||
|
interface SelectInputpProps {
|
||||||
|
value: string[] | string;
|
||||||
|
selectProps: JSX.InputHTMLAttributes<HTMLSelectElement>;
|
||||||
|
options: Option[];
|
||||||
|
label: JSX.Element;
|
||||||
|
altLabel?: JSX.Element;
|
||||||
|
helperText?: JSX.Element;
|
||||||
|
error?: string;
|
||||||
|
required?: boolean;
|
||||||
|
type?: string;
|
||||||
|
inlineLabel?: JSX.Element;
|
||||||
|
class?: string;
|
||||||
|
adornment?: {
|
||||||
|
position: "start" | "end";
|
||||||
|
content: JSX.Element;
|
||||||
|
};
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectInput(props: SelectInputpProps) {
|
||||||
|
const _id = createUniqueId();
|
||||||
|
|
||||||
|
const [reference, setReference] = createSignal<HTMLElement>();
|
||||||
|
const [floating, setFloating] = createSignal<HTMLElement>();
|
||||||
|
|
||||||
|
// `position` is a reactive object.
|
||||||
|
const position = useFloating(reference, floating, {
|
||||||
|
placement: "bottom-start",
|
||||||
|
|
||||||
|
// pass options. Ensure the cleanup function is returned.
|
||||||
|
whileElementsMounted: (reference, floating, update) =>
|
||||||
|
autoUpdate(reference, floating, update, {
|
||||||
|
animationFrame: true,
|
||||||
|
}),
|
||||||
|
middleware: [
|
||||||
|
size({
|
||||||
|
apply({ rects, elements }) {
|
||||||
|
Object.assign(elements.floating.style, {
|
||||||
|
minWidth: `${rects.reference.width}px`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
shift(),
|
||||||
|
flip(),
|
||||||
|
hide({
|
||||||
|
strategy: "referenceHidden",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create values list
|
||||||
|
const getValues = createMemo(() => {
|
||||||
|
return Array.isArray(props.value)
|
||||||
|
? (props.value as string[])
|
||||||
|
: typeof props.value === "string"
|
||||||
|
? [props.value]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// const getSingleValue = createMemo(() => {
|
||||||
|
// const values = getValues();
|
||||||
|
// return values.length > 0 ? values[0] : "";
|
||||||
|
// });
|
||||||
|
|
||||||
|
const handleClickOption = (opt: Option) => {
|
||||||
|
if (!props.multiple) {
|
||||||
|
// @ts-ignore
|
||||||
|
props.selectProps.onInput({
|
||||||
|
currentTarget: {
|
||||||
|
value: opt.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let currValues = getValues();
|
||||||
|
|
||||||
|
if (currValues.includes(opt.value)) {
|
||||||
|
currValues = currValues.filter((o) => o !== opt.value);
|
||||||
|
} else {
|
||||||
|
currValues.push(opt.value);
|
||||||
|
}
|
||||||
|
// @ts-ignore
|
||||||
|
props.selectProps.onInput({
|
||||||
|
currentTarget: {
|
||||||
|
options: currValues.map((value) => ({
|
||||||
|
value,
|
||||||
|
selected: true,
|
||||||
|
disabled: false,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label
|
||||||
|
class={cx("form-control w-full", props.class)}
|
||||||
|
aria-disabled={props.disabled}
|
||||||
|
>
|
||||||
|
<div class="label">
|
||||||
|
<Label label={props.label} required={props.required} />
|
||||||
|
<span class="label-text-alt block">{props.altLabel}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="select select-bordered flex items-center gap-2"
|
||||||
|
ref={setReference}
|
||||||
|
popovertarget={_id}
|
||||||
|
>
|
||||||
|
<Show when={props.adornment && props.adornment.position === "start"}>
|
||||||
|
{props.adornment?.content}
|
||||||
|
</Show>
|
||||||
|
{props.inlineLabel}
|
||||||
|
<div class="flex flex-row gap-2 cursor-default">
|
||||||
|
<For each={getValues()} fallback={"Select"}>
|
||||||
|
{(item) => (
|
||||||
|
<div class="text-sm rounded-xl bg-slate-800 text-white px-4 py-1">
|
||||||
|
{item}
|
||||||
|
<Show when={props.multiple}>
|
||||||
|
<button
|
||||||
|
class="btn-xs btn-ghost"
|
||||||
|
type="button"
|
||||||
|
onClick={(_e) => {
|
||||||
|
// @ts-ignore
|
||||||
|
props.selectProps.onInput({
|
||||||
|
currentTarget: {
|
||||||
|
options: getValues()
|
||||||
|
.filter((o) => o !== item)
|
||||||
|
.map((value) => ({
|
||||||
|
value,
|
||||||
|
selected: true,
|
||||||
|
disabled: false,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
X
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
class="hidden"
|
||||||
|
multiple
|
||||||
|
{...props.selectProps}
|
||||||
|
required={props.required}
|
||||||
|
>
|
||||||
|
<For each={props.options}>
|
||||||
|
{({ label, value }) => (
|
||||||
|
<option value={value} selected={getValues().includes(value)}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</select>
|
||||||
|
<Show when={props.adornment && props.adornment.position === "end"}>
|
||||||
|
{props.adornment?.content}
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
<Portal mount={document.body}>
|
||||||
|
<div
|
||||||
|
id={_id}
|
||||||
|
popover
|
||||||
|
ref={setFloating}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
position: position.strategy,
|
||||||
|
top: `${position.y ?? 0}px`,
|
||||||
|
left: `${position.x ?? 0}px`,
|
||||||
|
}}
|
||||||
|
class="dropdown-content bg-base-100 rounded-b-box z-[1] shadow"
|
||||||
|
>
|
||||||
|
<ul class="menu flex flex-col gap-1 max-h-96 overflow-y-scroll overflow-x-hidden">
|
||||||
|
<For each={props.options}>
|
||||||
|
{(opt) => (
|
||||||
|
<>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => handleClickOption(opt)}
|
||||||
|
classList={{
|
||||||
|
active: getValues().includes(opt.value),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
<div class="label">
|
||||||
|
{props.helperText && (
|
||||||
|
<span class="label-text text-neutral">{props.helperText}</span>
|
||||||
|
)}
|
||||||
|
{props.error && (
|
||||||
|
<span class="label-text-alt font-bold text-error">
|
||||||
|
{props.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
pkgs/webview-ui/app/src/Form/fields/TextInput.tsx
Normal file
68
pkgs/webview-ui/app/src/Form/fields/TextInput.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { createEffect, Show, type JSX } from "solid-js";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { Label } from "../base/label";
|
||||||
|
|
||||||
|
interface TextInputProps {
|
||||||
|
value: string;
|
||||||
|
inputProps?: JSX.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
label: JSX.Element;
|
||||||
|
altLabel?: JSX.Element;
|
||||||
|
helperText?: JSX.Element;
|
||||||
|
error?: string;
|
||||||
|
required?: boolean;
|
||||||
|
type?: string;
|
||||||
|
inlineLabel?: JSX.Element;
|
||||||
|
class?: string;
|
||||||
|
adornment?: {
|
||||||
|
position: "start" | "end";
|
||||||
|
content: JSX.Element;
|
||||||
|
};
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextInput(props: TextInputProps) {
|
||||||
|
const value = () => props.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
class={cx("form-control w-full", props.class)}
|
||||||
|
aria-disabled={props.disabled}
|
||||||
|
>
|
||||||
|
<div class="label">
|
||||||
|
<Label label={props.label} required={props.required} />
|
||||||
|
<span class="label-text-alt block">{props.altLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input input-bordered flex items-center gap-2">
|
||||||
|
<Show when={props.adornment && props.adornment.position === "start"}>
|
||||||
|
{props.adornment?.content}
|
||||||
|
</Show>
|
||||||
|
{props.inlineLabel}
|
||||||
|
<input
|
||||||
|
{...props.inputProps}
|
||||||
|
value={value()}
|
||||||
|
type={props.type ? props.type : "text"}
|
||||||
|
class="grow"
|
||||||
|
classList={{
|
||||||
|
"input-disabled": props.disabled,
|
||||||
|
}}
|
||||||
|
placeholder={`${props.placeholder || props.label}`}
|
||||||
|
required
|
||||||
|
disabled={props.disabled}
|
||||||
|
/>
|
||||||
|
<Show when={props.adornment && props.adornment.position === "end"}>
|
||||||
|
{props.adornment?.content}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="label">
|
||||||
|
{props.helperText && (
|
||||||
|
<span class="label-text text-neutral">{props.helperText}</span>
|
||||||
|
)}
|
||||||
|
{props.error && (
|
||||||
|
<span class="label-text-alt font-bold text-error">{props.error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
pkgs/webview-ui/app/src/Form/fields/index.ts
Normal file
2
pkgs/webview-ui/app/src/Form/fields/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./FormSection";
|
||||||
|
export * from "./TextInput";
|
||||||
896
pkgs/webview-ui/app/src/Form/form/index.tsx
Normal file
896
pkgs/webview-ui/app/src/Form/form/index.tsx
Normal file
@@ -0,0 +1,896 @@
|
|||||||
|
import {
|
||||||
|
createForm,
|
||||||
|
Field,
|
||||||
|
FieldArray,
|
||||||
|
FieldElement,
|
||||||
|
FieldValues,
|
||||||
|
FormStore,
|
||||||
|
getValue,
|
||||||
|
minLength,
|
||||||
|
pattern,
|
||||||
|
ResponseData,
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
|
insert,
|
||||||
|
SubmitHandler,
|
||||||
|
swap,
|
||||||
|
reset,
|
||||||
|
remove,
|
||||||
|
move,
|
||||||
|
setError,
|
||||||
|
setValues,
|
||||||
|
} from "@modular-forms/solid";
|
||||||
|
import { JSONSchema7, JSONSchema7Type, validate } from "json-schema";
|
||||||
|
import { TextInput } from "../fields/TextInput";
|
||||||
|
import {
|
||||||
|
children,
|
||||||
|
Component,
|
||||||
|
createEffect,
|
||||||
|
For,
|
||||||
|
JSX,
|
||||||
|
Match,
|
||||||
|
Show,
|
||||||
|
Switch,
|
||||||
|
} from "solid-js";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { Label } from "../base/label";
|
||||||
|
import { SelectInput } from "../fields/Select";
|
||||||
|
|
||||||
|
function generateDefaults(schema: JSONSchema7): any {
|
||||||
|
switch (schema.type) {
|
||||||
|
case "string":
|
||||||
|
return ""; // Default value for string
|
||||||
|
|
||||||
|
case "number":
|
||||||
|
case "integer":
|
||||||
|
return 0; // Default value for number/integer
|
||||||
|
|
||||||
|
case "boolean":
|
||||||
|
return false; // Default value for boolean
|
||||||
|
|
||||||
|
case "array":
|
||||||
|
return []; // Default empty array if no items schema or items is true/false
|
||||||
|
|
||||||
|
case "object":
|
||||||
|
const obj: { [key: string]: any } = {};
|
||||||
|
if (schema.properties) {
|
||||||
|
Object.entries(schema.properties).forEach(([key, propSchema]) => {
|
||||||
|
if (typeof propSchema === "boolean") {
|
||||||
|
} else {
|
||||||
|
// if (schema.required schema.required.includes(key))
|
||||||
|
obj[key] = generateDefaults(propSchema);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null; // Default for unknown types or nulls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormProps {
|
||||||
|
schema: JSONSchema7;
|
||||||
|
initialValues?: NonNullable<unknown>;
|
||||||
|
handleSubmit?: SubmitHandler<NonNullable<unknown>>;
|
||||||
|
initialPath?: string[];
|
||||||
|
components?: {
|
||||||
|
before?: JSX.Element;
|
||||||
|
after?: JSX.Element;
|
||||||
|
};
|
||||||
|
readonly?: boolean;
|
||||||
|
formProps?: JSX.InputHTMLAttributes<HTMLFormElement>;
|
||||||
|
errorContext?: string;
|
||||||
|
resetOnSubmit?: boolean;
|
||||||
|
}
|
||||||
|
export const DynForm = (props: FormProps) => {
|
||||||
|
const [formStore, { Field, Form: ModuleForm }] = createForm({
|
||||||
|
initialValues: props.initialValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit: SubmitHandler<NonNullable<unknown>> = async (
|
||||||
|
values,
|
||||||
|
event,
|
||||||
|
) => {
|
||||||
|
console.log("Submitting form values", values, props.errorContext);
|
||||||
|
props.handleSubmit?.(values, event);
|
||||||
|
// setValue(formStore, "root", null);
|
||||||
|
if (props.resetOnSubmit) {
|
||||||
|
console.log("Resetting form", values, props.initialValues);
|
||||||
|
reset(formStore);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
console.log("FormStore", formStore);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ModuleForm {...props.formProps} onSubmit={handleSubmit}>
|
||||||
|
{props.components?.before}
|
||||||
|
<SchemaFields
|
||||||
|
schema={props.schema}
|
||||||
|
Field={Field}
|
||||||
|
formStore={formStore}
|
||||||
|
path={props.initialPath || []}
|
||||||
|
readonly={!!props.readonly}
|
||||||
|
parent={props.schema}
|
||||||
|
/>
|
||||||
|
{props.components?.after}
|
||||||
|
</ModuleForm>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UnsupportedProps {
|
||||||
|
schema: JSONSchema7;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Unsupported = (props: UnsupportedProps) => (
|
||||||
|
<div>
|
||||||
|
{props.error && <div class="font-bold text-error">{props.error}</div>}
|
||||||
|
<span>
|
||||||
|
Invalid or unsupported schema entry of type:{" "}
|
||||||
|
<b>{JSON.stringify(props.schema.type)}</b>
|
||||||
|
</span>
|
||||||
|
<pre>
|
||||||
|
<code>{JSON.stringify(props.schema, null, 2)}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SchemaFieldsProps<T extends FieldValues, R extends ResponseData> {
|
||||||
|
formStore: FormStore<T, R>;
|
||||||
|
Field: typeof Field<T, R, never>;
|
||||||
|
schema: JSONSchema7;
|
||||||
|
path: string[];
|
||||||
|
readonly: boolean;
|
||||||
|
parent: JSONSchema7;
|
||||||
|
}
|
||||||
|
export function SchemaFields<T extends FieldValues, R extends ResponseData>(
|
||||||
|
props: SchemaFieldsProps<T, R>,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Switch fallback={<Unsupported schema={props.schema} />}>
|
||||||
|
{/* Simple types */}
|
||||||
|
<Match when={props.schema.type === "boolean"}>bool</Match>
|
||||||
|
|
||||||
|
<Match when={props.schema.type === "integer"}>
|
||||||
|
<StringField {...props} schema={props.schema} />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.schema.type === "number"}>
|
||||||
|
<StringField {...props} schema={props.schema} />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.schema.type === "string"}>
|
||||||
|
<StringField {...props} schema={props.schema} />
|
||||||
|
</Match>
|
||||||
|
{/* Composed types */}
|
||||||
|
<Match when={props.schema.type === "array"}>
|
||||||
|
<ArrayFields {...props} schema={props.schema} />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.schema.type === "object"}>
|
||||||
|
<ObjectFields {...props} schema={props.schema} />
|
||||||
|
</Match>
|
||||||
|
{/* Empty / Null */}
|
||||||
|
<Match when={props.schema.type === "null"}>
|
||||||
|
Dont know how to rendner InputType null
|
||||||
|
<Unsupported schema={props.schema} />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StringField<T extends FieldValues, R extends ResponseData>(
|
||||||
|
props: SchemaFieldsProps<T, R>,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
props.schema.type !== "string" &&
|
||||||
|
props.schema.type !== "number" &&
|
||||||
|
props.schema.type !== "integer"
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<span class="text-error">
|
||||||
|
Error cannot render the following as String input.
|
||||||
|
<Unsupported schema={props.schema} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { Field } = props;
|
||||||
|
|
||||||
|
const validate = props.schema.pattern
|
||||||
|
? pattern(
|
||||||
|
new RegExp(props.schema.pattern),
|
||||||
|
`String should follow pattern ${props.schema.pattern}`,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
label: props.schema.title || props.path.join("."),
|
||||||
|
required:
|
||||||
|
props.parent.required &&
|
||||||
|
props.parent.required.some(
|
||||||
|
(r) => r === props.path[props.path.length - 1],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const readonly = props.readonly;
|
||||||
|
return (
|
||||||
|
<Switch fallback={<Unsupported schema={props.schema} />}>
|
||||||
|
<Match
|
||||||
|
when={props.schema.type === "number" || props.schema.type === "integer"}
|
||||||
|
>
|
||||||
|
{(s) => (
|
||||||
|
<Field
|
||||||
|
// @ts-expect-error: We dont know dynamic names while type checking
|
||||||
|
name={props.path.join(".")}
|
||||||
|
validate={validate}
|
||||||
|
>
|
||||||
|
{(field, fieldProps) => (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
inputProps={{
|
||||||
|
...fieldProps,
|
||||||
|
inputmode: "numeric",
|
||||||
|
pattern: "[0-9.]*",
|
||||||
|
readonly,
|
||||||
|
}}
|
||||||
|
{...commonProps}
|
||||||
|
value={(field.value as unknown as string) || ""}
|
||||||
|
error={field.error}
|
||||||
|
// required
|
||||||
|
// altLabel="Leave empty to accept the default"
|
||||||
|
// helperText="Configure how dude connects"
|
||||||
|
// error="Something is wrong now"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
<Match when={props.schema.enum}>
|
||||||
|
{(_enumSchemas) => (
|
||||||
|
<Field
|
||||||
|
// @ts-expect-error: We dont know dynamic names while type checking
|
||||||
|
name={props.path.join(".")}
|
||||||
|
>
|
||||||
|
{(field, fieldProps) => (
|
||||||
|
<OnlyStringItems itemspec={props.schema}>
|
||||||
|
{(options) => (
|
||||||
|
<SelectInput
|
||||||
|
error={field.error}
|
||||||
|
altLabel={props.schema.title}
|
||||||
|
label={props.path.join(".")}
|
||||||
|
helperText={props.schema.description}
|
||||||
|
value={field.value || []}
|
||||||
|
options={options.map((o) => ({
|
||||||
|
value: o,
|
||||||
|
label: o,
|
||||||
|
}))}
|
||||||
|
selectProps={fieldProps}
|
||||||
|
required={!!props.schema.minItems}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</OnlyStringItems>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
<Match when={props.schema.writeOnly && props.schema}>
|
||||||
|
{(s) => (
|
||||||
|
<Field
|
||||||
|
// @ts-expect-error: We dont know dynamic names while type checking
|
||||||
|
name={props.path.join(".")}
|
||||||
|
validate={validate}
|
||||||
|
>
|
||||||
|
{(field, fieldProps) => (
|
||||||
|
<TextInput
|
||||||
|
inputProps={{ ...fieldProps, readonly }}
|
||||||
|
value={field.value as unknown as string}
|
||||||
|
type="password"
|
||||||
|
error={field.error}
|
||||||
|
{...commonProps}
|
||||||
|
// required
|
||||||
|
// altLabel="Leave empty to accept the default"
|
||||||
|
// helperText="Configure how dude connects"
|
||||||
|
// error="Something is wrong now"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
{/* TODO: when is it a normal string input? */}
|
||||||
|
<Match when={props.schema}>
|
||||||
|
{(s) => (
|
||||||
|
<Field
|
||||||
|
// @ts-expect-error: We dont know dynamic names while type checking
|
||||||
|
name={props.path.join(".")}
|
||||||
|
validate={validate}
|
||||||
|
>
|
||||||
|
{(field, fieldProps) => (
|
||||||
|
<TextInput
|
||||||
|
inputProps={{ ...fieldProps, readonly }}
|
||||||
|
value={field.value as unknown as string}
|
||||||
|
error={field.error}
|
||||||
|
{...commonProps}
|
||||||
|
// placeholder="foobar"
|
||||||
|
// inlineLabel={
|
||||||
|
// <div class="label">
|
||||||
|
// <span class="label-text"></span>
|
||||||
|
// </div>
|
||||||
|
// }
|
||||||
|
// required
|
||||||
|
// altLabel="Leave empty to accept the default"
|
||||||
|
// helperText="Configure how dude connects"
|
||||||
|
// error="Something is wrong now"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OptionSchemaProps {
|
||||||
|
itemSpec: JSONSchema7Type;
|
||||||
|
}
|
||||||
|
export function OptionSchema(props: OptionSchemaProps) {
|
||||||
|
return (
|
||||||
|
<Switch fallback={<option class="text-error">Item spec unhandled</option>}>
|
||||||
|
<Match when={typeof props.itemSpec === "string" && props.itemSpec}>
|
||||||
|
{(o) => <option>{o()}</option>}
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValueDisplayProps<T extends FieldValues, R extends ResponseData>
|
||||||
|
extends SchemaFieldsProps<T, R> {
|
||||||
|
children: JSX.Element;
|
||||||
|
listFieldName: string;
|
||||||
|
idx: number;
|
||||||
|
of: number;
|
||||||
|
}
|
||||||
|
export function ListValueDisplay<T extends FieldValues, R extends ResponseData>(
|
||||||
|
props: ValueDisplayProps<T, R>,
|
||||||
|
) {
|
||||||
|
const removeItem = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
remove(
|
||||||
|
props.formStore,
|
||||||
|
// @ts-ignore
|
||||||
|
props.listFieldName,
|
||||||
|
{ at: props.idx },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const moveItemBy = (dir: number) => (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
move(
|
||||||
|
props.formStore,
|
||||||
|
// @ts-ignore
|
||||||
|
props.listFieldName,
|
||||||
|
{ from: props.idx, to: props.idx + dir },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const topMost = () => props.idx === props.of - 1;
|
||||||
|
const bottomMost = () => props.idx === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="border-l-4 border-gray-300 w-full">
|
||||||
|
<div class="flex w-full gap-2 items-end px-4">
|
||||||
|
{props.children}
|
||||||
|
<div class="pb-4 ml-4 min-w-fit">
|
||||||
|
<button class="btn" onClick={moveItemBy(1)} disabled={topMost()}>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button class="btn" onClick={moveItemBy(-1)} disabled={bottomMost()}>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-error" onClick={removeItem}>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const findDuplicates = (arr: any[]) => {
|
||||||
|
const seen = new Set();
|
||||||
|
const duplicates: number[] = [];
|
||||||
|
|
||||||
|
arr.forEach((obj, idx) => {
|
||||||
|
const serializedObj = JSON.stringify(obj);
|
||||||
|
|
||||||
|
if (seen.has(serializedObj)) {
|
||||||
|
duplicates.push(idx);
|
||||||
|
} else {
|
||||||
|
seen.add(serializedObj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return duplicates;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OnlyStringItems {
|
||||||
|
children: (items: string[]) => JSX.Element;
|
||||||
|
itemspec: JSONSchema7;
|
||||||
|
}
|
||||||
|
const OnlyStringItems = (props: OnlyStringItems) => {
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
Array.isArray(props.itemspec.enum) &&
|
||||||
|
typeof props.itemspec.type === "string" &&
|
||||||
|
props.itemspec
|
||||||
|
}
|
||||||
|
fallback={
|
||||||
|
<Unsupported
|
||||||
|
schema={props.itemspec}
|
||||||
|
error="Unsupported array item type"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children(props.itemspec.enum as string[])}
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ArrayFields<T extends FieldValues, R extends ResponseData>(
|
||||||
|
props: SchemaFieldsProps<T, R>,
|
||||||
|
) {
|
||||||
|
if (props.schema.type !== "array") {
|
||||||
|
return (
|
||||||
|
<span class="text-error">
|
||||||
|
Error cannot render the following as array.
|
||||||
|
<Unsupported schema={props.schema} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { Field } = props;
|
||||||
|
|
||||||
|
const listFieldName = props.path.join(".");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Switch fallback={<Unsupported schema={props.schema} />}>
|
||||||
|
<Match
|
||||||
|
when={
|
||||||
|
!Array.isArray(props.schema.items) &&
|
||||||
|
typeof props.schema.items === "object" &&
|
||||||
|
props.schema.items
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(itemsSchema) => (
|
||||||
|
<>
|
||||||
|
<Switch fallback={<Unsupported schema={props.schema} />}>
|
||||||
|
<Match when={itemsSchema().type === "array"}>
|
||||||
|
<Unsupported
|
||||||
|
schema={props.schema}
|
||||||
|
error="Array of Array is not supported yet."
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match
|
||||||
|
when={itemsSchema().type === "string" && itemsSchema().enum}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
// @ts-ignore
|
||||||
|
name={listFieldName}
|
||||||
|
// @ts-ignore
|
||||||
|
type="string[]"
|
||||||
|
validateOn="touched"
|
||||||
|
revalidateOn="touched"
|
||||||
|
validate={() => {
|
||||||
|
let error = "";
|
||||||
|
// @ts-ignore
|
||||||
|
const values: any[] = getValues(
|
||||||
|
props.formStore,
|
||||||
|
// @ts-ignore
|
||||||
|
listFieldName,
|
||||||
|
// @ts-ignore
|
||||||
|
)?.strings?.selection;
|
||||||
|
console.log("vali", { values });
|
||||||
|
if (props.schema.uniqueItems) {
|
||||||
|
const duplicates = findDuplicates(values);
|
||||||
|
if (duplicates.length) {
|
||||||
|
error = `Duplicate entries are not allowed. Please make sure each entry is unique.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
props.schema.maxItems &&
|
||||||
|
values.length > props.schema.maxItems
|
||||||
|
) {
|
||||||
|
error = `You can only select up to ${props.schema.maxItems} items`;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
props.schema.minItems &&
|
||||||
|
values.length < props.schema.minItems
|
||||||
|
) {
|
||||||
|
error = `Please select at least ${props.schema.minItems} items.`;
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(field, fieldProps) => (
|
||||||
|
<OnlyStringItems itemspec={itemsSchema()}>
|
||||||
|
{(options) => (
|
||||||
|
<SelectInput
|
||||||
|
multiple
|
||||||
|
error={field.error}
|
||||||
|
altLabel={props.schema.title}
|
||||||
|
label={listFieldName}
|
||||||
|
helperText={props.schema.description}
|
||||||
|
value={field.value || ""}
|
||||||
|
options={options.map((o) => ({
|
||||||
|
value: o,
|
||||||
|
label: o,
|
||||||
|
}))}
|
||||||
|
selectProps={fieldProps}
|
||||||
|
required={!!props.schema.minItems}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</OnlyStringItems>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</Match>
|
||||||
|
|
||||||
|
<Match
|
||||||
|
when={
|
||||||
|
itemsSchema().type === "string" ||
|
||||||
|
itemsSchema().type === "object"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* !Important: Register the parent field to gain access to array items*/}
|
||||||
|
<FieldArray
|
||||||
|
// @ts-ignore
|
||||||
|
name={listFieldName}
|
||||||
|
of={props.formStore}
|
||||||
|
validateOn="touched"
|
||||||
|
revalidateOn="touched"
|
||||||
|
validate={() => {
|
||||||
|
let error = "";
|
||||||
|
// @ts-ignore
|
||||||
|
const values: any[] = getValues(
|
||||||
|
props.formStore,
|
||||||
|
// @ts-ignore
|
||||||
|
listFieldName,
|
||||||
|
);
|
||||||
|
if (props.schema.uniqueItems) {
|
||||||
|
const duplicates = findDuplicates(values);
|
||||||
|
if (duplicates.length) {
|
||||||
|
error = `Duplicate entries are not allowed. Please make sure each entry is unique.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
props.schema.maxItems &&
|
||||||
|
values.length > props.schema.maxItems
|
||||||
|
) {
|
||||||
|
error = `You can only add up to ${props.schema.maxItems} items`;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
props.schema.minItems &&
|
||||||
|
values.length < props.schema.minItems
|
||||||
|
) {
|
||||||
|
error = `Please add at least ${props.schema.minItems} items.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(fieldArray) => (
|
||||||
|
<>
|
||||||
|
{/* Render existing items */}
|
||||||
|
<For
|
||||||
|
each={fieldArray.items}
|
||||||
|
fallback={
|
||||||
|
// Empty list
|
||||||
|
<span class="text-neutral-500">No items</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(item, idx) => (
|
||||||
|
<ListValueDisplay
|
||||||
|
{...props}
|
||||||
|
listFieldName={listFieldName}
|
||||||
|
idx={idx()}
|
||||||
|
of={fieldArray.items.length}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
// @ts-ignore: field names are not know ahead of time
|
||||||
|
name={`${listFieldName}.${idx()}`}
|
||||||
|
>
|
||||||
|
{(f, fp) => (
|
||||||
|
<>
|
||||||
|
<DynForm
|
||||||
|
formProps={{
|
||||||
|
class: cx("w-full"),
|
||||||
|
}}
|
||||||
|
resetOnSubmit={true}
|
||||||
|
schema={itemsSchema()}
|
||||||
|
initialValues={
|
||||||
|
itemsSchema().type === "object"
|
||||||
|
? f.value
|
||||||
|
: { "": f.value }
|
||||||
|
}
|
||||||
|
readonly={true}
|
||||||
|
></DynForm>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</ListValueDisplay>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<span class="label-text-alt font-bold text-error">
|
||||||
|
{fieldArray.error}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Add new item */}
|
||||||
|
<DynForm
|
||||||
|
formProps={{
|
||||||
|
class: cx("px-2 w-full"),
|
||||||
|
}}
|
||||||
|
schema={{ ...itemsSchema(), title: "Add entry" }}
|
||||||
|
initialPath={["root"]}
|
||||||
|
// Reset the input field for list items
|
||||||
|
resetOnSubmit={true}
|
||||||
|
initialValues={{
|
||||||
|
root: generateDefaults(itemsSchema()),
|
||||||
|
}}
|
||||||
|
// Button for adding new items
|
||||||
|
components={{
|
||||||
|
before: <button class="btn">Add ↑</button>,
|
||||||
|
}}
|
||||||
|
// Add the new item to the FieldArray
|
||||||
|
handleSubmit={(values, event) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const prev: any[] = getValues(
|
||||||
|
props.formStore,
|
||||||
|
// @ts-ignore
|
||||||
|
listFieldName,
|
||||||
|
);
|
||||||
|
if (itemsSchema().type === "object") {
|
||||||
|
const newIdx = prev.length;
|
||||||
|
setValue(
|
||||||
|
props.formStore,
|
||||||
|
// @ts-ignore
|
||||||
|
`${listFieldName}.${newIdx}`,
|
||||||
|
// @ts-ignore
|
||||||
|
values.root,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// @ts-ignore
|
||||||
|
insert(props.formStore, listFieldName, {
|
||||||
|
// @ts-ignore
|
||||||
|
value: values.root,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FieldArray>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ObjectFieldPropertyLabelProps {
|
||||||
|
schema: JSONSchema7;
|
||||||
|
fallback: JSX.Element;
|
||||||
|
}
|
||||||
|
export function ObjectFieldPropertyLabel(props: ObjectFieldPropertyLabelProps) {
|
||||||
|
return (
|
||||||
|
<Switch fallback={props.fallback}>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<Match when={props.schema?.$exportedModuleInfo?.path}>
|
||||||
|
{(path) => path()[path().length - 1]}
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObjectFields<T extends FieldValues, R extends ResponseData>(
|
||||||
|
props: SchemaFieldsProps<T, R>,
|
||||||
|
) {
|
||||||
|
if (props.schema.type !== "object") {
|
||||||
|
return (
|
||||||
|
<span class="text-error">
|
||||||
|
Error cannot render the following as Object
|
||||||
|
<Unsupported schema={props.schema} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldName = props.path.join(".");
|
||||||
|
const { Field } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
fallback={
|
||||||
|
<Unsupported
|
||||||
|
schema={props.schema}
|
||||||
|
error="Dont know how to render objectFields"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Match
|
||||||
|
when={!props.schema.additionalProperties && props.schema.properties}
|
||||||
|
>
|
||||||
|
{(properties) => (
|
||||||
|
<For each={Object.entries(properties())}>
|
||||||
|
{([propName, propSchema]) => (
|
||||||
|
<div
|
||||||
|
class={cx(
|
||||||
|
"w-full grid grid-cols-1 gap-4 justify-items-start",
|
||||||
|
`p-${props.path.length * 2}`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
label={propName}
|
||||||
|
required={props.schema.required?.some((r) => r === propName)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{typeof propSchema === "object" && (
|
||||||
|
<SchemaFields
|
||||||
|
{...props}
|
||||||
|
schema={propSchema}
|
||||||
|
path={[...props.path, propName]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{typeof propSchema === "boolean" && (
|
||||||
|
<span class="text-error">
|
||||||
|
Schema: Object of Boolean not supported
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
{/* Objects where people can define their own keys
|
||||||
|
- Trivial Key-value pairs. Where the value is a string a number or a list of strings (trivial select).
|
||||||
|
- Non-trivial Key-value pairs. Where the value is an object or a list
|
||||||
|
*/}
|
||||||
|
<Match
|
||||||
|
when={
|
||||||
|
typeof props.schema.additionalProperties === "object" &&
|
||||||
|
props.schema.additionalProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(additionalPropertiesSchema) => (
|
||||||
|
<Switch
|
||||||
|
fallback={
|
||||||
|
<Unsupported
|
||||||
|
schema={additionalPropertiesSchema()}
|
||||||
|
error="type of additionalProperties not supported yet"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Non-trivival cases */}
|
||||||
|
<Match
|
||||||
|
when={
|
||||||
|
additionalPropertiesSchema().type === "object" &&
|
||||||
|
additionalPropertiesSchema()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(itemSchema) => (
|
||||||
|
<Field
|
||||||
|
// Important!: Register the object field to gain access to the dynamic object properties
|
||||||
|
// @ts-ignore
|
||||||
|
name={fieldName}
|
||||||
|
>
|
||||||
|
{(objectField, fp) => (
|
||||||
|
<>
|
||||||
|
<For
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<label class="label-text">
|
||||||
|
No{" "}
|
||||||
|
<ObjectFieldPropertyLabel
|
||||||
|
schema={itemSchema()}
|
||||||
|
fallback={"No entries"}
|
||||||
|
/>{" "}
|
||||||
|
yet.
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
each={Object.entries(objectField.value || {})}
|
||||||
|
>
|
||||||
|
{([key, relatedValue]) => (
|
||||||
|
<Field
|
||||||
|
// @ts-ignore
|
||||||
|
name={`${fieldName}.${key}`}
|
||||||
|
>
|
||||||
|
{(f, fp) => (
|
||||||
|
<div class="border-l-4 border-gray-300 pl-4 w-full">
|
||||||
|
<DynForm
|
||||||
|
formProps={{
|
||||||
|
class: cx("w-full"),
|
||||||
|
}}
|
||||||
|
schema={itemSchema()}
|
||||||
|
initialValues={f.value}
|
||||||
|
components={{
|
||||||
|
before: (
|
||||||
|
<div class="flex w-full">
|
||||||
|
<span class="text-xl font-semibold">
|
||||||
|
{key}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-warning ml-auto"
|
||||||
|
type="button"
|
||||||
|
onClick={(_e) => {
|
||||||
|
const copy = {
|
||||||
|
// @ts-ignore
|
||||||
|
...objectField.value,
|
||||||
|
};
|
||||||
|
delete copy[key];
|
||||||
|
setValue(
|
||||||
|
props.formStore,
|
||||||
|
// @ts-ignore
|
||||||
|
`${fieldName}`,
|
||||||
|
// @ts-ignore
|
||||||
|
copy,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
{/* Replace this with a normal input ?*/}
|
||||||
|
<DynForm
|
||||||
|
formProps={{
|
||||||
|
class: cx("w-full"),
|
||||||
|
}}
|
||||||
|
resetOnSubmit={true}
|
||||||
|
initialValues={{ "": "" }}
|
||||||
|
schema={{
|
||||||
|
type: "string",
|
||||||
|
title: `Entry title or key`,
|
||||||
|
}}
|
||||||
|
handleSubmit={(values, event) => {
|
||||||
|
setValue(
|
||||||
|
props.formStore,
|
||||||
|
// @ts-ignore
|
||||||
|
`${fieldName}`,
|
||||||
|
// @ts-ignore
|
||||||
|
{ ...objectField.value, [values[""]]: {} },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
<Match
|
||||||
|
when={
|
||||||
|
additionalPropertiesSchema().type === "array" &&
|
||||||
|
additionalPropertiesSchema()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(itemSchema) => (
|
||||||
|
<Unsupported
|
||||||
|
schema={itemSchema()}
|
||||||
|
error="dynamic arrays are not implemented yet"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
{/* TODO: Trivial cases */}
|
||||||
|
</Switch>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
setValue,
|
setValue,
|
||||||
SubmitHandler,
|
SubmitHandler,
|
||||||
} from "@modular-forms/solid";
|
} from "@modular-forms/solid";
|
||||||
|
import { DynForm } from "@/src/Form/form";
|
||||||
|
|
||||||
export const ModuleDetails = () => {
|
export const ModuleDetails = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -167,7 +168,6 @@ export const ModuleForm = (props: { id: string }) => {
|
|||||||
console.log("Schema Query", schemaQuery.data?.[props.id]);
|
console.log("Schema Query", schemaQuery.data?.[props.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const [formStore, { Form, Field }] = createForm();
|
|
||||||
const handleSubmit: SubmitHandler<NonNullable<unknown>> = async (
|
const handleSubmit: SubmitHandler<NonNullable<unknown>> = async (
|
||||||
values,
|
values,
|
||||||
event,
|
event,
|
||||||
@@ -175,156 +175,6 @@ export const ModuleForm = (props: { id: string }) => {
|
|||||||
console.log("Submitted form values", values);
|
console.log("Submitted form values", values);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [newKey, setNewKey] = createSignal<string>("");
|
|
||||||
|
|
||||||
const handleChangeKey: JSX.ChangeEventHandler<HTMLInputElement, Event> = (
|
|
||||||
e,
|
|
||||||
) => {
|
|
||||||
setNewKey(e.currentTarget.value);
|
|
||||||
};
|
|
||||||
const SchemaForm = (props: SchemaFormProps) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Switch
|
|
||||||
fallback={<Unsupported what={"schema"} schema={props.schema} />}
|
|
||||||
>
|
|
||||||
<Match when={props.schema.type === "object"}>
|
|
||||||
<Switch
|
|
||||||
fallback={<Unsupported what={"object"} schema={props.schema} />}
|
|
||||||
>
|
|
||||||
<Match
|
|
||||||
when={
|
|
||||||
!props.schema.additionalProperties && props.schema.properties
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(properties) => (
|
|
||||||
<For each={Object.entries(properties())}>
|
|
||||||
{([key, value]) => (
|
|
||||||
<Switch fallback={`Cannot render sub-schema of ${value}`}>
|
|
||||||
<Match when={typeof value === "object" && value}>
|
|
||||||
{(sub) => (
|
|
||||||
<SchemaForm
|
|
||||||
title={key}
|
|
||||||
schema={sub()}
|
|
||||||
path={[...props.path, key]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
<Match
|
|
||||||
when={
|
|
||||||
typeof props.schema.additionalProperties == "object" &&
|
|
||||||
props.schema.additionalProperties
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(additionalProperties) => (
|
|
||||||
<>
|
|
||||||
<div>{props.title}</div>
|
|
||||||
{/* @ts-expect-error: We don't know the field names ahead of time */}
|
|
||||||
<Field name={props.title}>
|
|
||||||
{(f, p) => (
|
|
||||||
<>
|
|
||||||
<Show when={f.value}>
|
|
||||||
<For
|
|
||||||
each={Object.entries(
|
|
||||||
f.value as Record<string, unknown>,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{(v) => (
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
{removeTrailingS(props.title)}: {v[0]}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<SchemaForm
|
|
||||||
path={[...props.path, v[0]]}
|
|
||||||
schema={additionalProperties()}
|
|
||||||
title={v[0]}
|
|
||||||
/>{" "}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
<input
|
|
||||||
value={newKey()}
|
|
||||||
onChange={handleChangeKey}
|
|
||||||
type={"text"}
|
|
||||||
placeholder={`Name of ${removeTrailingS(props.title)}`}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const value = getValue(formStore, props.title);
|
|
||||||
if (!newKey()) return;
|
|
||||||
|
|
||||||
if (value === undefined) {
|
|
||||||
setValue(formStore, props.title, {
|
|
||||||
[newKey()]: {},
|
|
||||||
});
|
|
||||||
setNewKey("");
|
|
||||||
} else if (
|
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
!(newKey() in value)
|
|
||||||
) {
|
|
||||||
setValue(formStore, props.title, {
|
|
||||||
...value,
|
|
||||||
[newKey()]: {},
|
|
||||||
});
|
|
||||||
setNewKey("");
|
|
||||||
} else {
|
|
||||||
console.debug(
|
|
||||||
"Unsupported key value pair. (attrsOf t)",
|
|
||||||
{ value },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add new {removeTrailingS(props.title)}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Match>
|
|
||||||
<Match when={props.schema.type === "array"}>
|
|
||||||
TODO: Array field "{props.title}"
|
|
||||||
</Match>
|
|
||||||
<Match when={props.schema.type === "string"}>
|
|
||||||
{/* @ts-expect-error: We dont know the field names ahead of time */}
|
|
||||||
<Field name={props.path.join(".")}>
|
|
||||||
{(field, fieldProps) => (
|
|
||||||
<TextInput
|
|
||||||
formStore={formStore}
|
|
||||||
inputProps={fieldProps}
|
|
||||||
label={props.title}
|
|
||||||
// @ts-expect-error: It is a string, otherwise the json schema would be invalid
|
|
||||||
value={field.value ?? ""}
|
|
||||||
placeholder={`${props.schema.default || ""}`.replace(
|
|
||||||
"\u2039name\u203a",
|
|
||||||
`${props.path.at(-2)}`,
|
|
||||||
)}
|
|
||||||
error={field.error}
|
|
||||||
required={!props.schema.default}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="ModuleForm">
|
<div id="ModuleForm">
|
||||||
<Switch fallback={"No Schema found"}>
|
<Switch fallback={"No Schema found"}>
|
||||||
@@ -337,11 +187,13 @@ export const ModuleForm = (props: { id: string }) => {
|
|||||||
{([role, schema]) => (
|
{([role, schema]) => (
|
||||||
<div class="my-2">
|
<div class="my-2">
|
||||||
<h4 class="text-xl">{role}</h4>
|
<h4 class="text-xl">{role}</h4>
|
||||||
<Form onSubmit={handleSubmit}>
|
<DynForm
|
||||||
<SchemaForm title={role} schema={schema} path={[]} />
|
handleSubmit={handleSubmit}
|
||||||
<br />
|
schema={schema}
|
||||||
<button class="btn btn-primary">Save</button>
|
components={{
|
||||||
</Form>
|
after: <button class="btn btn-primary">Submit</button>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
Reference in New Issue
Block a user