Merge branch 'main' into fix/chat-scroll-to-bottom
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderConfigForm } from "./views/config-form";
|
||||
import { analyzeConfigSchema, renderConfigForm } from "./views/config-form";
|
||||
|
||||
const rootSchema = {
|
||||
type: "object",
|
||||
@@ -28,6 +28,14 @@ const rootSchema = {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
},
|
||||
bind: {
|
||||
anyOf: [
|
||||
{ const: "auto" },
|
||||
{ const: "lan" },
|
||||
{ const: "tailnet" },
|
||||
{ const: "loopback" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,12 +43,14 @@ describe("config form renderer", () => {
|
||||
it("renders inputs and patches values", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: rootSchema,
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: {},
|
||||
onPatch,
|
||||
}),
|
||||
@@ -62,7 +72,7 @@ describe("config form renderer", () => {
|
||||
const select = container.querySelector("select") as HTMLSelectElement | null;
|
||||
expect(select).not.toBeNull();
|
||||
if (!select) return;
|
||||
select.value = "token";
|
||||
select.value = "1";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["mode"], "token");
|
||||
|
||||
@@ -79,10 +89,12 @@ describe("config form renderer", () => {
|
||||
it("adds and removes array entries", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: rootSchema,
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { allowFrom: ["+1"] },
|
||||
onPatch,
|
||||
}),
|
||||
@@ -103,4 +115,102 @@ describe("config form renderer", () => {
|
||||
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []);
|
||||
});
|
||||
|
||||
it("renders union literals as select options", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { bind: "auto" },
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const selects = Array.from(container.querySelectorAll("select"));
|
||||
const bindSelect = selects.find((el) =>
|
||||
Array.from(el.options).some((opt) => opt.value === "tailnet"),
|
||||
) as HTMLSelectElement | undefined;
|
||||
expect(bindSelect).not.toBeUndefined();
|
||||
if (!bindSelect) return;
|
||||
bindSelect.value = "tailnet";
|
||||
bindSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet");
|
||||
});
|
||||
|
||||
it("renders map fields from additionalProperties", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
slack: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { slack: { channelA: "ok" } },
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const removeButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(btn) => btn.textContent?.trim() === "Remove",
|
||||
);
|
||||
expect(removeButton).not.toBeUndefined();
|
||||
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["slack"], {});
|
||||
});
|
||||
|
||||
it("flags unsupported unions", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
mixed: {
|
||||
anyOf: [{ type: "string" }, { type: "number" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
expect(analysis.unsupportedPaths).toContain("mixed");
|
||||
});
|
||||
|
||||
it("supports nullable types", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
note: { type: ["string", "null"] },
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
expect(analysis.unsupportedPaths).not.toContain("note");
|
||||
});
|
||||
|
||||
it("flags additionalProperties true", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
extra: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
expect(analysis.unsupportedPaths).toContain("extra");
|
||||
});
|
||||
});
|
||||
|
||||
163
ui/src/ui/controllers/config.test.ts
Normal file
163
ui/src/ui/controllers/config.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
applyConfigSnapshot,
|
||||
updateConfigFormValue,
|
||||
type ConfigState,
|
||||
} from "./config";
|
||||
import {
|
||||
defaultDiscordActions,
|
||||
defaultSlackActions,
|
||||
type DiscordForm,
|
||||
type IMessageForm,
|
||||
type SignalForm,
|
||||
type SlackForm,
|
||||
type TelegramForm,
|
||||
} from "../ui-types";
|
||||
|
||||
const baseTelegramForm: TelegramForm = {
|
||||
token: "",
|
||||
requireMention: true,
|
||||
allowFrom: "",
|
||||
proxy: "",
|
||||
webhookUrl: "",
|
||||
webhookSecret: "",
|
||||
webhookPath: "",
|
||||
};
|
||||
|
||||
const baseDiscordForm: DiscordForm = {
|
||||
enabled: true,
|
||||
token: "",
|
||||
dmEnabled: true,
|
||||
allowFrom: "",
|
||||
groupEnabled: false,
|
||||
groupChannels: "",
|
||||
mediaMaxMb: "",
|
||||
historyLimit: "",
|
||||
textChunkLimit: "",
|
||||
replyToMode: "off",
|
||||
guilds: [],
|
||||
actions: { ...defaultDiscordActions },
|
||||
slashEnabled: false,
|
||||
slashName: "",
|
||||
slashSessionPrefix: "",
|
||||
slashEphemeral: true,
|
||||
};
|
||||
|
||||
const baseSlackForm: SlackForm = {
|
||||
enabled: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
dmEnabled: true,
|
||||
allowFrom: "",
|
||||
groupEnabled: false,
|
||||
groupChannels: "",
|
||||
mediaMaxMb: "",
|
||||
textChunkLimit: "",
|
||||
reactionNotifications: "own",
|
||||
reactionAllowlist: "",
|
||||
slashEnabled: false,
|
||||
slashName: "",
|
||||
slashSessionPrefix: "",
|
||||
slashEphemeral: true,
|
||||
actions: { ...defaultSlackActions },
|
||||
channels: [],
|
||||
};
|
||||
|
||||
const baseSignalForm: SignalForm = {
|
||||
enabled: true,
|
||||
account: "",
|
||||
httpUrl: "",
|
||||
httpHost: "",
|
||||
httpPort: "",
|
||||
cliPath: "",
|
||||
autoStart: true,
|
||||
receiveMode: "",
|
||||
ignoreAttachments: false,
|
||||
ignoreStories: false,
|
||||
sendReadReceipts: false,
|
||||
allowFrom: "",
|
||||
mediaMaxMb: "",
|
||||
};
|
||||
|
||||
const baseIMessageForm: IMessageForm = {
|
||||
enabled: true,
|
||||
cliPath: "",
|
||||
dbPath: "",
|
||||
service: "auto",
|
||||
region: "",
|
||||
allowFrom: "",
|
||||
includeAttachments: false,
|
||||
mediaMaxMb: "",
|
||||
};
|
||||
|
||||
function createState(): ConfigState {
|
||||
return {
|
||||
client: null,
|
||||
connected: false,
|
||||
configLoading: false,
|
||||
configRaw: "",
|
||||
configValid: null,
|
||||
configIssues: [],
|
||||
configSaving: false,
|
||||
configSnapshot: null,
|
||||
configSchema: null,
|
||||
configSchemaVersion: null,
|
||||
configSchemaLoading: false,
|
||||
configUiHints: {},
|
||||
configForm: null,
|
||||
configFormDirty: false,
|
||||
configFormMode: "form",
|
||||
lastError: null,
|
||||
telegramForm: { ...baseTelegramForm },
|
||||
discordForm: { ...baseDiscordForm },
|
||||
slackForm: { ...baseSlackForm },
|
||||
signalForm: { ...baseSignalForm },
|
||||
imessageForm: { ...baseIMessageForm },
|
||||
telegramConfigStatus: null,
|
||||
discordConfigStatus: null,
|
||||
slackConfigStatus: null,
|
||||
signalConfigStatus: null,
|
||||
imessageConfigStatus: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyConfigSnapshot", () => {
|
||||
it("handles missing slack config without throwing", () => {
|
||||
const state = createState();
|
||||
applyConfigSnapshot(state, {
|
||||
config: {
|
||||
telegram: {},
|
||||
discord: {},
|
||||
signal: {},
|
||||
imessage: {},
|
||||
},
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: "{}",
|
||||
});
|
||||
|
||||
expect(state.slackForm.botToken).toBe("");
|
||||
expect(state.slackForm.actions).toEqual(defaultSlackActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateConfigFormValue", () => {
|
||||
it("seeds from snapshot when form is null", () => {
|
||||
const state = createState();
|
||||
state.configSnapshot = {
|
||||
config: { telegram: { botToken: "t" }, gateway: { mode: "local" } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: "{}",
|
||||
};
|
||||
|
||||
updateConfigFormValue(state, ["gateway", "port"], 18789);
|
||||
|
||||
expect(state.configFormDirty).toBe(true);
|
||||
expect(state.configForm).toEqual({
|
||||
telegram: { botToken: "t" },
|
||||
gateway: { mode: "local", port: 18789 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
||||
const config = snapshot.config ?? {};
|
||||
const telegram = (config.telegram ?? {}) as Record<string, unknown>;
|
||||
const discord = (config.discord ?? {}) as Record<string, unknown>;
|
||||
const slack = (config.slack ?? {}) as Record<string, unknown>;
|
||||
const signal = (config.signal ?? {}) as Record<string, unknown>;
|
||||
const imessage = (config.imessage ?? {}) as Record<string, unknown>;
|
||||
const toList = (value: unknown) =>
|
||||
@@ -401,7 +402,9 @@ export function updateConfigFormValue(
|
||||
path: Array<string | number>,
|
||||
value: unknown,
|
||||
) {
|
||||
const base = cloneConfigObject(state.configForm ?? {});
|
||||
const base = cloneConfigObject(
|
||||
state.configForm ?? state.configSnapshot?.config ?? {},
|
||||
);
|
||||
setPathValue(base, path, value);
|
||||
state.configForm = base;
|
||||
state.configFormDirty = true;
|
||||
@@ -411,7 +414,9 @@ export function removeConfigFormValue(
|
||||
state: ConfigState,
|
||||
path: Array<string | number>,
|
||||
) {
|
||||
const base = cloneConfigObject(state.configForm ?? {});
|
||||
const base = cloneConfigObject(
|
||||
state.configForm ?? state.configSnapshot?.config ?? {},
|
||||
);
|
||||
removePathValue(base, path);
|
||||
state.configForm = base;
|
||||
state.configFormDirty = true;
|
||||
|
||||
@@ -73,7 +73,14 @@ export function buildCronPayload(form: CronFormState) {
|
||||
kind: "agentTurn";
|
||||
message: string;
|
||||
deliver?: boolean;
|
||||
channel?: "last" | "whatsapp" | "telegram";
|
||||
channel?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
to?: string;
|
||||
timeoutSeconds?: number;
|
||||
} = { kind: "agentTurn", message };
|
||||
@@ -188,4 +195,3 @@ export async function loadCronRuns(state: CronState, jobId: string) {
|
||||
state.cronError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"element",
|
||||
"node",
|
||||
"nodeId",
|
||||
"jobId",
|
||||
"id",
|
||||
"requestId",
|
||||
"to",
|
||||
"channelId",
|
||||
@@ -136,10 +136,10 @@
|
||||
"label": "add",
|
||||
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
|
||||
},
|
||||
"update": { "label": "update", "detailKeys": ["jobId"] },
|
||||
"remove": { "label": "remove", "detailKeys": ["jobId"] },
|
||||
"run": { "label": "run", "detailKeys": ["jobId"] },
|
||||
"runs": { "label": "runs", "detailKeys": ["jobId"] },
|
||||
"update": { "label": "update", "detailKeys": ["id"] },
|
||||
"remove": { "label": "remove", "detailKeys": ["id"] },
|
||||
"run": { "label": "run", "detailKeys": ["id"] },
|
||||
"runs": { "label": "runs", "detailKeys": ["id"] },
|
||||
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -271,7 +271,14 @@ export type CronPayload =
|
||||
thinking?: string;
|
||||
timeoutSeconds?: number;
|
||||
deliver?: boolean;
|
||||
channel?: "last" | "whatsapp" | "telegram";
|
||||
channel?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
to?: string;
|
||||
bestEffortDeliver?: boolean;
|
||||
};
|
||||
@@ -306,7 +313,7 @@ export type CronJob = {
|
||||
|
||||
export type CronStatus = {
|
||||
enabled: boolean;
|
||||
jobCount: number;
|
||||
jobs: number;
|
||||
nextWakeAtMs?: number | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -162,7 +162,14 @@ export type CronFormState = {
|
||||
payloadKind: "systemEvent" | "agentTurn";
|
||||
payloadText: string;
|
||||
deliver: boolean;
|
||||
channel: "last" | "whatsapp" | "telegram";
|
||||
channel:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
to: string;
|
||||
timeoutSeconds: string;
|
||||
postToMainPrefix: string;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import type { ConfigUiHint, ConfigUiHints } from "../types";
|
||||
|
||||
export type ConfigFormProps = {
|
||||
schema: unknown | null;
|
||||
schema: JsonSchema | null;
|
||||
uiHints: ConfigUiHints;
|
||||
value: Record<string, unknown> | null;
|
||||
disabled?: boolean;
|
||||
unsupportedPaths?: string[];
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
};
|
||||
|
||||
@@ -14,22 +16,26 @@ type JsonSchema = {
|
||||
description?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
items?: JsonSchema | JsonSchema[];
|
||||
additionalProperties?: JsonSchema | boolean;
|
||||
enum?: unknown[];
|
||||
const?: unknown;
|
||||
default?: unknown;
|
||||
anyOf?: JsonSchema[];
|
||||
oneOf?: JsonSchema[];
|
||||
allOf?: JsonSchema[];
|
||||
nullable?: boolean;
|
||||
};
|
||||
|
||||
export function renderConfigForm(props: ConfigFormProps) {
|
||||
if (!props.schema) {
|
||||
return html`<div class="muted">Schema unavailable.</div>`;
|
||||
}
|
||||
const schema = props.schema as JsonSchema;
|
||||
const schema = props.schema;
|
||||
const value = props.value ?? {};
|
||||
if (schemaType(schema) !== "object" || !schema.properties) {
|
||||
return html`<div class="callout danger">Unsupported schema. Use Raw.</div>`;
|
||||
}
|
||||
const unsupported = new Set(props.unsupportedPaths ?? []);
|
||||
const entries = Object.entries(schema.properties);
|
||||
const sorted = entries.sort((a, b) => {
|
||||
const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 0;
|
||||
@@ -46,6 +52,8 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
value: (value as Record<string, unknown>)[key],
|
||||
path: [key],
|
||||
hints: props.uiHints,
|
||||
unsupported,
|
||||
disabled: props.disabled ?? false,
|
||||
onPatch: props.onPatch,
|
||||
}),
|
||||
)}
|
||||
@@ -58,15 +66,114 @@ function renderNode(params: {
|
||||
value: unknown;
|
||||
path: Array<string | number>;
|
||||
hints: ConfigUiHints;
|
||||
unsupported: Set<string>;
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}) {
|
||||
const { schema, value, path, hints, onPatch } = params;
|
||||
}): TemplateResult | typeof nothing {
|
||||
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const type = schemaType(schema);
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
const key = pathKey(path);
|
||||
|
||||
if (schema.anyOf || schema.oneOf || schema.allOf) {
|
||||
if (unsupported.has(key)) {
|
||||
return html`<div class="callout danger">
|
||||
${label}: unsupported schema node. Use Raw.
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
const variants = schema.anyOf ?? schema.oneOf ?? [];
|
||||
const nonNull = variants.filter(
|
||||
(v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))),
|
||||
);
|
||||
|
||||
if (nonNull.length === 1) {
|
||||
return renderNode({ ...params, schema: nonNull[0] });
|
||||
}
|
||||
|
||||
const extractLiteral = (v: JsonSchema): unknown | undefined => {
|
||||
if (v.const !== undefined) return v.const;
|
||||
if (v.enum && v.enum.length === 1) return v.enum[0];
|
||||
return undefined;
|
||||
};
|
||||
const literals = nonNull.map(extractLiteral);
|
||||
const allLiterals = literals.every((v) => v !== undefined);
|
||||
|
||||
if (allLiterals && literals.length > 0) {
|
||||
const currentIndex = literals.findIndex(
|
||||
(lit) => lit === value || String(lit) === String(value),
|
||||
);
|
||||
return html`
|
||||
<label class="field">
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<select
|
||||
.value=${currentIndex >= 0 ? String(currentIndex) : ""}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const idx = (e.target as HTMLSelectElement).value;
|
||||
onPatch(path, idx === "" ? undefined : literals[Number(idx)]);
|
||||
}}
|
||||
>
|
||||
<option value="">—</option>
|
||||
${literals.map(
|
||||
(opt, i) => html`<option value=${String(i)}>${String(opt)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
const primitiveTypes = ["string", "number", "integer", "boolean"];
|
||||
const allPrimitive = nonNull.every((v) => v.type && primitiveTypes.includes(String(v.type)));
|
||||
if (allPrimitive) {
|
||||
const typeHint = nonNull.map((v) => v.type).join(" | ");
|
||||
const hasBoolean = nonNull.some((v) => v.type === "boolean");
|
||||
const hasNumber = nonNull.some((v) => v.type === "number" || v.type === "integer");
|
||||
const isInteger = nonNull.every((v) => v.type !== "number");
|
||||
return html`
|
||||
<label class="field">
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${typeHint}
|
||||
.value=${value == null ? "" : String(value)}
|
||||
?disabled=${disabled}
|
||||
@input=${(e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
if (raw === "") {
|
||||
onPatch(path, undefined);
|
||||
return;
|
||||
}
|
||||
if (hasBoolean && (raw === "true" || raw === "false")) {
|
||||
onPatch(path, raw === "true");
|
||||
return;
|
||||
}
|
||||
if (hasNumber && /^-?\d+(\.\d+)?$/.test(raw)) {
|
||||
const num = Number(raw);
|
||||
if (Number.isFinite(num) && (!isInteger || Number.isInteger(num))) {
|
||||
onPatch(path, num);
|
||||
return;
|
||||
}
|
||||
}
|
||||
onPatch(path, raw);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`<div class="callout danger">
|
||||
${label}: unsupported schema node. Use Raw.
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (schema.allOf) {
|
||||
return html`<div class="callout danger">
|
||||
${label}: unsupported schema node. Use Raw.
|
||||
</div>`;
|
||||
@@ -75,7 +182,11 @@ function renderNode(params: {
|
||||
if (type === "object") {
|
||||
const props = schema.properties ?? {};
|
||||
const entries = Object.entries(props);
|
||||
if (entries.length === 0) return nothing;
|
||||
const hasMap =
|
||||
schema.additionalProperties &&
|
||||
typeof schema.additionalProperties === "object";
|
||||
if (entries.length === 0 && !hasMap) return nothing;
|
||||
const reservedKeys = new Set(entries.map(([key]) => key));
|
||||
return html`
|
||||
<fieldset class="field-group">
|
||||
<legend>${label}</legend>
|
||||
@@ -86,9 +197,23 @@ function renderNode(params: {
|
||||
value: value && typeof value === "object" ? (value as any)[key] : undefined,
|
||||
path: [...path, key],
|
||||
hints,
|
||||
unsupported,
|
||||
onPatch,
|
||||
disabled,
|
||||
}),
|
||||
)}
|
||||
${hasMap
|
||||
? renderMapField({
|
||||
schema: schema.additionalProperties as JsonSchema,
|
||||
value: value && typeof value === "object" ? (value as any) : {},
|
||||
path,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
reservedKeys,
|
||||
onPatch,
|
||||
})
|
||||
: nothing}
|
||||
</fieldset>
|
||||
`;
|
||||
}
|
||||
@@ -101,14 +226,15 @@ function renderNode(params: {
|
||||
return html`
|
||||
<div class="field">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<span>${label}</span>
|
||||
<button
|
||||
class="btn"
|
||||
@click=${() => {
|
||||
const next = [...arr, defaultValue(itemSchema)];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = [...arr, defaultValue(itemSchema)];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
@@ -121,11 +247,14 @@ function renderNode(params: {
|
||||
value: entry,
|
||||
path: [...path, index],
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
onPatch,
|
||||
})
|
||||
: nothing}
|
||||
<button
|
||||
class="btn danger"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = arr.slice();
|
||||
next.splice(index, 1);
|
||||
@@ -141,17 +270,26 @@ function renderNode(params: {
|
||||
}
|
||||
|
||||
if (schema.enum) {
|
||||
const enumValues = schema.enum;
|
||||
const currentIndex = enumValues.findIndex(
|
||||
(v) => v === value || String(v) === String(value),
|
||||
);
|
||||
const unsetValue = "__unset__";
|
||||
return html`
|
||||
<label class="field">
|
||||
<span>${label}</span>
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<select
|
||||
.value=${value == null ? "" : String(value)}
|
||||
@change=${(e: Event) =>
|
||||
onPatch(path, (e.target as HTMLSelectElement).value)}
|
||||
.value=${currentIndex >= 0 ? String(currentIndex) : unsetValue}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const idx = (e.target as HTMLSelectElement).value;
|
||||
onPatch(path, idx === unsetValue ? undefined : enumValues[Number(idx)]);
|
||||
}}
|
||||
>
|
||||
${schema.enum.map(
|
||||
(opt) => html`<option value=${String(opt)}>${String(opt)}</option>`,
|
||||
<option value=${unsetValue}>—</option>
|
||||
${enumValues.map(
|
||||
(opt, i) => html`<option value=${String(i)}>${String(opt)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
@@ -161,11 +299,12 @@ function renderNode(params: {
|
||||
if (type === "boolean") {
|
||||
return html`
|
||||
<label class="field">
|
||||
<span>${label}</span>
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${Boolean(value)}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) =>
|
||||
onPatch(path, (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
@@ -176,11 +315,12 @@ function renderNode(params: {
|
||||
if (type === "number" || type === "integer") {
|
||||
return html`
|
||||
<label class="field">
|
||||
<span>${label}</span>
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<input
|
||||
type="number"
|
||||
.value=${value == null ? "" : String(value)}
|
||||
?disabled=${disabled}
|
||||
@input=${(e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
const parsed = raw === "" ? undefined : Number(raw);
|
||||
@@ -196,12 +336,13 @@ function renderNode(params: {
|
||||
const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : "");
|
||||
return html`
|
||||
<label class="field">
|
||||
<span>${label}</span>
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<input
|
||||
type=${isSensitive ? "password" : "text"}
|
||||
placeholder=${placeholder}
|
||||
.value=${value == null ? "" : String(value)}
|
||||
?disabled=${disabled}
|
||||
@input=${(e: Event) =>
|
||||
onPatch(path, (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
@@ -210,7 +351,7 @@ function renderNode(params: {
|
||||
}
|
||||
|
||||
return html`<div class="field">
|
||||
<span>${label}</span>
|
||||
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||
<div class="muted">Unsupported type. Use Raw.</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -272,3 +413,283 @@ function isSensitivePath(path: Array<string | number>): boolean {
|
||||
key.endsWith("key")
|
||||
);
|
||||
}
|
||||
|
||||
function renderMapField(params: {
|
||||
schema: JsonSchema;
|
||||
value: Record<string, unknown>;
|
||||
path: Array<string | number>;
|
||||
hints: ConfigUiHints;
|
||||
unsupported: Set<string>;
|
||||
disabled: boolean;
|
||||
reservedKeys: Set<string>;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const {
|
||||
schema,
|
||||
value,
|
||||
path,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
reservedKeys,
|
||||
onPatch,
|
||||
} = params;
|
||||
const entries = Object.entries(value ?? {}).filter(
|
||||
([key]) => !reservedKeys.has(key),
|
||||
);
|
||||
return html`
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<span class="muted">Extra entries</span>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = { ...(value ?? {}) };
|
||||
let index = 1;
|
||||
let key = `new-${index}`;
|
||||
while (key in next) {
|
||||
index += 1;
|
||||
key = `new-${index}`;
|
||||
}
|
||||
next[key] = defaultValue(schema);
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
${entries.length === 0
|
||||
? html`<div class="muted">No entries yet.</div>`
|
||||
: entries.map(([key, entryValue]) => {
|
||||
const valuePath = [...path, key];
|
||||
return html`<div class="array-item" style="gap: 8px;">
|
||||
<input
|
||||
class="mono"
|
||||
style="min-width: 140px;"
|
||||
?disabled=${disabled}
|
||||
.value=${key}
|
||||
@change=${(e: Event) => {
|
||||
const nextKey = (e.target as HTMLInputElement).value.trim();
|
||||
if (!nextKey || nextKey === key) return;
|
||||
const next = { ...(value ?? {}) };
|
||||
if (nextKey in next) return;
|
||||
next[nextKey] = next[key];
|
||||
delete next[key];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
/>
|
||||
<div style="flex: 1;">
|
||||
${renderNode({
|
||||
schema,
|
||||
value: entryValue,
|
||||
path: valuePath,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
showLabel: false,
|
||||
onPatch,
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
class="btn danger"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
const next = { ...(value ?? {}) };
|
||||
delete next[key];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export type ConfigSchemaAnalysis = {
|
||||
schema: JsonSchema | null;
|
||||
unsupportedPaths: string[];
|
||||
};
|
||||
|
||||
export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return { schema: null, unsupportedPaths: ["<root>"] };
|
||||
}
|
||||
const result = normalizeSchemaNode(raw as JsonSchema, []);
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeSchemaNode(
|
||||
schema: JsonSchema,
|
||||
path: Array<string | number>,
|
||||
): ConfigSchemaAnalysis {
|
||||
const unsupportedPaths: string[] = [];
|
||||
const normalized = { ...schema };
|
||||
const pathLabel = pathKey(path) || "<root>";
|
||||
|
||||
if (schema.anyOf || schema.oneOf || schema.allOf) {
|
||||
const union = normalizeUnion(schema, path);
|
||||
if (union) return union;
|
||||
unsupportedPaths.push(pathLabel);
|
||||
return { schema, unsupportedPaths };
|
||||
}
|
||||
|
||||
const nullable =
|
||||
Array.isArray(schema.type) && schema.type.includes("null");
|
||||
const type =
|
||||
schemaType(schema) ??
|
||||
(schema.properties || schema.additionalProperties ? "object" : undefined);
|
||||
normalized.type = type ?? schema.type;
|
||||
normalized.nullable = nullable || schema.nullable;
|
||||
|
||||
if (normalized.enum) {
|
||||
const { enumValues, nullable: enumNullable } = normalizeEnumValues(
|
||||
normalized.enum,
|
||||
);
|
||||
normalized.enum = enumValues;
|
||||
if (enumNullable) normalized.nullable = true;
|
||||
if (enumValues.length === 0) {
|
||||
unsupportedPaths.push(pathLabel);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "object") {
|
||||
const props = schema.properties ?? {};
|
||||
const normalizedProps: Record<string, JsonSchema> = {};
|
||||
for (const [key, child] of Object.entries(props)) {
|
||||
const result = normalizeSchemaNode(child, [...path, key]);
|
||||
if (result.schema) normalizedProps[key] = result.schema;
|
||||
unsupportedPaths.push(...result.unsupportedPaths);
|
||||
}
|
||||
normalized.properties = normalizedProps;
|
||||
|
||||
if (schema.additionalProperties === true) {
|
||||
unsupportedPaths.push(pathLabel);
|
||||
} else if (schema.additionalProperties === false) {
|
||||
normalized.additionalProperties = false;
|
||||
} else if (schema.additionalProperties) {
|
||||
const result = normalizeSchemaNode(
|
||||
schema.additionalProperties,
|
||||
[...path, "*"],
|
||||
);
|
||||
normalized.additionalProperties = result.schema ?? schema.additionalProperties;
|
||||
if (result.unsupportedPaths.length > 0) {
|
||||
unsupportedPaths.push(pathLabel);
|
||||
}
|
||||
}
|
||||
} else if (type === "array") {
|
||||
const itemSchema = Array.isArray(schema.items)
|
||||
? schema.items[0]
|
||||
: schema.items;
|
||||
if (!itemSchema) {
|
||||
unsupportedPaths.push(pathLabel);
|
||||
} else {
|
||||
const result = normalizeSchemaNode(itemSchema, [...path, "*"]);
|
||||
normalized.items = result.schema ?? itemSchema;
|
||||
if (result.unsupportedPaths.length > 0) {
|
||||
unsupportedPaths.push(pathLabel);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
type === "string" ||
|
||||
type === "number" ||
|
||||
type === "integer" ||
|
||||
type === "boolean"
|
||||
) {
|
||||
// ok
|
||||
} else if (!normalized.enum) {
|
||||
unsupportedPaths.push(pathLabel);
|
||||
}
|
||||
|
||||
return {
|
||||
schema: normalized,
|
||||
unsupportedPaths: Array.from(new Set(unsupportedPaths)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUnion(
|
||||
schema: JsonSchema,
|
||||
path: Array<string | number>,
|
||||
): ConfigSchemaAnalysis | null {
|
||||
if (schema.allOf) return null;
|
||||
const variants = schema.anyOf ?? schema.oneOf;
|
||||
if (!variants) return null;
|
||||
const values: unknown[] = [];
|
||||
const nonLiteral: JsonSchema[] = [];
|
||||
let nullable = false;
|
||||
for (const variant of variants) {
|
||||
if (!variant || typeof variant !== "object") return null;
|
||||
if (Array.isArray(variant.enum)) {
|
||||
const { enumValues, nullable: enumNullable } = normalizeEnumValues(
|
||||
variant.enum,
|
||||
);
|
||||
values.push(...enumValues);
|
||||
if (enumNullable) nullable = true;
|
||||
continue;
|
||||
}
|
||||
if ("const" in variant) {
|
||||
if (variant.const === null || variant.const === undefined) {
|
||||
nullable = true;
|
||||
continue;
|
||||
}
|
||||
values.push(variant.const);
|
||||
continue;
|
||||
}
|
||||
if (schemaType(variant) === "null") {
|
||||
nullable = true;
|
||||
continue;
|
||||
}
|
||||
nonLiteral.push(variant);
|
||||
}
|
||||
|
||||
if (values.length > 0 && nonLiteral.length === 0) {
|
||||
const unique: unknown[] = [];
|
||||
for (const value of values) {
|
||||
if (!unique.some((entry) => Object.is(entry, value))) unique.push(value);
|
||||
}
|
||||
return {
|
||||
schema: {
|
||||
...schema,
|
||||
enum: unique,
|
||||
nullable,
|
||||
anyOf: undefined,
|
||||
oneOf: undefined,
|
||||
allOf: undefined,
|
||||
},
|
||||
unsupportedPaths: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (nonLiteral.length === 1) {
|
||||
const result = normalizeSchemaNode(nonLiteral[0], path);
|
||||
if (result.schema) {
|
||||
result.schema.nullable = nullable || result.schema.nullable;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const primitiveTypes = ["string", "number", "integer", "boolean"];
|
||||
const allPrimitive = nonLiteral.every(
|
||||
(v) => v.type && primitiveTypes.includes(String(v.type)),
|
||||
);
|
||||
if (allPrimitive && nonLiteral.length > 0 && values.length === 0) {
|
||||
return {
|
||||
schema: { ...schema, nullable },
|
||||
unsupportedPaths: [],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeEnumValues(values: unknown[]) {
|
||||
const filtered = values.filter((value) => value !== null && value !== undefined);
|
||||
const nullable = filtered.length !== values.length;
|
||||
const unique: unknown[] = [];
|
||||
for (const value of filtered) {
|
||||
if (!unique.some((entry) => Object.is(entry, value))) unique.push(value);
|
||||
}
|
||||
return { enumValues: unique, nullable };
|
||||
}
|
||||
|
||||
44
ui/src/ui/views/config.browser.test.ts
Normal file
44
ui/src/ui/views/config.browser.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderConfig } from "./config";
|
||||
|
||||
describe("config view", () => {
|
||||
it("disables save when form is unsafe", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfig({
|
||||
raw: "{\n}\n",
|
||||
valid: true,
|
||||
issues: [],
|
||||
loading: false,
|
||||
saving: false,
|
||||
connected: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mixed: { anyOf: [{ type: "string" }, { type: "number" }] },
|
||||
},
|
||||
},
|
||||
schemaLoading: false,
|
||||
uiHints: {},
|
||||
formMode: "form",
|
||||
formValue: { mixed: "x" },
|
||||
onRawChange: vi.fn(),
|
||||
onFormModeChange: vi.fn(),
|
||||
onFormPatch: vi.fn(),
|
||||
onReload: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const saveButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((btn) => btn.textContent?.trim() === "Save") as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
expect(saveButton).not.toBeUndefined();
|
||||
expect(saveButton?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { ConfigUiHints } from "../types";
|
||||
import { renderConfigForm } from "./config-form";
|
||||
import { analyzeConfigSchema, renderConfigForm } from "./config-form";
|
||||
|
||||
export type ConfigProps = {
|
||||
raw: string;
|
||||
@@ -24,6 +24,16 @@ export type ConfigProps = {
|
||||
export function renderConfig(props: ConfigProps) {
|
||||
const validity =
|
||||
props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
|
||||
const analysis = analyzeConfigSchema(props.schema);
|
||||
const formUnsafe = analysis.schema
|
||||
? analysis.unsupportedPaths.length > 0
|
||||
: false;
|
||||
const canSaveForm =
|
||||
Boolean(props.formValue) && !props.loading && !formUnsafe;
|
||||
const canSave =
|
||||
props.connected &&
|
||||
!props.saving &&
|
||||
(props.formMode === "raw" ? true : canSaveForm);
|
||||
return html`
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
@@ -52,7 +62,7 @@ export function renderConfig(props: ConfigProps) {
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${props.saving || !props.connected}
|
||||
?disabled=${!canSave}
|
||||
@click=${props.onSave}
|
||||
>
|
||||
${props.saving ? "Saving…" : "Save"}
|
||||
@@ -70,11 +80,19 @@ export function renderConfig(props: ConfigProps) {
|
||||
${props.schemaLoading
|
||||
? html`<div class="muted">Loading schema…</div>`
|
||||
: renderConfigForm({
|
||||
schema: props.schema,
|
||||
schema: analysis.schema,
|
||||
uiHints: props.uiHints,
|
||||
value: props.formValue,
|
||||
disabled: props.loading || !props.formValue,
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
onPatch: props.onFormPatch,
|
||||
})}
|
||||
${formUnsafe
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
Form view can’t safely edit some fields.
|
||||
Use Raw to avoid losing config entries.
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>`
|
||||
: html`<label class="field" style="margin-top: 12px;">
|
||||
<span>Raw JSON5</span>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function renderCron(props: CronProps) {
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Jobs</div>
|
||||
<div class="stat-value">${props.status?.jobCount ?? "n/a"}</div>
|
||||
<div class="stat-value">${props.status?.jobs ?? "n/a"}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Next wake</div>
|
||||
@@ -185,6 +185,10 @@ export function renderCron(props: CronProps) {
|
||||
<option value="last">Last</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="signal">Signal</option>
|
||||
<option value="imessage">iMessage</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
@@ -387,4 +391,3 @@ function renderRun(entry: CronRunLogEntry) {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user