fix: harden config form
This commit is contained in:
@@ -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.
|
- 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).
|
- 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.
|
- 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.
|
- 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).
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -132,9 +132,13 @@ describe("getApiKeyForModel", () => {
|
|||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const { resolveApiKeyForProvider } = await import("./model-auth.js");
|
const { resolveApiKeyForProvider } = await import("./model-auth.js");
|
||||||
|
|
||||||
await expect(
|
let error: unknown = null;
|
||||||
resolveApiKeyForProvider({ provider: "openai" }),
|
try {
|
||||||
).rejects.toThrow(/openai-codex\/gpt-5\\.2/);
|
await resolveApiKeyForProvider({ provider: "openai" });
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
expect(String(error)).toContain("openai-codex/gpt-5.2");
|
||||||
} finally {
|
} finally {
|
||||||
if (previousOpenAiKey === undefined) {
|
if (previousOpenAiKey === undefined) {
|
||||||
delete process.env.OPENAI_API_KEY;
|
delete process.env.OPENAI_API_KEY;
|
||||||
|
|||||||
@@ -6,9 +6,15 @@ import {
|
|||||||
type OAuthCredentials,
|
type OAuthCredentials,
|
||||||
type OAuthProvider,
|
type OAuthProvider,
|
||||||
} from "@mariozechner/pi-ai";
|
} 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 { 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 { loadModelCatalog } from "../agents/model-catalog.js";
|
||||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||||
import {
|
import {
|
||||||
@@ -62,6 +68,7 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { resolveUserPath, sleep } from "../utils.js";
|
import { resolveUserPath, sleep } from "../utils.js";
|
||||||
import type { WizardPrompter } from "./prompts.js";
|
import type { WizardPrompter } from "./prompts.js";
|
||||||
|
import type { AgentModelListConfig } from "../config/types.js";
|
||||||
|
|
||||||
const OPENAI_CODEX_DEFAULT_MODEL = "openai-codex/gpt-5.2";
|
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";
|
return normalized === "gpt" || normalized === "gpt-mini";
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyOpenAICodexModelDefault(
|
function resolvePrimaryModel(
|
||||||
cfg: ClawdbotConfig,
|
model?: AgentModelListConfig | string,
|
||||||
): { next: ClawdbotConfig; changed: boolean } {
|
): string | undefined {
|
||||||
if (!shouldSetOpenAICodexModel(cfg.agent?.model)) {
|
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 { next: cfg, changed: false };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -85,7 +104,10 @@ function applyOpenAICodexModelDefault(
|
|||||||
...cfg,
|
...cfg,
|
||||||
agent: {
|
agent: {
|
||||||
...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,
|
changed: true,
|
||||||
@@ -125,8 +147,7 @@ async function warnIfModelConfigLooksOff(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ref.provider === "openai") {
|
if (ref.provider === "openai") {
|
||||||
const hasCodex =
|
const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0;
|
||||||
listProfilesForProvider(store, "openai-codex").length > 0;
|
|
||||||
if (hasCodex) {
|
if (hasCodex) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`Detected OpenAI Codex OAuth. Consider setting agent.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`,
|
`Detected OpenAI Codex OAuth. Consider setting agent.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render } from "lit";
|
import { render } from "lit";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { renderConfigForm } from "./views/config-form";
|
import { analyzeConfigSchema, renderConfigForm } from "./views/config-form";
|
||||||
|
|
||||||
const rootSchema = {
|
const rootSchema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -28,6 +28,14 @@ const rootSchema = {
|
|||||||
enabled: {
|
enabled: {
|
||||||
type: "boolean",
|
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", () => {
|
it("renders inputs and patches values", () => {
|
||||||
const onPatch = vi.fn();
|
const onPatch = vi.fn();
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
|
const analysis = analyzeConfigSchema(rootSchema);
|
||||||
render(
|
render(
|
||||||
renderConfigForm({
|
renderConfigForm({
|
||||||
schema: rootSchema,
|
schema: analysis.schema,
|
||||||
uiHints: {
|
uiHints: {
|
||||||
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
|
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
|
||||||
},
|
},
|
||||||
|
unsupportedPaths: analysis.unsupportedPaths,
|
||||||
value: {},
|
value: {},
|
||||||
onPatch,
|
onPatch,
|
||||||
}),
|
}),
|
||||||
@@ -79,10 +89,12 @@ describe("config form renderer", () => {
|
|||||||
it("adds and removes array entries", () => {
|
it("adds and removes array entries", () => {
|
||||||
const onPatch = vi.fn();
|
const onPatch = vi.fn();
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
|
const analysis = analyzeConfigSchema(rootSchema);
|
||||||
render(
|
render(
|
||||||
renderConfigForm({
|
renderConfigForm({
|
||||||
schema: rootSchema,
|
schema: analysis.schema,
|
||||||
uiHints: {},
|
uiHints: {},
|
||||||
|
unsupportedPaths: analysis.unsupportedPaths,
|
||||||
value: { allowFrom: ["+1"] },
|
value: { allowFrom: ["+1"] },
|
||||||
onPatch,
|
onPatch,
|
||||||
}),
|
}),
|
||||||
@@ -103,4 +115,102 @@ describe("config form renderer", () => {
|
|||||||
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { applyConfigSnapshot, type ConfigState } from "./config";
|
import {
|
||||||
|
applyConfigSnapshot,
|
||||||
|
updateConfigFormValue,
|
||||||
|
type ConfigState,
|
||||||
|
} from "./config";
|
||||||
import {
|
import {
|
||||||
defaultDiscordActions,
|
defaultDiscordActions,
|
||||||
defaultSlackActions,
|
defaultSlackActions,
|
||||||
@@ -137,3 +141,23 @@ describe("applyConfigSnapshot", () => {
|
|||||||
expect(state.slackForm.actions).toEqual(defaultSlackActions);
|
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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -402,7 +402,9 @@ export function updateConfigFormValue(
|
|||||||
path: Array<string | number>,
|
path: Array<string | number>,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
) {
|
) {
|
||||||
const base = cloneConfigObject(state.configForm ?? {});
|
const base = cloneConfigObject(
|
||||||
|
state.configForm ?? state.configSnapshot?.config ?? {},
|
||||||
|
);
|
||||||
setPathValue(base, path, value);
|
setPathValue(base, path, value);
|
||||||
state.configForm = base;
|
state.configForm = base;
|
||||||
state.configFormDirty = true;
|
state.configFormDirty = true;
|
||||||
@@ -412,7 +414,9 @@ export function removeConfigFormValue(
|
|||||||
state: ConfigState,
|
state: ConfigState,
|
||||||
path: Array<string | number>,
|
path: Array<string | number>,
|
||||||
) {
|
) {
|
||||||
const base = cloneConfigObject(state.configForm ?? {});
|
const base = cloneConfigObject(
|
||||||
|
state.configForm ?? state.configSnapshot?.config ?? {},
|
||||||
|
);
|
||||||
removePathValue(base, path);
|
removePathValue(base, path);
|
||||||
state.configForm = base;
|
state.configForm = base;
|
||||||
state.configFormDirty = true;
|
state.configFormDirty = true;
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { html, nothing } from "lit";
|
|||||||
import type { ConfigUiHint, ConfigUiHints } from "../types";
|
import type { ConfigUiHint, ConfigUiHints } from "../types";
|
||||||
|
|
||||||
export type ConfigFormProps = {
|
export type ConfigFormProps = {
|
||||||
schema: unknown | null;
|
schema: JsonSchema | null;
|
||||||
uiHints: ConfigUiHints;
|
uiHints: ConfigUiHints;
|
||||||
value: Record<string, unknown> | null;
|
value: Record<string, unknown> | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
unsupportedPaths?: string[];
|
||||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -14,22 +16,26 @@ type JsonSchema = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
properties?: Record<string, JsonSchema>;
|
properties?: Record<string, JsonSchema>;
|
||||||
items?: JsonSchema | JsonSchema[];
|
items?: JsonSchema | JsonSchema[];
|
||||||
|
additionalProperties?: JsonSchema | boolean;
|
||||||
enum?: unknown[];
|
enum?: unknown[];
|
||||||
|
const?: unknown;
|
||||||
default?: unknown;
|
default?: unknown;
|
||||||
anyOf?: JsonSchema[];
|
anyOf?: JsonSchema[];
|
||||||
oneOf?: JsonSchema[];
|
oneOf?: JsonSchema[];
|
||||||
allOf?: JsonSchema[];
|
allOf?: JsonSchema[];
|
||||||
|
nullable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function renderConfigForm(props: ConfigFormProps) {
|
export function renderConfigForm(props: ConfigFormProps) {
|
||||||
if (!props.schema) {
|
if (!props.schema) {
|
||||||
return html`<div class="muted">Schema unavailable.</div>`;
|
return html`<div class="muted">Schema unavailable.</div>`;
|
||||||
}
|
}
|
||||||
const schema = props.schema as JsonSchema;
|
const schema = props.schema;
|
||||||
const value = props.value ?? {};
|
const value = props.value ?? {};
|
||||||
if (schemaType(schema) !== "object" || !schema.properties) {
|
if (schemaType(schema) !== "object" || !schema.properties) {
|
||||||
return html`<div class="callout danger">Unsupported schema. Use Raw.</div>`;
|
return html`<div class="callout danger">Unsupported schema. Use Raw.</div>`;
|
||||||
}
|
}
|
||||||
|
const unsupported = new Set(props.unsupportedPaths ?? []);
|
||||||
const entries = Object.entries(schema.properties);
|
const entries = Object.entries(schema.properties);
|
||||||
const sorted = entries.sort((a, b) => {
|
const sorted = entries.sort((a, b) => {
|
||||||
const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 0;
|
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],
|
value: (value as Record<string, unknown>)[key],
|
||||||
path: [key],
|
path: [key],
|
||||||
hints: props.uiHints,
|
hints: props.uiHints,
|
||||||
|
unsupported,
|
||||||
|
disabled: props.disabled ?? false,
|
||||||
onPatch: props.onPatch,
|
onPatch: props.onPatch,
|
||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
@@ -58,13 +66,24 @@ function renderNode(params: {
|
|||||||
value: unknown;
|
value: unknown;
|
||||||
path: Array<string | number>;
|
path: Array<string | number>;
|
||||||
hints: ConfigUiHints;
|
hints: ConfigUiHints;
|
||||||
|
unsupported: Set<string>;
|
||||||
|
disabled: boolean;
|
||||||
|
showLabel?: boolean;
|
||||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
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 type = schemaType(schema);
|
||||||
const hint = hintForPath(path, hints);
|
const hint = hintForPath(path, hints);
|
||||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||||
const help = hint?.help ?? schema.description;
|
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) {
|
if (schema.anyOf || schema.oneOf || schema.allOf) {
|
||||||
return html`<div class="callout danger">
|
return html`<div class="callout danger">
|
||||||
@@ -75,7 +94,11 @@ function renderNode(params: {
|
|||||||
if (type === "object") {
|
if (type === "object") {
|
||||||
const props = schema.properties ?? {};
|
const props = schema.properties ?? {};
|
||||||
const entries = Object.entries(props);
|
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`
|
return html`
|
||||||
<fieldset class="field-group">
|
<fieldset class="field-group">
|
||||||
<legend>${label}</legend>
|
<legend>${label}</legend>
|
||||||
@@ -86,9 +109,23 @@ function renderNode(params: {
|
|||||||
value: value && typeof value === "object" ? (value as any)[key] : undefined,
|
value: value && typeof value === "object" ? (value as any)[key] : undefined,
|
||||||
path: [...path, key],
|
path: [...path, key],
|
||||||
hints,
|
hints,
|
||||||
|
unsupported,
|
||||||
onPatch,
|
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>
|
</fieldset>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -101,9 +138,10 @@ function renderNode(params: {
|
|||||||
return html`
|
return html`
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="row" style="justify-content: space-between;">
|
<div class="row" style="justify-content: space-between;">
|
||||||
<span>${label}</span>
|
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||||
<button
|
<button
|
||||||
class="btn"
|
class="btn"
|
||||||
|
?disabled=${disabled}
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
const next = [...arr, defaultValue(itemSchema)];
|
const next = [...arr, defaultValue(itemSchema)];
|
||||||
onPatch(path, next);
|
onPatch(path, next);
|
||||||
@@ -121,11 +159,14 @@ function renderNode(params: {
|
|||||||
value: entry,
|
value: entry,
|
||||||
path: [...path, index],
|
path: [...path, index],
|
||||||
hints,
|
hints,
|
||||||
|
unsupported,
|
||||||
|
disabled,
|
||||||
onPatch,
|
onPatch,
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
<button
|
<button
|
||||||
class="btn danger"
|
class="btn danger"
|
||||||
|
?disabled=${disabled}
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
const next = arr.slice();
|
const next = arr.slice();
|
||||||
next.splice(index, 1);
|
next.splice(index, 1);
|
||||||
@@ -143,10 +184,11 @@ function renderNode(params: {
|
|||||||
if (schema.enum) {
|
if (schema.enum) {
|
||||||
return html`
|
return html`
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>${label}</span>
|
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||||
<select
|
<select
|
||||||
.value=${value == null ? "" : String(value)}
|
.value=${value == null ? "" : String(value)}
|
||||||
|
?disabled=${disabled}
|
||||||
@change=${(e: Event) =>
|
@change=${(e: Event) =>
|
||||||
onPatch(path, (e.target as HTMLSelectElement).value)}
|
onPatch(path, (e.target as HTMLSelectElement).value)}
|
||||||
>
|
>
|
||||||
@@ -161,11 +203,12 @@ function renderNode(params: {
|
|||||||
if (type === "boolean") {
|
if (type === "boolean") {
|
||||||
return html`
|
return html`
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>${label}</span>
|
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
.checked=${Boolean(value)}
|
.checked=${Boolean(value)}
|
||||||
|
?disabled=${disabled}
|
||||||
@change=${(e: Event) =>
|
@change=${(e: Event) =>
|
||||||
onPatch(path, (e.target as HTMLInputElement).checked)}
|
onPatch(path, (e.target as HTMLInputElement).checked)}
|
||||||
/>
|
/>
|
||||||
@@ -176,11 +219,12 @@ function renderNode(params: {
|
|||||||
if (type === "number" || type === "integer") {
|
if (type === "number" || type === "integer") {
|
||||||
return html`
|
return html`
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>${label}</span>
|
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
.value=${value == null ? "" : String(value)}
|
.value=${value == null ? "" : String(value)}
|
||||||
|
?disabled=${disabled}
|
||||||
@input=${(e: Event) => {
|
@input=${(e: Event) => {
|
||||||
const raw = (e.target as HTMLInputElement).value;
|
const raw = (e.target as HTMLInputElement).value;
|
||||||
const parsed = raw === "" ? undefined : Number(raw);
|
const parsed = raw === "" ? undefined : Number(raw);
|
||||||
@@ -196,12 +240,13 @@ function renderNode(params: {
|
|||||||
const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : "");
|
const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : "");
|
||||||
return html`
|
return html`
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>${label}</span>
|
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||||
<input
|
<input
|
||||||
type=${isSensitive ? "password" : "text"}
|
type=${isSensitive ? "password" : "text"}
|
||||||
placeholder=${placeholder}
|
placeholder=${placeholder}
|
||||||
.value=${value == null ? "" : String(value)}
|
.value=${value == null ? "" : String(value)}
|
||||||
|
?disabled=${disabled}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
onPatch(path, (e.target as HTMLInputElement).value)}
|
onPatch(path, (e.target as HTMLInputElement).value)}
|
||||||
/>
|
/>
|
||||||
@@ -210,7 +255,7 @@ function renderNode(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`<div class="field">
|
return html`<div class="field">
|
||||||
<span>${label}</span>
|
${showLabel ? html`<span>${label}</span>` : nothing}
|
||||||
<div class="muted">Unsupported type. Use Raw.</div>
|
<div class="muted">Unsupported type. Use Raw.</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -272,3 +317,271 @@ function isSensitivePath(path: Array<string | number>): boolean {
|
|||||||
key.endsWith("key")
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
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 { html, nothing } from "lit";
|
||||||
import type { ConfigUiHints } from "../types";
|
import type { ConfigUiHints } from "../types";
|
||||||
import { renderConfigForm } from "./config-form";
|
import { analyzeConfigSchema, renderConfigForm } from "./config-form";
|
||||||
|
|
||||||
export type ConfigProps = {
|
export type ConfigProps = {
|
||||||
raw: string;
|
raw: string;
|
||||||
@@ -24,6 +24,16 @@ export type ConfigProps = {
|
|||||||
export function renderConfig(props: ConfigProps) {
|
export function renderConfig(props: ConfigProps) {
|
||||||
const validity =
|
const validity =
|
||||||
props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
|
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`
|
return html`
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="row" style="justify-content: space-between;">
|
<div class="row" style="justify-content: space-between;">
|
||||||
@@ -52,7 +62,7 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn primary"
|
class="btn primary"
|
||||||
?disabled=${props.saving || !props.connected}
|
?disabled=${!canSave}
|
||||||
@click=${props.onSave}
|
@click=${props.onSave}
|
||||||
>
|
>
|
||||||
${props.saving ? "Saving…" : "Save"}
|
${props.saving ? "Saving…" : "Save"}
|
||||||
@@ -70,11 +80,19 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
${props.schemaLoading
|
${props.schemaLoading
|
||||||
? html`<div class="muted">Loading schema…</div>`
|
? html`<div class="muted">Loading schema…</div>`
|
||||||
: renderConfigForm({
|
: renderConfigForm({
|
||||||
schema: props.schema,
|
schema: analysis.schema,
|
||||||
uiHints: props.uiHints,
|
uiHints: props.uiHints,
|
||||||
value: props.formValue,
|
value: props.formValue,
|
||||||
|
disabled: props.loading || !props.formValue,
|
||||||
|
unsupportedPaths: analysis.unsupportedPaths,
|
||||||
onPatch: props.onFormPatch,
|
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>`
|
</div>`
|
||||||
: html`<label class="field" style="margin-top: 12px;">
|
: html`<label class="field" style="margin-top: 12px;">
|
||||||
<span>Raw JSON5</span>
|
<span>Raw JSON5</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user