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:
clan-bot
2024-11-13 08:02:29 +00:00
19 changed files with 1576 additions and 183 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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
---
```

View File

@@ -16,6 +16,15 @@
}
},
"services": {
"zerotier": {
"1": {
"roles": {
"controller": {
"machines": ["test-inventory-machine"]
}
}
}
},
"borgbackup": {
"simple": {
"roles": {

View 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);
}

View 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;
};
};
}
];
}
);
};
}

View File

@@ -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
;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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]:

View 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,
};
}

View 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>
);

View 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>;
};

View 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>
</>
);
}

View 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>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./FormSection";
export * from "./TextInput";

View 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>
);
}

View File

@@ -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>