Merge branch 'main' into fix/chat-scroll-to-bottom

This commit is contained in:
Kiran Jd
2026-01-06 11:27:57 +05:30
committed by GitHub
255 changed files with 12754 additions and 3193 deletions

View File

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

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

View File

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

View File

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

View File

@@ -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"] }
}
},

View File

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

View File

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

View File

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

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

View File

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

View File

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