From 53a8771c184df6177814a1df9a713769d61ed906 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 12 Nov 2024 18:33:20 +0100 Subject: [PATCH 1/4] Zerotier: add to inventory test machine --- clanModules/zerotier/roles/controller.nix | 5 +++-- inventory.json | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/clanModules/zerotier/roles/controller.nix b/clanModules/zerotier/roles/controller.nix index 27dd01d6d..b364f76d6 100644 --- a/clanModules/zerotier/roles/controller.nix +++ b/clanModules/zerotier/roles/controller.nix @@ -9,7 +9,8 @@ let instanceName = builtins.head instanceNames; zeroTierInstance = config.clan.inventory.services.zerotier.${instanceName}; roles = zeroTierInstance.roles; - stringSet = list: builtins.attrNames (builtins.groupBy lib.id list); + # TODO(@mic92): This should be upstreamed to nixpkgs + uniqueStrings = list: builtins.attrNames (builtins.groupBy lib.id list); in { imports = [ @@ -18,7 +19,7 @@ in config = { systemd.services.zerotier-inventory-autoaccept = let - machines = stringSet (roles.moon.machines ++ roles.controller.machines ++ roles.peer.machines); + machines = uniqueStrings (roles.moon.machines ++ roles.controller.machines ++ roles.peer.machines); networkIps = builtins.foldl' ( ips: name: if builtins.pathExists "${config.clan.core.clanDir}/machines/${name}/facts/zerotier-ip" then diff --git a/inventory.json b/inventory.json index 8a8fe11ed..ebc3ec114 100644 --- a/inventory.json +++ b/inventory.json @@ -16,6 +16,15 @@ } }, "services": { + "zerotier": { + "1": { + "roles": { + "controller": { + "machines": ["test-inventory-machine"] + } + } + } + }, "borgbackup": { "simple": { "roles": { From 241db1cade8dec27aa708d84de3271683038f7d0 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 12 Nov 2024 18:35:01 +0100 Subject: [PATCH 2/4] Modules/constraints: init constraints checking for inventory compatible modules --- clanModules/zerotier/README.md | 3 + clanModules/zerotier/shared.nix | 9 +-- docs/site/clanmodules/index.md | 16 +++++- lib/constraints/default.nix | 54 ++++++++++++++++++ lib/constraints/interface.nix | 54 ++++++++++++++++++ lib/description.nix | 69 ++++++++++++++++++++++- lib/eval-clan-modules/default.nix | 16 +----- lib/inventory/build-inventory/default.nix | 6 ++ pkgs/clan-cli/clan_cli/api/modules.py | 1 + 9 files changed, 203 insertions(+), 25 deletions(-) create mode 100644 lib/constraints/default.nix create mode 100644 lib/constraints/interface.nix diff --git a/clanModules/zerotier/README.md b/clanModules/zerotier/README.md index 0572addb4..c82f8c1f1 100644 --- a/clanModules/zerotier/README.md +++ b/clanModules/zerotier/README.md @@ -1,6 +1,9 @@ --- description = "Configures [Zerotier VPN](https://zerotier.com) secure and efficient networking within a Clan.." features = [ "inventory" ] + +constraints.roles.controller.eq = 1 +constraints.roles.moon.max = 7 --- ## Overview diff --git a/clanModules/zerotier/shared.nix b/clanModules/zerotier/shared.nix index 72e8ccca8..2b5de214e 100644 --- a/clanModules/zerotier/shared.nix +++ b/clanModules/zerotier/shared.nix @@ -45,18 +45,11 @@ in config = { assertions = [ + # TODO: This should also be checked via frontmatter constraints { assertion = builtins.length instanceNames == 1; message = "The zerotier module currently only supports one instance per machine, but found ${builtins.toString instanceNames}"; } - { - assertion = builtins.length roles.controller.machines == 1; - message = "The zerotier module requires exactly one controller, but found ${builtins.toString roles.controller.machines}"; - } - { - assertion = builtins.length roles.moons.machines <= 7; - message = "The zerotier module allows at most for seven moons , but found ${builtins.toString roles.moons.machines}"; - } ]; clan.core.networking.zerotier.networkId = networkId; diff --git a/docs/site/clanmodules/index.md b/docs/site/clanmodules/index.md index 16e8a6355..670995314 100644 --- a/docs/site/clanmodules/index.md +++ b/docs/site/clanmodules/index.md @@ -191,4 +191,18 @@ Assuming that there is a common code path or a common interface between `server` Every ClanModule, that specifies `features = [ "inventory" ]` MUST have at least one role. Many modules use `roles/default.nix` which registers the role `default`. - If you are a clan module author and your module has only one role where you cannot determine the name, then we would like you to follow the convention. \ No newline at end of file + If you are a clan module author and your module has only one role where you cannot determine the name, then we would like you to follow the convention. + + +`constraints.roles..` (Optional `int`) (Experimental) +: Contraints for the module + + The following example requires exactly one `server` + and supports up to `7` clients + + ```md + --- + constraints.roles.server.eq = 1 + constraints.roles.client.max = 7 + --- + ``` diff --git a/lib/constraints/default.nix b/lib/constraints/default.nix new file mode 100644 index 000000000..3b1b4f48c --- /dev/null +++ b/lib/constraints/default.nix @@ -0,0 +1,54 @@ +{ + lib, + config, + resolvedRoles, + moduleName, + ... +}: +{ + imports = [ + ./interface.nix + ]; + config.assertions = lib.foldl' ( + ass: roleName: + let + roleConstraints = config.roles.${roleName}; + members = resolvedRoles.${roleName}.machines; + memberCount = builtins.length members; + # Checks + eqCheck = + if roleConstraints.eq != null then + [ + { + assertion = memberCount == roleConstraints.eq; + message = "The ${moduleName} module requires exactly ${builtins.toString roleConstraints.eq} '${roleName}', but found ${builtins.toString memberCount}: ${builtins.toString members}"; + } + ] + else + [ ]; + + minCheck = + if roleConstraints.min > 0 then + [ + { + assertion = memberCount >= roleConstraints.min; + message = "The ${moduleName} module requires at least ${builtins.toString roleConstraints.min} '${roleName}'s, but found ${builtins.toString memberCount}: ${builtins.toString members}"; + } + ] + else + [ ]; + + maxCheck = + if roleConstraints.max != null then + [ + { + assertion = memberCount <= roleConstraints.max; + message = "The ${moduleName} module allows at most for ${builtins.toString roleConstraints.max} '${roleName}'s, but found ${builtins.toString memberCount}: ${builtins.toString members}"; + } + ] + else + [ ]; + in + eqCheck ++ minCheck ++ maxCheck ++ ass + ) [ ] (lib.attrNames config.roles); +} diff --git a/lib/constraints/interface.nix b/lib/constraints/interface.nix new file mode 100644 index 000000000..58f3c5ede --- /dev/null +++ b/lib/constraints/interface.nix @@ -0,0 +1,54 @@ +{ lib, allRoles, ... }: +let + inherit (lib) mkOption types; + rolesAttrs = builtins.groupBy lib.id allRoles; +in +{ + options.roles = lib.mapAttrs ( + _name: _: + mkOption { + default = { }; + type = types.submoduleWith { + modules = [ + { + options = { + max = mkOption { + type = types.nullOr types.int; + default = null; + }; + min = mkOption { + type = types.int; + default = 0; + }; + eq = mkOption { + type = types.nullOr types.int; + default = null; + }; + }; + } + ]; + }; + } + ) rolesAttrs; + + # The resulting assertions + options.assertions = mkOption { + default = [ ]; + type = types.listOf ( + types.submoduleWith { + modules = [ + { + options = { + assertion = mkOption { + type = types.bool; + }; + message = mkOption { + type = types.str; + }; + }; + } + ]; + } + ); + }; +} diff --git a/lib/description.nix b/lib/description.nix index e18bcded4..58ec5f74d 100644 --- a/lib/description.nix +++ b/lib/description.nix @@ -1,5 +1,63 @@ { clan-core, lib }: -rec { +let + getRoles = + modulePath: + let + rolesDir = modulePath + "/roles"; + in + if builtins.pathExists rolesDir then + lib.pipe rolesDir [ + builtins.readDir + (lib.filterAttrs (_n: v: v == "regular")) + lib.attrNames + (lib.filter (fileName: lib.hasSuffix ".nix" fileName)) + (map (fileName: lib.removeSuffix ".nix" fileName)) + ] + else + [ ]; + + getConstraints = + modulename: + let + eval = lib.evalModules { + specialArgs = { + allRoles = getRoles clan-core.clanModules.${modulename}; + }; + modules = [ + ./constraints/interface.nix + (getFrontmatter modulename).constraints + ]; + }; + in + eval.config.roles; + + checkConstraints = + { moduleName, resolvedRoles }: + let + eval = lib.evalModules { + specialArgs = { + inherit moduleName; + allRoles = getRoles clan-core.clanModules.${moduleName}; + resolvedRoles = { + controller = { + machines = [ "test-inventory-machine" ]; + }; + moon = { + machines = [ ]; + }; + peer = { + machines = [ ]; + }; + }; + }; + modules = [ + ./constraints/default.nix + ((getFrontmatter moduleName).constraints or { }) + ]; + }; + in + eval.config.assertions; + getReadme = modulename: let @@ -38,4 +96,13 @@ rec { --- ...rest of your README.md... ''; +in +{ + inherit + getFrontmatter + getReadme + getRoles + getConstraints + checkConstraints + ; } diff --git a/lib/eval-clan-modules/default.nix b/lib/eval-clan-modules/default.nix index a5d4f5995..e2f2f70d2 100644 --- a/lib/eval-clan-modules/default.nix +++ b/lib/eval-clan-modules/default.nix @@ -55,21 +55,7 @@ let evalClanModulesWithRoles = clanModules: let - getRoles = - modulePath: - let - rolesDir = "${modulePath}/roles"; - in - if builtins.pathExists rolesDir then - lib.pipe rolesDir [ - builtins.readDir - (lib.filterAttrs (_n: v: v == "regular")) - lib.attrNames - (lib.filter (fileName: lib.hasSuffix ".nix" fileName)) - (map (fileName: lib.removeSuffix ".nix" fileName)) - ] - else - [ ]; + getRoles = clan-core.lib.modules.getRoles; res = builtins.mapAttrs ( moduleName: module: let diff --git a/lib/inventory/build-inventory/default.nix b/lib/inventory/build-inventory/default.nix index 9d6fe2439..17f5fff96 100644 --- a/lib/inventory/build-inventory/default.nix +++ b/lib/inventory/build-inventory/default.nix @@ -42,6 +42,7 @@ let builtins.elem "inventory" (clan-core.lib.modules.getFrontmatter serviceName).features or [ ]; trimExtension = name: builtins.substring 0 (builtins.stringLength name - 4) name; + /* Returns a NixOS configuration for every machine in the inventory. @@ -126,6 +127,10 @@ let nonExistingRoles = builtins.filter (role: !(builtins.elem role roles)) ( builtins.attrNames (serviceConfig.roles or { }) ); + constraintAssertions = clan-core.lib.modules.checkConstraints { + moduleName = serviceName; + inherit resolvedRoles; + }; in if (nonExistingRoles != [ ]) then throw "Roles ${builtins.toString nonExistingRoles} are not defined in the service ${serviceName}." @@ -149,6 +154,7 @@ let } ) ({ + assertions = constraintAssertions; clan.inventory.services.${serviceName}.${instanceName} = { roles = resolvedRoles; # TODO: Add inverseRoles to the service config if needed diff --git a/pkgs/clan-cli/clan_cli/api/modules.py b/pkgs/clan-cli/clan_cli/api/modules.py index ac25b9b59..fd7e02415 100644 --- a/pkgs/clan-cli/clan_cli/api/modules.py +++ b/pkgs/clan-cli/clan_cli/api/modules.py @@ -25,6 +25,7 @@ class Frontmatter: description: str categories: list[str] = field(default_factory=lambda: ["Uncategorized"]) features: list[str] = field(default_factory=list) + constraints: dict[str, Any] = field(default_factory=dict) @property def categories_info(self) -> dict[str, CategoryInfo]: From 5b201856d199bbee65974be3d82026b790a7a93a Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 12 Nov 2024 18:35:55 +0100 Subject: [PATCH 3/4] 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, + }} + />
)} From f259157eb0cfbaf4a9e31b5574dd8ddf9fcf587c Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 13 Nov 2024 08:57:03 +0100 Subject: [PATCH 4/4] UI/modules: exclude typescript from dynamic field names and types --- .../webview-ui/app/src/Form/fields/Select.tsx | 22 ++--- pkgs/webview-ui/app/src/Form/form/index.tsx | 80 ++++++++++--------- 2 files changed, 56 insertions(+), 46 deletions(-) diff --git a/pkgs/webview-ui/app/src/Form/fields/Select.tsx b/pkgs/webview-ui/app/src/Form/fields/Select.tsx index b059a81c9..b58f5c4c8 100644 --- a/pkgs/webview-ui/app/src/Form/fields/Select.tsx +++ b/pkgs/webview-ui/app/src/Form/fields/Select.tsx @@ -12,7 +12,11 @@ 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 }; +export interface Option { + value: string; + label: string; +} + interface SelectInputpProps { value: string[] | string; selectProps: JSX.InputHTMLAttributes; @@ -81,7 +85,7 @@ export function SelectInput(props: SelectInputpProps) { const handleClickOption = (opt: Option) => { if (!props.multiple) { - // @ts-ignore + // @ts-expect-error: fieldName is not known ahead of time props.selectProps.onInput({ currentTarget: { value: opt.value, @@ -96,7 +100,7 @@ export function SelectInput(props: SelectInputpProps) { } else { currValues.push(opt.value); } - // @ts-ignore + // @ts-expect-error: fieldName is not known ahead of time props.selectProps.onInput({ currentTarget: { options: currValues.map((value) => ({ @@ -128,17 +132,17 @@ export function SelectInput(props: SelectInputpProps) { {props.adornment?.content} {props.inlineLabel} -
+
{(item) => ( -
+
{item} @@ -396,7 +398,7 @@ export function ListValueDisplay( ); } -const findDuplicates = (arr: any[]) => { +const findDuplicates = (arr: unknown[]) => { const seen = new Set(); const duplicates: number[] = []; @@ -475,20 +477,19 @@ export function ArrayFields( when={itemsSchema().type === "string" && itemsSchema().enum} > { let error = ""; - // @ts-ignore - const values: any[] = getValues( + const values: unknown[] = getValues( props.formStore, - // @ts-ignore + // @ts-expect-error: listFieldName is not known ahead of time listFieldName, - // @ts-ignore + // @ts-expect-error: assumption based on the behavior of selectInput )?.strings?.selection; console.log("vali", { values }); if (props.schema.uniqueItems) { @@ -543,17 +544,17 @@ export function ArrayFields( > {/* !Important: Register the parent field to gain access to array items*/} { let error = ""; - // @ts-ignore - const values: any[] = getValues( + // @ts-expect-error: listFieldName is not known ahead of time + const values: unknown[] = getValues( props.formStore, - // @ts-ignore + // @ts-expect-error: listFieldName is not known ahead of time listFieldName, ); if (props.schema.uniqueItems) { @@ -596,7 +597,7 @@ export function ArrayFields( of={fieldArray.items.length} > {(f, fp) => ( @@ -642,25 +643,29 @@ export function ArrayFields( }} // Add the new item to the FieldArray handleSubmit={(values, event) => { - // @ts-ignore - const prev: any[] = getValues( + // @ts-expect-error: listFieldName is not known ahead of time + const prev: unknown[] = getValues( props.formStore, - // @ts-ignore + + // @ts-expect-error: listFieldName is not known ahead of time listFieldName, ); if (itemsSchema().type === "object") { const newIdx = prev.length; setValue( props.formStore, - // @ts-ignore + + // @ts-expect-error: listFieldName is not known ahead of time `${listFieldName}.${newIdx}`, - // @ts-ignore + + // @ts-expect-error: listFieldName is not known ahead of time values.root, ); } - // @ts-ignore + + // @ts-expect-error: listFieldName is not known ahead of time insert(props.formStore, listFieldName, { - // @ts-ignore + // @ts-expect-error: listFieldName is not known ahead of time value: values.root, }); }} @@ -685,7 +690,7 @@ interface ObjectFieldPropertyLabelProps { export function ObjectFieldPropertyLabel(props: ObjectFieldPropertyLabelProps) { return ( - {/* @ts-ignore */} + {/* @ts-expect-error: $exportedModuleInfo should exist since we export it */} {(path) => path()[path().length - 1]} @@ -724,6 +729,7 @@ export function ObjectFields( {([propName, propSchema]) => (
( {(itemSchema) => ( {(objectField, fp) => ( @@ -802,11 +808,11 @@ export function ObjectFields( > {([key, relatedValue]) => ( {(f, fp) => ( -
+
( {key}