diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 9217f10..e290f67 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -23,6 +23,12 @@ import { ConditionalRequiredField, extractSchemaRequirements, SchemaRequirements import { evaluateUiConditionRule, flattenPrimitiveValues, formatNodeTitle, getOutputPillClass, getOutputsTitle, getValueByPath, isConditionalByPorts, isHumanInteractiveNode, orderedSchemaPropertyEntries, parentGroupLabel, parentPath, pathToLabel, readUiConditionRule, resolveNodeIcon, resolveSchemaPath, resolveSchemaRef, schemaFieldDescription, schemaFieldLabel, shouldSkipSchemaField, splitTemplatedTextParts, toStringOrNull, type UiConditionRule, validateUniqueByConstraint, valueToDisplayString } from '../node-utility'; import { buildSchemaEditableFieldDefinitions, + buildSchemaObjectDialog, + buildSchemaOptionalGroupFieldDefinitions, + countSetSchemaGroupValues, + parseSchemaObjectDialogResult, + type SchemaObjectDialogHooks, + type SchemaOptionalGroupFieldDefinition, buildSchemaFieldViewModel, buildSchemaRetrieverContext, deleteSchemaValueByPath, @@ -63,18 +69,7 @@ type EditableFieldDefinition = SchemaEditableFieldDefinition; type EditableFieldView = SchemaParameterFieldView; -/** A nested object of optional settings, rendered as one control that opens a dialog. */ -type OptionalGroupFieldDefinition = { - path: string; - label: string; - objectSchema: Record | null; - ui: { - structural: boolean; - visibleWhen: UiConditionRule[]; - enabledWhen: UiConditionRule[]; - group: string | null; - }; -}; +type OptionalGroupFieldDefinition = SchemaOptionalGroupFieldDefinition; type ArrayFieldDefinition = { path: string; @@ -785,7 +780,7 @@ export class GenericNodeComponent implements OnDestroy { this.schemaRequirements = extractSchemaRequirements(this.blockSchema); this.editableFieldDefinitions = this.buildEditableFieldDefinitions(this.blockSchema); this.arrayFieldDefinitions = this.buildArrayFieldDefinitions(this.blockSchema); - this.optionalGroupFieldDefinitions = this.buildOptionalGroupFieldDefinitions(this.blockSchema); + this.optionalGroupFieldDefinitions = buildSchemaOptionalGroupFieldDefinitions(this.blockSchema); this.pruneInactiveConfiguration(this.ensureBlockConfiguration()); await this.refreshConditionalRequirements(); @@ -892,47 +887,15 @@ export class GenericNodeComponent implements OnDestroy { return this.optionalGroupFieldDefinitions .filter((definition) => this.isPathVisible(definition.path)) .map((definition) => { - const value = this.getByPath(config, definition.path); - const values = value && typeof value === 'object' && !Array.isArray(value) - ? Object.values(value as Record) - : []; return { path: definition.path, label: definition.label, - // A temperature of 0 counts: it is the repeatable setting, not an empty box. - setCount: values.filter((entry) => entry !== undefined && entry !== null && entry !== '').length, + setCount: countSetSchemaGroupValues(this.getByPath(config, definition.path)), enabled: this.isPathEnabled(definition.path) }; }); } - private buildOptionalGroupFieldDefinitions(schema: Record | null): OptionalGroupFieldDefinition[] { - return collectSchemaLeafFields(schema, ({ path, schema: childResolved, ui }) => { - if (childResolved?.['x-ui-optional-group'] !== true) return null; - - // The label comes from the annotation when given, so a group can be named for what it is - // rather than for the field that happens to hold it. - const label = typeof childResolved?.['x-ui-optional-group-label'] === 'string' - && String(childResolved['x-ui-optional-group-label']).trim().length - ? String(childResolved['x-ui-optional-group-label']).trim() - : schemaFieldLabel(path, childResolved); - - return { - path, - label, - objectSchema: childResolved, - ui: { - structural: ui.structural, - visibleWhen: ui.visibleWhen, - enabledWhen: ui.enabledWhen, - group: ui.group - } - }; - }, { - includeOptionalGroups: true - }); - } - private isStructuralField(path: string): boolean { return getSchemaPathUiMeta(this.blockSchema, path).structural; } @@ -1421,179 +1384,33 @@ export class GenericNodeComponent implements OnDestroy { this.maybeCreateBlockOnServer(); } - /** Builds a dialog from an object's schema. Serves an array element and an optional group alike. */ - private async buildObjectDialog( - itemSchema: Record | null, - title: string, - item: Record - ): Promise { - const properties = itemSchema?.['properties'] as Record | undefined; - const schemaRoot = this.blockSchema ?? itemSchema ?? {}; - - if (!properties) { - return { - title, - fields: [ - { - key: '__raw', - label: 'Item JSON', - type: 'textarea' as const, - rows: 10 - } - ], - initial: { - __raw: JSON.stringify(item ?? {}, null, 2) - } - }; - } - - const fields: NodeSettingField[] = []; - const initial: Record = {}; - - for (const { key, schema: propertySchema } of orderedSchemaPropertyEntries(itemSchema, schemaRoot)) { - if (!propertySchema) continue; - if (shouldSkipSchemaField(key, propertySchema)) continue; - - const fieldUi = this.toFieldUiMeta(propertySchema); - const visible = fieldUi.visibleWhen.every((rule) => - evaluateUiConditionRule(rule, item, (fieldPath) => resolveSchemaPath(itemSchema, fieldPath)) - ); - if (!visible) continue; - - if (this.hasDynamicSchema(propertySchema)) { - const dynamicFields = await this.buildDynamicSchemaFields(key, propertySchema, item); - fields.push(...dynamicFields.fields); - Object.assign(initial, dynamicFields.initial); - continue; - } - - const label = schemaFieldLabel(key, propertySchema); - const currentValue = item[key]; - const options = await this.loadNodeSettingOptions(propertySchema, item, ''); - const isObjectLike = propertySchema?.['type'] === 'object'; - const fieldType = - options ? 'select' : - propertySchema?.['type'] === 'boolean' ? 'checkbox' : - propertySchema?.['x-ui-widget'] === 'textarea' || isObjectLike ? 'textarea' : - 'text'; - - fields.push({ - key, - label, - type: fieldType, - rows: fieldType === 'textarea' ? 8 : undefined, - options, - placeholder: typeof propertySchema?.['x-ui-placeholder'] === 'string' ? String(propertySchema['x-ui-placeholder']) : undefined, - tip: schemaFieldDescription(propertySchema) ?? undefined, - readonly: !fieldUi.enabledWhen.every((rule) => - evaluateUiConditionRule(rule, item, (fieldPath) => resolveSchemaPath(itemSchema, fieldPath)) - ) - }); - - if (fieldType === 'checkbox') { - initial[key] = currentValue === true; - } else if (fieldType === 'textarea' && isObjectLike) { - initial[key] = JSON.stringify(currentValue ?? {}, null, 2); - } else { - initial[key] = currentValue == null ? '' : String(currentValue); - } - } - + /** The parts of the object round trip that only this component can supply. */ + private objectDialogHooks(objectSchema: Record | null): SchemaObjectDialogHooks { return { - title, - fields, - initial, - // Re-derives itself from the draft, which is how conditional visibility and dependent - // retrievers re-resolve while the dialog is open. - onValuesChange: async (draft) => { - const draftItem = this.parseObjectDialogResult(itemSchema, draft, item); - const nextDialog = await this.buildObjectDialog(itemSchema, title, draftItem); - if (!nextDialog) return null; - return { - fields: nextDialog.fields, - initial: nextDialog.initial - }; + schemaRoot: this.blockSchema ?? objectSchema ?? {}, + loadOptions: (propertySchema, draft) => this.loadNodeSettingOptions(propertySchema, draft, ''), + dynamic: { + isDynamic: (propertySchema) => this.hasDynamicSchema(propertySchema), + buildFields: (key, propertySchema, draft) => this.buildDynamicSchemaFields(key, propertySchema, draft), + readValue: (result, key) => this.extractNestedDialogValues(result, key) } }; } - /** - * Reads a dialog result back into an object, using the object's own schema. - * - * Takes a schema rather than an ArrayFieldDefinition because the same round trip serves an array - * element and an optional group; array-ness only ever showed up in the parameter type. - */ + private buildObjectDialog( + objectSchema: Record | null, + title: string, + value: Record + ): Promise { + return buildSchemaObjectDialog(objectSchema, title, value, this.objectDialogHooks(objectSchema)); + } + private parseObjectDialogResult( - itemSchema: Record | null, + objectSchema: Record | null, result: NodeSettingsValues, - previousItem: Record + previousValue: Record ) { - const properties = itemSchema?.['properties'] as Record | undefined; - const schemaRoot = this.blockSchema ?? itemSchema ?? {}; - const requiredKeys = new Set(Array.isArray(itemSchema?.['required']) ? itemSchema['required'] as string[] : []); - if (!properties) { - try { - return JSON.parse(String(result['__raw'] ?? '{}')) as Record; - } catch { - return previousItem; - } - } - - const nextItem: Record = {}; - - for (const { key, schema: propertySchema } of orderedSchemaPropertyEntries(itemSchema, schemaRoot)) { - if (!propertySchema) continue; - if (shouldSkipSchemaField(key, propertySchema)) continue; - - const fieldUi = this.toFieldUiMeta(propertySchema); - const visible = fieldUi.visibleWhen.every((rule) => - evaluateUiConditionRule(rule, result, (fieldPath) => resolveSchemaPath(itemSchema, fieldPath)) - ); - if (!visible) continue; - - if (this.hasDynamicSchema(propertySchema)) { - const dynamicValue = this.extractNestedDialogValues(result, key); - nextItem[key] = Object.keys(dynamicValue).length ? dynamicValue : (previousItem[key] ?? {}); - continue; - } - - const rawValue = result[key]; - const isObjectLike = propertySchema?.['type'] === 'object'; - - if (propertySchema?.['type'] === 'boolean') { - nextItem[key] = rawValue === true; - continue; - } - - if (isObjectLike) { - try { - nextItem[key] = JSON.parse(String(rawValue ?? '{}')); - } catch { - nextItem[key] = previousItem[key] ?? {}; - } - continue; - } - - if (propertySchema?.['type'] === 'number' || propertySchema?.['type'] === 'integer') { - const text = String(rawValue ?? '').trim(); - const numeric = Number(text); - if (!text.length || !Number.isFinite(numeric)) { - // Cleared. For an optional field that means "unset", and the key is left out entirely - - // writing 0 would make the provider default unreachable on the very setting, like a - // temperature, where 0 is itself a meaningful value. A required field keeps the old - // behaviour: there is no "absent" for something the schema insists on. - if (!requiredKeys.has(key)) continue; - nextItem[key] = 0; - continue; - } - nextItem[key] = propertySchema['type'] === 'integer' ? Math.trunc(numeric) : numeric; - continue; - } - - nextItem[key] = String(rawValue ?? ''); - } - - return nextItem; + return parseSchemaObjectDialogResult(objectSchema, result, previousValue, this.objectDialogHooks(objectSchema)); } private createEmptyArrayItem(itemSchema: Record | null) { diff --git a/src/app/shared/nodes/schema-driven-fields.ts b/src/app/shared/nodes/schema-driven-fields.ts index 45c58c4..08fc89a 100644 --- a/src/app/shared/nodes/schema-driven-fields.ts +++ b/src/app/shared/nodes/schema-driven-fields.ts @@ -1,4 +1,6 @@ -import { evaluateUiConditionRule, getValueByPath, orderedSchemaPropertyEntries, parentGroupLabel, readEffectiveUiVisibleConditionRule, readUiConditionRule, readUiGroup, readUiLabel, resolveSchemaPath, resolveSchemaRef, schemaFieldDescription, schemaFieldLabel, type UiConditionRule } from './node-utility'; +import type { NodeSettingField, NodeSettingOption, NodeSettingsDialogInput, NodeSettingsValues } from '@services/dialogs/node-settings-dialog'; + +import { evaluateUiConditionRule, getValueByPath, orderedSchemaPropertyEntries, parentGroupLabel, readEffectiveUiVisibleConditionRule, readUiConditionRule, readUiGroup, readUiLabel, resolveSchemaPath, resolveSchemaRef, schemaFieldDescription, schemaFieldLabel, shouldSkipSchemaField, type UiConditionRule } from './node-utility'; export type SchemaFieldType = 'string' | 'number' | 'integer' | 'boolean' | 'unknown'; @@ -789,3 +791,245 @@ export function buildSchemaFieldViewModel< parameterFieldGroups: grouped.groups }; } + +/** A nested object of optional settings, rendered as one control that opens a dialog. */ +export type SchemaOptionalGroupFieldDefinition = { + path: string; + label: string; + objectSchema: Record | null; + ui: { + structural: boolean; + visibleWhen: UiConditionRule[]; + enabledWhen: UiConditionRule[]; + group: string | null; + }; +}; + +export function buildSchemaOptionalGroupFieldDefinitions( + root: Record | null | undefined +): SchemaOptionalGroupFieldDefinition[] { + return collectSchemaLeafFields(root, ({ path, schema, ui }) => { + if (schema?.['x-ui-optional-group'] !== true) return null; + + // The label comes from the annotation when given, so a group can be named for what it is + // rather than for the field that happens to hold it. + const annotated = schema?.['x-ui-optional-group-label']; + const label = typeof annotated === 'string' && annotated.trim().length + ? annotated.trim() + : schemaFieldLabel(path, schema); + + return { + path, + label, + objectSchema: schema, + ui: { + structural: ui.structural, + visibleWhen: ui.visibleWhen, + enabledWhen: ui.enabledWhen, + group: ui.group + } + }; + }, { includeOptionalGroups: true }); +} + +/** + * How many of a group's settings are set. A collapsed control must never hide a value, so this is + * what the button reports - and 0 counts, because a temperature of 0 is the repeatable setting and + * not an absence. + */ +export function countSetSchemaGroupValues(value: unknown): number { + if (!value || typeof value !== 'object' || Array.isArray(value)) return 0; + return Object.values(value as Record) + .filter((entry) => entry !== undefined && entry !== null && entry !== '') + .length; +} + +/** + * What a component lends to the shared object round trip below. + * + * The two node components resolve select options differently, and only the block editor knows how + * to expand a dynamic sub-schema, so those stay callbacks; everything else - ordering, conditional + * visibility, types, and the cleared-value semantics - is the same in both and lives here. + */ +export type SchemaObjectDialogHooks = { + /** The schema the object belongs to, used to resolve $refs while ordering properties. */ + schemaRoot: Record | null; + loadOptions: ( + propertySchema: Record, + draft: Record + ) => Promise | NodeSettingOption[] | undefined; + /** Optional: a property whose schema is fetched at runtime rather than declared inline. */ + dynamic?: { + isDynamic: (propertySchema: Record | null | undefined) => boolean; + buildFields: ( + key: string, + propertySchema: Record, + draft: Record + ) => Promise<{ fields: NodeSettingField[]; initial: Record }>; + readValue: (result: NodeSettingsValues, key: string) => Record; + }; +}; + +/** Builds a dialog from an object's schema. Serves an array element and an optional group alike. */ +export async function buildSchemaObjectDialog( + objectSchema: Record | null, + title: string, + value: Record, + hooks: SchemaObjectDialogHooks +): Promise { + const properties = objectSchema?.['properties'] as Record | undefined; + const schemaRoot = hooks.schemaRoot ?? objectSchema ?? {}; + + if (!properties) { + return { + title, + fields: [{ key: '__raw', label: 'Item JSON', type: 'textarea' as const, rows: 10 }], + initial: { __raw: JSON.stringify(value ?? {}, null, 2) } + }; + } + + const fields: NodeSettingField[] = []; + const initial: Record = {}; + + for (const { key, schema: propertySchema } of orderedSchemaPropertyEntries(objectSchema, schemaRoot)) { + if (!propertySchema) continue; + if (shouldSkipSchemaField(key, propertySchema)) continue; + + const fieldUi = toSchemaFieldUiMeta(propertySchema); + const visible = fieldUi.visibleWhen.every((rule) => + evaluateUiConditionRule(rule, value, (fieldPath) => resolveSchemaPath(objectSchema, fieldPath)) + ); + if (!visible) continue; + + if (hooks.dynamic?.isDynamic(propertySchema)) { + const dynamicFields = await hooks.dynamic.buildFields(key, propertySchema, value); + fields.push(...dynamicFields.fields); + Object.assign(initial, dynamicFields.initial); + continue; + } + + const currentValue = value[key]; + const options = await hooks.loadOptions(propertySchema, value); + const isObjectLike = propertySchema?.['type'] === 'object'; + const fieldType = + options ? 'select' : + propertySchema?.['type'] === 'boolean' ? 'checkbox' : + propertySchema?.['x-ui-widget'] === 'textarea' || isObjectLike ? 'textarea' : + 'text'; + + fields.push({ + key, + label: schemaFieldLabel(key, propertySchema), + type: fieldType, + rows: fieldType === 'textarea' ? 8 : undefined, + options, + placeholder: typeof propertySchema?.['x-ui-placeholder'] === 'string' ? String(propertySchema['x-ui-placeholder']) : undefined, + tip: schemaFieldDescription(propertySchema) ?? undefined, + readonly: !fieldUi.enabledWhen.every((rule) => + evaluateUiConditionRule(rule, value, (fieldPath) => resolveSchemaPath(objectSchema, fieldPath)) + ) + }); + + if (fieldType === 'checkbox') { + initial[key] = currentValue === true; + } else if (fieldType === 'textarea' && isObjectLike) { + initial[key] = JSON.stringify(currentValue ?? {}, null, 2); + } else { + initial[key] = currentValue == null ? '' : String(currentValue); + } + } + + return { + title, + fields, + initial, + // Re-derives itself from the draft, which is how conditional visibility and dependent + // retrievers re-resolve while the dialog is open. + onValuesChange: async (draft) => { + const draftValue = parseSchemaObjectDialogResult(objectSchema, draft, value, hooks); + const nextDialog = await buildSchemaObjectDialog(objectSchema, title, draftValue, hooks); + return { fields: nextDialog.fields, initial: nextDialog.initial }; + } + }; +} + +/** + * Reads a dialog result back into an object, using the object's own schema. + * + * Takes a schema rather than a field definition because the same round trip serves an array element + * and an optional group; array-ness only ever showed up in the parameter type. + */ +export function parseSchemaObjectDialogResult( + objectSchema: Record | null, + result: NodeSettingsValues, + previousValue: Record, + hooks: SchemaObjectDialogHooks +): Record { + const properties = objectSchema?.['properties'] as Record | undefined; + const schemaRoot = hooks.schemaRoot ?? objectSchema ?? {}; + const requiredKeys = new Set(Array.isArray(objectSchema?.['required']) ? objectSchema['required'] as string[] : []); + + if (!properties) { + try { + return JSON.parse(String(result['__raw'] ?? '{}')) as Record; + } catch { + return previousValue; + } + } + + const next: Record = {}; + + for (const { key, schema: propertySchema } of orderedSchemaPropertyEntries(objectSchema, schemaRoot)) { + if (!propertySchema) continue; + if (shouldSkipSchemaField(key, propertySchema)) continue; + + const fieldUi = toSchemaFieldUiMeta(propertySchema); + const visible = fieldUi.visibleWhen.every((rule) => + evaluateUiConditionRule(rule, result, (fieldPath) => resolveSchemaPath(objectSchema, fieldPath)) + ); + if (!visible) continue; + + if (hooks.dynamic?.isDynamic(propertySchema)) { + const dynamicValue = hooks.dynamic.readValue(result, key); + next[key] = Object.keys(dynamicValue).length ? dynamicValue : (previousValue[key] ?? {}); + continue; + } + + const rawValue = result[key]; + const isObjectLike = propertySchema?.['type'] === 'object'; + + if (propertySchema?.['type'] === 'boolean') { + next[key] = rawValue === true; + continue; + } + + if (isObjectLike) { + try { + next[key] = JSON.parse(String(rawValue ?? '{}')); + } catch { + next[key] = previousValue[key] ?? {}; + } + continue; + } + + if (propertySchema?.['type'] === 'number' || propertySchema?.['type'] === 'integer') { + const text = String(rawValue ?? '').trim(); + const numeric = Number(text); + if (!text.length || !Number.isFinite(numeric)) { + // Cleared. For an optional field that means "unset", and the key is left out entirely - + // writing 0 would make the provider default unreachable on the very setting, like a + // temperature, where 0 is itself a meaningful value. A required field keeps the old + // behaviour: there is no "absent" for something the schema insists on. + if (!requiredKeys.has(key)) continue; + next[key] = 0; + continue; + } + next[key] = propertySchema['type'] === 'integer' ? Math.trunc(numeric) : numeric; + continue; + } + + next[key] = String(rawValue ?? ''); + } + + return next; +}