From 5b201856d199bbee65974be3d82026b790a7a93a Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 12 Nov 2024 18:35:55 +0100 Subject: [PATCH] UI/modules: dynamic rendering of public module interfaces --- pkgs/webview-ui/app/src/Form/base/index.tsx | 128 +++ pkgs/webview-ui/app/src/Form/base/label.tsx | 15 + .../app/src/Form/fields/FormSection.tsx | 8 + .../webview-ui/app/src/Form/fields/Select.tsx | 226 +++++ .../app/src/Form/fields/TextInput.tsx | 68 ++ pkgs/webview-ui/app/src/Form/fields/index.ts | 2 + pkgs/webview-ui/app/src/Form/form/index.tsx | 896 ++++++++++++++++++ .../app/src/routes/modules/details.tsx | 164 +--- 8 files changed, 1351 insertions(+), 156 deletions(-) create mode 100644 pkgs/webview-ui/app/src/Form/base/index.tsx create mode 100644 pkgs/webview-ui/app/src/Form/base/label.tsx create mode 100644 pkgs/webview-ui/app/src/Form/fields/FormSection.tsx create mode 100644 pkgs/webview-ui/app/src/Form/fields/Select.tsx create mode 100644 pkgs/webview-ui/app/src/Form/fields/TextInput.tsx create mode 100644 pkgs/webview-ui/app/src/Form/fields/index.ts create mode 100644 pkgs/webview-ui/app/src/Form/form/index.tsx diff --git a/pkgs/webview-ui/app/src/Form/base/index.tsx b/pkgs/webview-ui/app/src/Form/base/index.tsx new file mode 100644 index 000000000..71ff78bbb --- /dev/null +++ b/pkgs/webview-ui/app/src/Form/base/index.tsx @@ -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 { + whileElementsMounted?: ( + reference: R, + floating: F, + update: () => void, + ) => // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + void | (() => void); +} + +interface UseFloatingState extends Omit { + x?: number | null; + y?: number | null; +} + +export interface UseFloatingResult extends UseFloatingState { + update(): void; +} + +export function useFloating( + reference: () => R | undefined | null, + floating: () => F | undefined | null, + options?: UseFloatingOptions, +): UseFloatingResult { + const placement = () => options?.placement ?? "bottom"; + const strategy = () => options?.strategy ?? "absolute"; + + const [data, setData] = createSignal({ + 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, + }; +} diff --git a/pkgs/webview-ui/app/src/Form/base/label.tsx b/pkgs/webview-ui/app/src/Form/base/label.tsx new file mode 100644 index 000000000..28cfb9b79 --- /dev/null +++ b/pkgs/webview-ui/app/src/Form/base/label.tsx @@ -0,0 +1,15 @@ +import type { JSX } from "solid-js"; +interface LabelProps { + label: JSX.Element; + required?: boolean; +} +export const Label = (props: LabelProps) => ( + + {props.label} + +); diff --git a/pkgs/webview-ui/app/src/Form/fields/FormSection.tsx b/pkgs/webview-ui/app/src/Form/fields/FormSection.tsx new file mode 100644 index 000000000..d3633f0b2 --- /dev/null +++ b/pkgs/webview-ui/app/src/Form/fields/FormSection.tsx @@ -0,0 +1,8 @@ +import { JSX } from "solid-js"; + +interface FormSectionProps { + children: JSX.Element; +} +export const FormSection = (props: FormSectionProps) => { + return
{props.children}
; +}; diff --git a/pkgs/webview-ui/app/src/Form/fields/Select.tsx b/pkgs/webview-ui/app/src/Form/fields/Select.tsx new file mode 100644 index 000000000..b059a81c9 --- /dev/null +++ b/pkgs/webview-ui/app/src/Form/fields/Select.tsx @@ -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; + 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(); + const [floating, setFloating] = createSignal(); + + // `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 ( + <> + + + ); +} diff --git a/pkgs/webview-ui/app/src/Form/fields/TextInput.tsx b/pkgs/webview-ui/app/src/Form/fields/TextInput.tsx new file mode 100644 index 000000000..e9d15d454 --- /dev/null +++ b/pkgs/webview-ui/app/src/Form/fields/TextInput.tsx @@ -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; + 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 ( + + ); +} diff --git a/pkgs/webview-ui/app/src/Form/fields/index.ts b/pkgs/webview-ui/app/src/Form/fields/index.ts new file mode 100644 index 000000000..46efa5eec --- /dev/null +++ b/pkgs/webview-ui/app/src/Form/fields/index.ts @@ -0,0 +1,2 @@ +export * from "./FormSection"; +export * from "./TextInput"; diff --git a/pkgs/webview-ui/app/src/Form/form/index.tsx b/pkgs/webview-ui/app/src/Form/form/index.tsx new file mode 100644 index 000000000..56a8eb3ef --- /dev/null +++ b/pkgs/webview-ui/app/src/Form/form/index.tsx @@ -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; + handleSubmit?: SubmitHandler>; + initialPath?: string[]; + components?: { + before?: JSX.Element; + after?: JSX.Element; + }; + readonly?: boolean; + formProps?: JSX.InputHTMLAttributes; + errorContext?: string; + resetOnSubmit?: boolean; +} +export const DynForm = (props: FormProps) => { + const [formStore, { Field, Form: ModuleForm }] = createForm({ + initialValues: props.initialValues, + }); + + const handleSubmit: SubmitHandler> = 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 ( + <> + + {props.components?.before} + + {props.components?.after} + + + ); +}; + +interface UnsupportedProps { + schema: JSONSchema7; + error?: string; +} + +const Unsupported = (props: UnsupportedProps) => ( +
+ {props.error &&
{props.error}
} + + Invalid or unsupported schema entry of type:{" "} + {JSON.stringify(props.schema.type)} + +
+      {JSON.stringify(props.schema, null, 2)}
+    
+
+); + +interface SchemaFieldsProps { + formStore: FormStore; + Field: typeof Field; + schema: JSONSchema7; + path: string[]; + readonly: boolean; + parent: JSONSchema7; +} +export function SchemaFields( + props: SchemaFieldsProps, +) { + return ( + }> + {/* Simple types */} + bool + + + + + + + + + + + {/* Composed types */} + + + + + + + {/* Empty / Null */} + + Dont know how to rendner InputType null + + + + ); +} + +export function StringField( + props: SchemaFieldsProps, +) { + if ( + props.schema.type !== "string" && + props.schema.type !== "number" && + props.schema.type !== "integer" + ) { + return ( + + Error cannot render the following as String input. + + + ); + } + 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 ( + }> + + {(s) => ( + + {(field, fieldProps) => ( + <> + + + )} + + )} + + + {(_enumSchemas) => ( + + {(field, fieldProps) => ( + + {(options) => ( + ({ + value: o, + label: o, + }))} + selectProps={fieldProps} + required={!!props.schema.minItems} + /> + )} + + )} + + )} + + + {(s) => ( + + {(field, fieldProps) => ( + + )} + + )} + + {/* TODO: when is it a normal string input? */} + + {(s) => ( + + {(field, fieldProps) => ( + + // + // + // } + // required + // altLabel="Leave empty to accept the default" + // helperText="Configure how dude connects" + // error="Something is wrong now" + /> + )} + + )} + + + ); +} + +interface OptionSchemaProps { + itemSpec: JSONSchema7Type; +} +export function OptionSchema(props: OptionSchemaProps) { + return ( + Item spec unhandled}> + + {(o) => } + + + ); +} + +interface ValueDisplayProps + extends SchemaFieldsProps { + children: JSX.Element; + listFieldName: string; + idx: number; + of: number; +} +export function ListValueDisplay( + props: ValueDisplayProps, +) { + 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 ( +
+
+ {props.children} +
+ + + +
+
+
+ ); +} + +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 ( + + } + > + {props.children(props.itemspec.enum as string[])} + + ); +}; + +export function ArrayFields( + props: SchemaFieldsProps, +) { + if (props.schema.type !== "array") { + return ( + + Error cannot render the following as array. + + + ); + } + const { Field } = props; + + const listFieldName = props.path.join("."); + + return ( + <> + }> + + {(itemsSchema) => ( + <> + }> + + + + + { + 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) => ( + + {(options) => ( + ({ + value: o, + label: o, + }))} + selectProps={fieldProps} + required={!!props.schema.minItems} + /> + )} + + )} + + + + + {/* !Important: Register the parent field to gain access to array items*/} + { + 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 */} + No items + } + > + {(item, idx) => ( + + + {(f, fp) => ( + <> + + + )} + + + )} + + + {fieldArray.error} + + + {/* Add new item */} + Add ↑, + }} + // 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, + }); + }} + /> + + )} + + + + + )} + + + + ); +} + +interface ObjectFieldPropertyLabelProps { + schema: JSONSchema7; + fallback: JSX.Element; +} +export function ObjectFieldPropertyLabel(props: ObjectFieldPropertyLabelProps) { + return ( + + {/* @ts-ignore */} + + {(path) => path()[path().length - 1]} + + + ); +} + +export function ObjectFields( + props: SchemaFieldsProps, +) { + if (props.schema.type !== "object") { + return ( + + Error cannot render the following as Object + + + ); + } + + const fieldName = props.path.join("."); + const { Field } = props; + + return ( + + } + > + + {(properties) => ( + + {([propName, propSchema]) => ( +
+
+ )} +
+ )} +
+ {/* 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 + */} + + {(additionalPropertiesSchema) => ( + + } + > + {/* Non-trivival cases */} + + {(itemSchema) => ( + + {(objectField, fp) => ( + <> + + + + } + each={Object.entries(objectField.value || {})} + > + {([key, relatedValue]) => ( + + {(f, fp) => ( +
+ + + {key} + + +
+ ), + }} + /> + + )} +
+ )} +
+ {/* Replace this with a normal input ?*/} + { + setValue( + props.formStore, + // @ts-ignore + `${fieldName}`, + // @ts-ignore + { ...objectField.value, [values[""]]: {} }, + ); + }} + /> + + )} +
+ )} +
+ + {(itemSchema) => ( + + )} + + {/* TODO: Trivial cases */} +
+ )} +
+
+ ); +} diff --git a/pkgs/webview-ui/app/src/routes/modules/details.tsx b/pkgs/webview-ui/app/src/routes/modules/details.tsx index 61d882b40..1161eb4ff 100644 --- a/pkgs/webview-ui/app/src/routes/modules/details.tsx +++ b/pkgs/webview-ui/app/src/routes/modules/details.tsx @@ -24,6 +24,7 @@ import { setValue, SubmitHandler, } from "@modular-forms/solid"; +import { DynForm } from "@/src/Form/form"; export const ModuleDetails = () => { const params = useParams(); @@ -167,7 +168,6 @@ export const ModuleForm = (props: { id: string }) => { console.log("Schema Query", schemaQuery.data?.[props.id]); }); - const [formStore, { Form, Field }] = createForm(); const handleSubmit: SubmitHandler> = async ( values, event, @@ -175,156 +175,6 @@ export const ModuleForm = (props: { id: string }) => { console.log("Submitted form values", values); }; - const [newKey, setNewKey] = createSignal(""); - - const handleChangeKey: JSX.ChangeEventHandler = ( - e, - ) => { - setNewKey(e.currentTarget.value); - }; - const SchemaForm = (props: SchemaFormProps) => { - return ( -
- } - > - - } - > - - {(properties) => ( - - {([key, value]) => ( - - - {(sub) => ( - - )} - - - )} - - )} - - - {(additionalProperties) => ( - <> -
{props.title}
- {/* @ts-expect-error: We don't know the field names ahead of time */} - - {(f, p) => ( - <> - - , - )} - > - {(v) => ( -
-
- {removeTrailingS(props.title)}: {v[0]} -
-
- {" "} -
-
- )} -
-
- - - - )} -
- - )} -
-
-
- - TODO: Array field "{props.title}" - - - {/* @ts-expect-error: We dont know the field names ahead of time */} - - {(field, fieldProps) => ( - - )} - - -
-
- ); - }; - return (
@@ -337,11 +187,13 @@ export const ModuleForm = (props: { id: string }) => { {([role, schema]) => (

{role}

-
- -
- - + Submit, + }} + />
)}