// Control UI helpers shared by config form node renderers. import { html, nothing, type TemplateResult } from "lit"; import { ref } from "lit/directives/ref.js"; 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 { isSupportedConfigValueValid } from "./config-form.constraints.ts"; import type { ConfigSearchCriteria } from "./config-form.search.ts"; import { configFieldId, hasSensitiveConfigData, redactedPlaceholder, type JsonSchema, } from "./config-form.shared.ts"; import { renderSettingsSegmented } from "./settings-ui.ts"; const META_KEYS = new Set([ "title", "description", "default", "nullable", "enumIncludesNull", "tags", "x-tags", ]); const jsonTextareaState = new WeakMap< HTMLTextAreaElement, { sourceValue: unknown; rowIdentity: unknown; fallback: string; pathKey: string } >(); export type ConfigNodeRenderParams = { schema: JsonSchema; value: unknown; path: Array; hints: ConfigUiHints; rawAvailable?: boolean; unsupported: Set; disabled: boolean; isRequired?: boolean; sourceIdentity?: unknown; controlIdentity?: unknown; rowIdentity?: unknown; structuredDraftOwner?: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; revealSensitive?: boolean; isSensitivePathRevealed?: (path: Array) => boolean; onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => boolean | 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; helpId?: string; 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: { schema: JsonSchema; path: Array; ariaLabel: string; descriptionId?: string; sourceValue: unknown; rowIdentity?: unknown; fallback: string; rows: number; sensitiveState: SensitiveRenderState; disabled: boolean; isRequired?: boolean; onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => boolean | void; }): TemplateResult { const { path, fallback, sensitiveState, disabled, onPatch } = params; const errorId = configFieldId(path, "json-error"); const describedBy = [params.descriptionId, errorId].filter(Boolean).join(" "); const setValidity = (target: HTMLTextAreaElement, message: string) => { const error = target .closest(".cfg-json-editor") ?.querySelector(".cfg-field__error"); target.setCustomValidity(message); target.setAttribute("aria-invalid", String(Boolean(message))); if (error) { error.hidden = !message; error.textContent = message; } }; const updateValidity = (target: HTMLTextAreaElement) => { let message = ""; const raw = target.value.trim(); if (!raw && params.isRequired) { message = t("configForm.invalidJson"); } else if (raw) { try { if (!isSupportedConfigValueValid(params.schema, JSON.parse(raw))) { message = t("configForm.invalidJson"); } } catch { message = t("configForm.invalidJson"); } } setValidity(target, message); return !message; }; const renderedFallback = sensitiveState.isRedacted ? "" : fallback; const pathKey = JSON.stringify(path); const commitJsonValue = (target: HTMLTextAreaElement, candidate: unknown) => { if (onPatch(path, candidate) !== false) { return true; } target.value = renderedFallback; updateValidity(target); return false; }; const textareaControl = html` `; return html` ${wrapSensitiveControl( textareaControl, renderSensitiveToggleButton({ path, state: sensitiveState, disabled, onToggleSensitivePath: params.onToggleSensitivePath, }), )} `; }