Merge pull request 'Modules/constraints: init constraints checking for inventory compatible modules' (#2391) from hsjobeki/clan-core:hsjobeki-main into main
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
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.<roleName>.<constraintType>` (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
|
||||
---
|
||||
```
|
||||
|
||||
@@ -16,6 +16,15 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"zerotier": {
|
||||
"1": {
|
||||
"roles": {
|
||||
"controller": {
|
||||
"machines": ["test-inventory-machine"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"borgbackup": {
|
||||
"simple": {
|
||||
"roles": {
|
||||
|
||||
54
lib/constraints/default.nix
Normal file
54
lib/constraints/default.nix
Normal file
@@ -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);
|
||||
}
|
||||
54
lib/constraints/interface.nix
Normal file
54
lib/constraints/interface.nix
Normal file
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
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>;
|
||||
};
|
||||
230
pkgs/webview-ui/app/src/Form/fields/Select.tsx
Normal file
230
pkgs/webview-ui/app/src/Form/fields/Select.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
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 interface 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-expect-error: fieldName is not known ahead of time
|
||||
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-expect-error: fieldName is not known ahead of time
|
||||
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 cursor-default flex-row gap-2">
|
||||
<For each={getValues()} fallback={"Select"}>
|
||||
{(item) => (
|
||||
<div class="rounded-xl bg-slate-800 px-4 py-1 text-sm text-white">
|
||||
{item}
|
||||
<Show when={props.multiple}>
|
||||
<button
|
||||
class="btn-ghost btn-xs"
|
||||
type="button"
|
||||
onClick={(_e) => {
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
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 z-[1] rounded-b-box bg-base-100 shadow"
|
||||
>
|
||||
<ul class="menu flex max-h-96 flex-col gap-1 overflow-x-hidden overflow-y-scroll">
|
||||
<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";
|
||||
902
pkgs/webview-ui/app/src/Form/form/index.tsx
Normal file
902
pkgs/webview-ui/app/src/Form/form/index.tsx
Normal file
@@ -0,0 +1,902 @@
|
||||
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): unknown {
|
||||
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: Record<string, unknown> = {};
|
||||
if (schema.properties) {
|
||||
Object.entries(schema.properties).forEach(([key, propSchema]) => {
|
||||
if (typeof propSchema === "boolean") {
|
||||
obj[key] = false;
|
||||
} 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-expect-error: listFieldName is not known ahead of time
|
||||
props.listFieldName,
|
||||
{ at: props.idx },
|
||||
);
|
||||
};
|
||||
const moveItemBy = (dir: number) => (e: Event) => {
|
||||
e.preventDefault();
|
||||
move(
|
||||
props.formStore,
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
props.listFieldName,
|
||||
{ from: props.idx, to: props.idx + dir },
|
||||
);
|
||||
};
|
||||
const topMost = () => props.idx === props.of - 1;
|
||||
const bottomMost = () => props.idx === 0;
|
||||
|
||||
return (
|
||||
<div class="w-full border-l-4 border-gray-300">
|
||||
<div class="flex w-full items-end gap-2 px-4">
|
||||
{props.children}
|
||||
<div class="ml-4 min-w-fit pb-4">
|
||||
<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: unknown[]) => {
|
||||
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-expect-error: listFieldName is not known ahead of time
|
||||
name={listFieldName}
|
||||
// @ts-expect-error: type is known due to schema
|
||||
type="string[]"
|
||||
validateOn="touched"
|
||||
revalidateOn="touched"
|
||||
validate={() => {
|
||||
let error = "";
|
||||
const values: unknown[] = getValues(
|
||||
props.formStore,
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
listFieldName,
|
||||
// @ts-expect-error: assumption based on the behavior of selectInput
|
||||
)?.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-expect-error: listFieldName is not known ahead of time
|
||||
name={listFieldName}
|
||||
of={props.formStore}
|
||||
validateOn="touched"
|
||||
revalidateOn="touched"
|
||||
validate={() => {
|
||||
let error = "";
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
const values: unknown[] = getValues(
|
||||
props.formStore,
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
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-expect-error: 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-expect-error: listFieldName is not known ahead of time
|
||||
const prev: unknown[] = getValues(
|
||||
props.formStore,
|
||||
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
listFieldName,
|
||||
);
|
||||
if (itemsSchema().type === "object") {
|
||||
const newIdx = prev.length;
|
||||
setValue(
|
||||
props.formStore,
|
||||
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
`${listFieldName}.${newIdx}`,
|
||||
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
values.root,
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
insert(props.formStore, listFieldName, {
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
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-expect-error: $exportedModuleInfo should exist since we export it */}
|
||||
<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
|
||||
// eslint-disable-next-line tailwindcss/no-custom-classname
|
||||
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-expect-error: fieldName is not known ahead of time
|
||||
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-expect-error: fieldName is not known ahead of time
|
||||
name={`${fieldName}.${key}`}
|
||||
>
|
||||
{(f, fp) => (
|
||||
<div class="w-full border-l-4 border-gray-300 pl-4">
|
||||
<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-warning btn-sm ml-auto"
|
||||
type="button"
|
||||
onClick={(_e) => {
|
||||
const copy = {
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
...objectField.value,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete copy[key];
|
||||
setValue(
|
||||
props.formStore,
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
`${fieldName}`,
|
||||
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-expect-error: fieldName is not known ahead of time
|
||||
`${fieldName}`,
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
{ ...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,
|
||||
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<NonNullable<unknown>> = async (
|
||||
values,
|
||||
event,
|
||||
@@ -175,156 +175,6 @@ export const ModuleForm = (props: { id: string }) => {
|
||||
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 (
|
||||
<div id="ModuleForm">
|
||||
<Switch fallback={"No Schema found"}>
|
||||
@@ -337,11 +187,13 @@ export const ModuleForm = (props: { id: string }) => {
|
||||
{([role, schema]) => (
|
||||
<div class="my-2">
|
||||
<h4 class="text-xl">{role}</h4>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SchemaForm title={role} schema={schema} path={[]} />
|
||||
<br />
|
||||
<button class="btn btn-primary">Save</button>
|
||||
</Form>
|
||||
<DynForm
|
||||
handleSubmit={handleSubmit}
|
||||
schema={schema}
|
||||
components={{
|
||||
after: <button class="btn btn-primary">Submit</button>,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
Reference in New Issue
Block a user