// Control UI renderers for structured config form nodes. import { html, nothing, type TemplateResult } from "lit"; import { icons } from "../components/icons.ts"; import { t } from "../i18n/index.ts"; import { containsRedactedSentinel, removePathValue, setPathValue, } from "../lib/config-form-utils.ts"; import { arrayAddCandidates } from "./config-form-array-candidates.ts"; import { appendArrayRowIdentities, discardArrayRowIdentities, preserveArrayRowIdentities, rowIdentitiesForArray, } from "./config-form-array-identity.ts"; import { ConfigFormCollectionDraft, type ConfigFormCollectionDraftCommit, type ConfigFormCollectionDraftProps, } from "./config-form-collection-draft.ts"; import { copyWithPathPatch } from "./config-form-copy-on-write.ts"; import { arrayItemSchema } from "./config-form.array-items.ts"; import { arrayInputConstraints, canApplyArrayCandidate, canApplyObjectCandidate, configValuesEqual, defaultValue, isSupportedConfigValueValid, NO_SAFE_DEFAULT, objectAdditionalPropertiesSchema, objectPropertyKeys, objectPropertySchema, requiredPropertyKeys, } from "./config-form.constraints.ts"; import { getSensitiveRenderState, isAnySchema, jsonValue, renderCollectionDefaultPresentation, renderFlatDefaultRow, renderFieldRow, renderJsonTextareaControl, renderTags, schemaWithDefault, type ConfigNodeRenderer, type ConfigNodeRenderParams, } from "./config-form.node.shared.ts"; import { hasConfigSearchCriteria as hasSearchCriteria, matchesNodeSearch, matchesNodeSelf, resolveConfigFieldMeta as resolveFieldMeta, } from "./config-form.search.ts"; import { configFieldId, hintForPath, type JsonSchema } from "./config-form.shared.ts"; import { renderSettingsEmpty } from "./settings-ui.ts"; const UNSET_ARRAY_SOURCE_IDENTITY = Symbol("unset-array-source"); const UNSET_MAP_SOURCE_IDENTITY = Symbol("unset-map-source"); function openCollectionDraft(event: Event, draftId: string): void { const block = (event.currentTarget as HTMLElement).closest(".cfg-block"); const draft = Array.from(block?.children ?? []).find((child) => child.id === draftId); (draft as Partial | undefined)?.openDraft?.call(draft); } export function renderObject( params: ConfigNodeRenderParams, renderNode: ConfigNodeRenderer, ): TemplateResult { const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria, rawAvailable, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, onRemove, } = params; const { label, help, tags } = resolveFieldMeta(path, schema, hints); const selfMatched = searchCriteria && hasSearchCriteria(searchCriteria) ? matchesNodeSelf({ schema, path, hints, criteria: searchCriteria }) : false; const childSearchCriteria = selfMatched ? undefined : searchCriteria; const inherited = value === undefined && schema.default !== undefined; const fallback = inherited ? schema.default : value; const objectSourceIdentity = fallback === undefined ? UNSET_MAP_SOURCE_IDENTITY : fallback; const objectValue = fallback && typeof fallback === "object" && !Array.isArray(fallback) ? (fallback as Record) : {}; const defaultPresentation = renderCollectionDefaultPresentation(params, fallback); const entries = objectPropertyKeys(schema) .map((key) => [key, objectPropertySchema(schema, key)] as const) .filter((entry): entry is readonly [string, ConfigNodeRenderParams["schema"]] => Boolean(entry[1]), ); const requiredKeys = requiredPropertyKeys(schema); // Sort by hint order const sorted = entries.toSorted((left, right) => { const leftOrder = hintForPath([...path, left[0]], hints)?.order ?? 0; const rightOrder = hintForPath([...path, right[0]], hints)?.order ?? 0; if (leftOrder !== rightOrder) { return leftOrder - rightOrder; } return left[0].localeCompare(right[0]); }); const reservedKeys = new Set(entries.map(([key]) => key)); const additionalProperties = objectAdditionalPropertiesSchema(schema); const allowExtra = Boolean(additionalProperties) && typeof additionalProperties === "object"; const patchObjectChild = (childPath: Array, childValue: unknown) => { if ( childPath.length < path.length || !path.every((segment, index) => segment === childPath[index]) ) { return false; } let candidate: Record; const relativePath = childPath.slice(path.length); if (relativePath.length === 0) { if (!childValue || typeof childValue !== "object" || Array.isArray(childValue)) { return false; } candidate = childValue as Record; } else { try { candidate = structuredClone(objectValue); } catch { return false; } if (childValue === undefined) { removePathValue(candidate, relativePath); } else { setPathValue(candidate, relativePath, childValue); } } if (!canApplyObjectCandidate(schema, objectValue, candidate)) { return false; } if (inherited) { return onPatch(path, candidate) !== false; } const accepted = childValue === undefined && onRemove ? onRemove(childPath) : onPatch(childPath, childValue); return accepted !== false; }; const fields = html` ${sorted.map(([propertyKey, node]) => { const hasInheritedChild = inherited && Object.hasOwn(objectValue, propertyKey); return renderNode({ schema: hasInheritedChild ? schemaWithDefault(node, objectValue[propertyKey]) : node, value: inherited ? undefined : objectValue[propertyKey], path: [...path, propertyKey], hints, rawAvailable, unsupported, disabled, isRequired: requiredKeys.has(propertyKey), sourceIdentity: inherited ? undefined : objectValue[propertyKey], controlIdentity: params.controlIdentity ?? objectValue, rowIdentity: params.rowIdentity, searchCriteria: childSearchCriteria, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, onPatch: patchObjectChild, }); })} ${allowExtra ? renderMapField( { ...params, schema: additionalProperties, value: objectValue, sourceIdentity: objectSourceIdentity, reservedKeys, searchCriteria: childSearchCriteria, onPatch: patchObjectChild, }, renderNode, ) : nothing} `; // Top-level objects and label-less contexts emit rows directly into the // surrounding settings-group so row dividers stay sibling-driven. if (path.length === 1 || params.showLabel === false) { return html`${path.length === 1 ? renderFlatDefaultRow(defaultPresentation) : nothing}${fields}`; } // Nested objects get collapsible treatment as an indented sub-block. return html`
${label} ${help ? html`${help}` : nothing} ${schema.default !== undefined ? html`${defaultPresentation.description}` : nothing} ${renderTags(tags)}
${defaultPresentation.action} ${icons.chevronDown}
${fields}
`; } export function renderArray( params: ConfigNodeRenderParams, renderNode: ConfigNodeRenderer, ): TemplateResult { const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria, rawAvailable, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, } = params; const showLabel = params.showLabel ?? true; const showHeaderMeta = params.showHeaderMeta ?? showLabel; const { label, help, tags } = resolveFieldMeta(path, schema, hints); const selfMatched = searchCriteria && hasSearchCriteria(searchCriteria) ? matchesNodeSelf({ schema, path, hints, criteria: searchCriteria }) : false; const childSearchCriteria = selfMatched ? undefined : searchCriteria; const tupleItems = Array.isArray(schema.items) ? schema.items : undefined; const itemsSchema = Array.isArray(schema.items) ? (schema.items[0] ?? {}) : schema.items; if (!itemsSchema) { return renderFieldRow({ label, tags: [], showLabel: true, control: nothing, error: t("configForm.unsupportedArray"), }); } const inherited = value === undefined && Array.isArray(schema.default); const arrayValue = Array.isArray(value) ? value : Array.isArray(schema.default) ? schema.default : []; const arraySourceIdentity = Array.isArray(value) ? value : Array.isArray(schema.default) ? schema.default : UNSET_ARRAY_SOURCE_IDENTITY; const defaultPresentation = renderCollectionDefaultPresentation(params, arrayValue); const rowIdentities = rowIdentitiesForArray(arrayValue); const { minItems: minimumItems, maxItems: maximumItems, uniqueItems, } = arrayInputConstraints(schema); const itemSchemaAt = (index: number): JsonSchema => arrayItemSchema(schema, index) ?? (tupleItems ? {} : itemsSchema); const { atomicCandidate, autoCandidate } = arrayAddCandidates({ schema, value: arrayValue, minimumItems, maximumItems, uniqueItems, isUnset: value === undefined, isRequired: params.isRequired ?? false, itemSchemaAt, }); const canAppend = maximumItems === undefined || arrayValue.length < maximumItems; const requiresDraft = atomicCandidate === undefined && autoCandidate === undefined; const nextItemSchema = itemSchemaAt(arrayValue.length); const draftId = configFieldId(path, "array-draft"); const draftProps: ConfigFormCollectionDraftProps = { schema: nextItemSchema, label, disabled: disabled || !canAppend, identity: draftId, sourceIdentity: arraySourceIdentity, existingValues: uniqueItems ? arrayValue : undefined, validateValue: (candidate) => { const nextValue = [...arrayValue, candidate]; return ( (maximumItems === undefined || nextValue.length <= maximumItems) && (nextValue.length < minimumItems || isSupportedConfigValueValid(schema, nextValue)) ); }, }; const patchArrayItem = (childPath: Array, childValue: unknown) => { if ( childPath.length <= path.length || !path.every((segment, index) => segment === childPath[index]) ) { return false; } const relativePath = childPath.slice(path.length); const itemIndex = relativePath[0]; if (typeof itemIndex !== "number" || itemIndex < 0 || itemIndex >= arrayValue.length) { return false; } const nextValue = [...arrayValue]; const itemPath = relativePath.slice(1); if (itemPath.length === 0) { if (childValue === undefined) { return false; } nextValue[itemIndex] = childValue; } else { const nextItem = copyWithPathPatch(arrayValue[itemIndex], itemPath, childValue); if (!nextItem.ok) { return false; } nextValue[itemIndex] = nextItem.value; } if (canApplyArrayCandidate(schema, arrayValue, nextValue, uniqueItems, true)) { preserveArrayRowIdentities(nextValue, rowIdentities); const accepted = onPatch(path, nextValue) !== false; if (!accepted) { discardArrayRowIdentities(nextValue); } return accepted; } return false; }; return html`
${showLabel ? html`${label}` : nothing} ${showHeaderMeta && help ? html`${help}` : nothing} ${showHeaderMeta && schema.default !== undefined ? html`${defaultPresentation.description}` : nothing} ${renderTags(tags)}
${t(arrayValue.length === 1 ? "configForm.itemCountOne" : "configForm.itemCount", { count: String(arrayValue.length), })} ${defaultPresentation.action}
) => { const nextValue = [...arrayValue, event.detail.value]; const canApply = !( uniqueItems && arrayValue.some((item) => configValuesEqual(item, event.detail.value)) ) && (maximumItems === undefined || arrayValue.length < maximumItems) && isSupportedConfigValueValid(nextItemSchema, event.detail.value) && (nextValue.length < minimumItems || isSupportedConfigValueValid(schema, nextValue)); let accepted = false; if (canApply) { appendArrayRowIdentities(nextValue, rowIdentities, 1); accepted = onPatch(path, nextValue) !== false; if (!accepted) { discardArrayRowIdentities(nextValue); } } if (!accepted) { event.preventDefault(); } }} > ${arrayValue.length === 0 ? renderSettingsEmpty(t("configForm.noItems")) : html`
${arrayValue.map((item, index) => { const itemSchema = itemSchemaAt(index); return html`
#${index + 1}
${renderNode({ schema: inherited ? schemaWithDefault(itemSchema, item) : itemSchema, value: inherited ? undefined : item, path: [...path, index], hints, rawAvailable, unsupported, disabled, isRequired: true, sourceIdentity: inherited ? undefined : item, controlIdentity: arrayValue, rowIdentity: rowIdentities[index], searchCriteria: childSearchCriteria, showLabel: false, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, // Inherited rows stay visually unset, but edits materialize the // complete effective array through patchArrayItem at the parent path. onPatch: patchArrayItem, })} `; })}
`}
`; } function renderMapField( params: ConfigNodeRenderParams & { value: Record; reservedKeys: Set; }, renderNode: ConfigNodeRenderer, ): TemplateResult { const { schema, value, path, hints, rawAvailable, unsupported, disabled, reservedKeys, onPatch, searchCriteria, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, } = params; const anySchema = isAnySchema(schema); const entryDefault = anySchema ? {} : defaultValue(schema); const draftId = configFieldId(path, "map-draft"); const draftProps: ConfigFormCollectionDraftProps = { schema, label: t("configForm.customEntries"), disabled, identity: draftId, sourceIdentity: params.sourceIdentity ?? value, existingKeys: [...new Set([...Object.keys(value), ...reservedKeys])], }; const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key)); const visibleEntries = searchCriteria && hasSearchCriteria(searchCriteria) ? entries.filter(([key, entryValue]) => matchesNodeSearch({ schema, value: entryValue, path: [...path, key], hints, criteria: searchCriteria, }), ) : entries; return html`
${t("configForm.customEntries")}
) => { const key = event.detail.key; if ( !key || Object.hasOwn(value, key) || reservedKeys.has(key) || onPatch(path, { ...value, [key]: event.detail.value }) === false ) { event.preventDefault(); } }} > ${visibleEntries.length === 0 ? renderSettingsEmpty(t("configForm.noCustomEntries")) : html`
${visibleEntries.map(([key, entryValue]) => { const valuePath = [...path, key]; const fallback = jsonValue(entryValue); const sensitiveState = getSensitiveRenderState({ path: valuePath, value: entryValue, hints, revealSensitive: revealSensitive ?? false, isSensitivePathRevealed, }); return html`
{ const target = event.target as HTMLInputElement; const nextKey = target.value.trim(); if (!nextKey || nextKey === key) { target.value = key; return; } const nextValue = { ...value }; // Renaming a key that still holds server-redacted secrets would // submit the sentinel under a new key: the gateway fails closed // (dead-end draft), and a delete+rename fold in one autosave // window silently binds the deleted entry's old credential. if (nextKey in nextValue || containsRedactedSentinel(nextValue[key])) { target.value = key; if (!(nextKey in nextValue)) { target.setCustomValidity(t("configForm.renameRedactedBlocked")); target.reportValidity(); target.setCustomValidity(""); } return; } nextValue[nextKey] = nextValue[key]; delete nextValue[key]; if (onPatch(path, nextValue) === false) { target.value = key; } }} />
${anySchema ? renderFieldRow({ label: key, tags: [], showLabel: false, stacked: true, control: renderJsonTextareaControl({ schema, path: valuePath, ariaLabel: `${key}: ${t("configForm.jsonValue")}`, sourceValue: entryValue, rowIdentity: params.rowIdentity, fallback, rows: 2, sensitiveState, disabled, isRequired: true, onToggleSensitivePath, onPatch, }), }) : renderNode({ schema, value: entryValue, path: valuePath, hints, rawAvailable, unsupported, disabled, isRequired: true, sourceIdentity: entryValue, controlIdentity: value, rowIdentity: params.rowIdentity, searchCriteria, showLabel: false, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, onPatch, })} `; })}
`}
`; }