// Control UI helpers shared by config form node renderers. import { html, nothing, type TemplateResult } from "lit"; import type { ConfigUiHints } from "../api/types.ts"; import { icons } from "../components/icons.ts"; import "../components/tooltip.ts"; import { t } from "../i18n/index.ts"; import { formatUnknownText } from "../lib/format.ts"; import type { ConfigSearchCriteria } from "./config-form.search.ts"; import { hasSensitiveConfigData, redactedPlaceholder, type JsonSchema, } from "./config-form.shared.ts"; import { renderSettingsSegmented } from "./settings-ui.ts"; const META_KEYS = new Set(["title", "description", "default", "nullable", "tags", "x-tags"]); export type ConfigNodeRenderParams = { schema: JsonSchema; value: unknown; path: Array; hints: ConfigUiHints; rawAvailable?: boolean; unsupported: Set; disabled: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; revealSensitive?: boolean; isSensitivePathRevealed?: (path: Array) => boolean; onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }; export type ConfigNodeRenderer = ( params: ConfigNodeRenderParams, ) => TemplateResult | typeof nothing; type SensitiveRenderState = { isSensitive: boolean; isRedacted: boolean; isRevealed: boolean; canReveal: boolean; }; export function isAnySchema(schema: JsonSchema): boolean { const keys = Object.keys(schema ?? {}).filter((key) => !META_KEYS.has(key)); return keys.length === 0; } export function jsonValue(value: unknown): string { if (value === undefined) { return ""; } try { return JSON.stringify(value, null, 2) ?? ""; } catch { return ""; } } function formatComparablePrimitive(value: unknown): string | null { if ( typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" ) { return String(value); } return null; } function matchesComparablePrimitiveValue(left: unknown, right: unknown): boolean { if (Object.is(left, right)) { return true; } const leftComparable = formatComparablePrimitive(left); const rightComparable = formatComparablePrimitive(right); return leftComparable !== null && leftComparable === rightComparable; } export function isSecretRefObject(value: unknown): value is { source: string; id: string; provider?: string; } { if (!value || typeof value !== "object" || Array.isArray(value)) { return false; } const candidate = value as Record; if (typeof candidate.source !== "string" || typeof candidate.id !== "string") { return false; } return candidate.provider === undefined || typeof candidate.provider === "string"; } export function getSensitiveRenderState(params: { path: Array; value: unknown; hints: ConfigUiHints; revealSensitive: boolean; isSensitivePathRevealed?: (path: Array) => boolean; }): SensitiveRenderState { const isSensitive = hasSensitiveConfigData(params.value, params.path, params.hints); const isRevealed = isSensitive && (params.revealSensitive || (params.isSensitivePathRevealed?.(params.path) ?? false)); return { isSensitive, isRedacted: isSensitive && !isRevealed, isRevealed, canReveal: isSensitive, }; } export function renderSensitiveToggleButton(params: { path: Array; state: SensitiveRenderState; disabled: boolean; onToggleSensitivePath?: (path: Array) => void; }): TemplateResult | typeof nothing { const { state } = params; if (!state.isSensitive || !params.onToggleSensitivePath) { return nothing; } const label = state.canReveal ? state.isRevealed ? t("configForm.hideValue") : t("configForm.revealValue") : t("configForm.disableStreamToReveal"); return html` `; } /* Sensitive fields inset the reveal eye inside the field (settings-secret * pattern); non-sensitive fields render the bare control unchanged. */ export function wrapSensitiveControl( control: TemplateResult, toggle: TemplateResult | typeof nothing, ): TemplateResult { if (toggle === nothing) { return control; } return html`${control}${toggle}`; } export function renderTags(tags: string[]): TemplateResult | typeof nothing { const visibleTags = tags.filter((tag) => tag !== "advanced"); if (visibleTags.length === 0) { return nothing; } return html`
${visibleTags.map((tag) => html`${tag}`)}
`; } export function renderFieldRow(params: { label: unknown; help?: unknown; tags: string[]; showLabel: boolean; control: TemplateResult | typeof nothing; stacked?: boolean; error?: unknown; }): TemplateResult { // Array/map item rows resolve their meta from the parent path (numeric and // wildcard segments collapse), so their help is the parent's. Showing it again // per item is noise; a row with no label of its own gets no help of its own. const help = params.showLabel ? params.help : undefined; const hasText = params.showLabel || Boolean(help) || params.tags.length > 0 || Boolean(params.error); // Control-only rows (array/map item values) stack so the control gets full width. const stacked = params.stacked || !hasText; const className = stacked ? "settings-row settings-row--stacked" : "settings-row"; return html`
${hasText ? html`
${params.showLabel ? html`${params.label}` : nothing} ${help ? html`${help}` : nothing} ${renderTags(params.tags)} ${params.error ? html`${params.error}` : nothing}
` : nothing} ${params.control !== nothing ? html`
${params.control}
` : nothing}
`; } export function renderSegmentedControl(params: { options: unknown[]; resolvedValue: unknown; disabled: boolean; ariaLabel: string; onSelect: (value: unknown) => void; }): TemplateResult { const selectedIndex = params.options.findIndex((option) => matchesComparablePrimitiveValue(option, params.resolvedValue), ); return renderSettingsSegmented({ value: selectedIndex < 0 ? "" : String(selectedIndex), options: params.options.map((option, index) => ({ value: String(index), label: formatUnknownText(option), })), disabled: params.disabled, ariaLabel: params.ariaLabel, onChange: (index) => { const option = params.options[Number(index)]; if (option !== undefined) { params.onSelect(option); } }, }); } export function renderJsonTextareaControl(params: { path: Array; fallback: string; rows: number; sensitiveState: SensitiveRenderState; disabled: boolean; onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { const { path, fallback, sensitiveState, disabled, onPatch } = params; const textareaControl = html` `; return wrapSensitiveControl( textareaControl, renderSensitiveToggleButton({ path, state: sensitiveState, disabled, onToggleSensitivePath: params.onToggleSensitivePath, }), ); }