fix: harden config form

This commit is contained in:
Peter Steinberger
2026-01-06 03:05:56 +01:00
parent 20705d1b37
commit 00061b2fd3
9 changed files with 577 additions and 38 deletions

View File

@@ -14,6 +14,7 @@
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).
- Auth: prioritize OAuth profiles but fall back to API keys when refresh fails; stored profiles now load without explicit auth order.
- Control UI: harden config Form view with schema normalization, map editing, and guardrails to prevent data loss on save.
- Docs: add group chat participation guidance to the AGENTS template.
- Gmail: stop restart loop when `gog gmail watch serve` fails to bind (address already in use).
- Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237.

View File

@@ -132,9 +132,13 @@ describe("getApiKeyForModel", () => {
vi.resetModules();
const { resolveApiKeyForProvider } = await import("./model-auth.js");
await expect(
resolveApiKeyForProvider({ provider: "openai" }),
).rejects.toThrow(/openai-codex\/gpt-5\\.2/);
let error: unknown = null;
try {
await resolveApiKeyForProvider({ provider: "openai" });
} catch (err) {
error = err;
}
expect(String(error)).toContain("openai-codex/gpt-5.2");
} finally {
if (previousOpenAiKey === undefined) {
delete process.env.OPENAI_API_KEY;

View File

@@ -6,9 +6,15 @@ import {
type OAuthCredentials,
type OAuthProvider,
} from "@mariozechner/pi-ai";
import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js";
import {
ensureAuthProfileStore,
listProfilesForProvider,
} from "../agents/auth-profiles.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js";
import {
getCustomProviderApiKey,
resolveEnvApiKey,
} from "../agents/model-auth.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import {
@@ -62,6 +68,7 @@ import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js";
import type { WizardPrompter } from "./prompts.js";
import type { AgentModelListConfig } from "../config/types.js";
const OPENAI_CODEX_DEFAULT_MODEL = "openai-codex/gpt-5.2";
@@ -74,10 +81,22 @@ function shouldSetOpenAICodexModel(model?: string): boolean {
return normalized === "gpt" || normalized === "gpt-mini";
}
function applyOpenAICodexModelDefault(
cfg: ClawdbotConfig,
): { next: ClawdbotConfig; changed: boolean } {
if (!shouldSetOpenAICodexModel(cfg.agent?.model)) {
function resolvePrimaryModel(
model?: AgentModelListConfig | string,
): string | undefined {
if (typeof model === "string") return model;
if (model && typeof model === "object" && typeof model.primary === "string") {
return model.primary;
}
return undefined;
}
function applyOpenAICodexModelDefault(cfg: ClawdbotConfig): {
next: ClawdbotConfig;
changed: boolean;
} {
const current = resolvePrimaryModel(cfg.agent?.model);
if (!shouldSetOpenAICodexModel(current)) {
return { next: cfg, changed: false };
}
return {
@@ -85,7 +104,10 @@ function applyOpenAICodexModelDefault(
...cfg,
agent: {
...cfg.agent,
model: OPENAI_CODEX_DEFAULT_MODEL,
model:
cfg.agent?.model && typeof cfg.agent.model === "object"
? { ...cfg.agent.model, primary: OPENAI_CODEX_DEFAULT_MODEL }
: { primary: OPENAI_CODEX_DEFAULT_MODEL },
},
},
changed: true,
@@ -125,8 +147,7 @@ async function warnIfModelConfigLooksOff(
}
if (ref.provider === "openai") {
const hasCodex =
listProfilesForProvider(store, "openai-codex").length > 0;
const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0;
if (hasCodex) {
warnings.push(
`Detected OpenAI Codex OAuth. Consider setting agent.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`,

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

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { applyConfigSnapshot, type ConfigState } from "./config";
import {
applyConfigSnapshot,
updateConfigFormValue,
type ConfigState,
} from "./config";
import {
defaultDiscordActions,
defaultSlackActions,
@@ -137,3 +141,23 @@ describe("applyConfigSnapshot", () => {
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

@@ -402,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;
@@ -412,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

@@ -2,9 +2,11 @@ import { html, nothing } 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,13 +66,24 @@ 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;
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 (unsupported.has(key)) {
return html`<div class="callout danger">
${label}: unsupported schema node. Use Raw.
</div>`;
}
if (schema.anyOf || schema.oneOf || schema.allOf) {
return html`<div class="callout danger">
@@ -75,7 +94,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 +109,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 +138,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 +159,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);
@@ -143,10 +184,11 @@ function renderNode(params: {
if (schema.enum) {
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)}
?disabled=${disabled}
@change=${(e: Event) =>
onPatch(path, (e.target as HTMLSelectElement).value)}
>
@@ -161,11 +203,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 +219,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 +240,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 +255,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 +317,271 @@ 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;
}) {
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 {
const variants = schema.anyOf ?? schema.oneOf ?? schema.allOf;
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 = true;
}
return result;
}
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>