diff --git a/src/app/models/flow.spec.ts b/src/app/models/flow.spec.ts new file mode 100644 index 0000000..f79150a --- /dev/null +++ b/src/app/models/flow.spec.ts @@ -0,0 +1,65 @@ +import { normalizeFlowValidationErrors } from './flow'; + +describe('normalizeFlowValidationErrors', () => { + it('keeps explicit related node ids when provided', () => { + expect( + normalizeFlowValidationErrors([{ + entity: 'flow', + id: 'flow-1', + message: 'Validation error', + relatedNodeIds: ['node-1', 'node-2', 'node-1'] + }]) + ).toEqual([{ + code: null, + entity: 'flow', + id: 'flow-1', + field: null, + message: 'Validation error', + relatedNodeIds: ['node-1', 'node-2'] + }]); + }); + + it('derives node ids from block and container validation errors', () => { + expect( + normalizeFlowValidationErrors([ + { entity: 'block', id: 'block-1', message: 'Broken block' }, + { entity: 'container', id: 'container-1', message: 'Broken container' } + ]) + ).toEqual([ + { + code: null, + entity: 'block', + id: 'block-1', + field: null, + message: 'Broken block', + relatedNodeIds: ['block-1'] + }, + { + code: null, + entity: 'container', + id: 'container-1', + field: null, + message: 'Broken container', + relatedNodeIds: ['container-1'] + } + ]); + }); + + it('does not highlight flow-level validation errors without node references', () => { + expect( + normalizeFlowValidationErrors([{ + entity: 'flow', + id: 'flow-1', + field: 'globalInputs', + message: 'Missing global input' + }]) + ).toEqual([{ + code: null, + entity: 'flow', + id: 'flow-1', + field: 'globalInputs', + message: 'Missing global input', + relatedNodeIds: [] + }]); + }); +}); diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index 0a0d4fc..8128db1 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -145,12 +145,29 @@ export function normalizeFlowValidationErrors(raw: unknown): FlowValidationError id: typeof item['id'] === 'string' ? item['id'] : null, field: typeof item['field'] === 'string' ? item['field'] : null, message: String(item['message'] ?? item['error'] ?? 'Validation error'), - relatedNodeIds: Array.isArray(item['relatedNodeIds']) - ? item['relatedNodeIds'].map((value) => String(value)).filter((value) => value.length > 0) - : [] + relatedNodeIds: normalizeRelatedNodeIds(item) })); } +function normalizeRelatedNodeIds(item: Record): string[] { + const explicitNodeIds = Array.isArray(item['relatedNodeIds']) + ? item['relatedNodeIds'].map((value) => String(value)).filter((value) => value.length > 0) + : []; + + if (explicitNodeIds.length > 0) { + return Array.from(new Set(explicitNodeIds)); + } + + const entity = typeof item['entity'] === 'string' ? item['entity'].toLowerCase() : ''; + const id = typeof item['id'] === 'string' ? item['id'].trim() : ''; + + if ((entity === 'block' || entity === 'container') && id.length > 0) { + return [id]; + } + + return []; +} + export type FlowBlockConfiguration = | LLMBlockConfiguration | HumanInteractiveBlockConfiguration diff --git a/src/app/shared/flow-validation-panel/flow-validation-panel.css b/src/app/shared/flow-validation-panel/flow-validation-panel.css index 6c6ef4b..62d2667 100644 --- a/src/app/shared/flow-validation-panel/flow-validation-panel.css +++ b/src/app/shared/flow-validation-panel/flow-validation-panel.css @@ -84,11 +84,29 @@ border-radius: 16px; background: rgba(255, 255, 255, 0.94); box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; +} + +.validation-item-clickable { + cursor: pointer; +} + +.validation-item-clickable:hover { + transform: translateY(-1px); + border-color: rgba(220, 38, 38, 0.42); + box-shadow: 0 14px 28px rgba(127, 29, 29, 0.12); +} + +.validation-item-clickable:focus-visible { + outline: none; + border-color: #dc2626; + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.18), 0 14px 28px rgba(127, 29, 29, 0.12); } .validation-item-head { display: flex; - align-items: center; + flex-wrap: wrap; + align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 8px; @@ -98,12 +116,15 @@ display: inline-flex; align-items: center; padding: 4px 8px; + max-width: 100%; border-radius: 999px; background: #fee2e2; color: #991b1b; font-size: 11px; font-weight: 800; letter-spacing: 0.06em; + overflow-wrap: anywhere; + white-space: normal; } .validation-message { diff --git a/src/app/shared/flow-validation-panel/flow-validation-panel.html b/src/app/shared/flow-validation-panel/flow-validation-panel.html index efcfd88..1cdf5df 100644 --- a/src/app/shared/flow-validation-panel/flow-validation-panel.html +++ b/src/app/shared/flow-validation-panel/flow-validation-panel.html @@ -22,14 +22,17 @@
@for (error of errors(); track trackByError($index, error)) { -
+
{{ error.code || 'VALIDATION_ERROR' }} - @if ((error.relatedNodeIds?.length ?? 0) > 0) { - - }

{{ error.message }}

diff --git a/src/app/shared/flow-validation-panel/flow-validation-panel.ts b/src/app/shared/flow-validation-panel/flow-validation-panel.ts index 6446b22..55e981f 100644 --- a/src/app/shared/flow-validation-panel/flow-validation-panel.ts +++ b/src/app/shared/flow-validation-panel/flow-validation-panel.ts @@ -25,7 +25,12 @@ export class FlowValidationPanel { return `${error.code ?? 'VALIDATION_ERROR'}:${error.entity ?? ''}:${error.id ?? ''}:${error.field ?? ''}:${error.message}`; } + canFocusError(error: FlowValidationError): boolean { + return (error.relatedNodeIds?.length ?? 0) > 0; + } + focusError(error: FlowValidationError) { + if (!this.canFocusError(error)) return; const nodeIds = Array.isArray(error.relatedNodeIds) ? error.relatedNodeIds : []; this.editorState.setHighlightedValidationNodes(nodeIds); } diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css index 8a02870..d4b0cc6 100644 --- a/src/app/shared/nodes/container-node/container-node.css +++ b/src/app/shared/nodes/container-node/container-node.css @@ -397,6 +397,34 @@ padding: 0 14px 14px; } +.container-node__params--grouped { + padding: 0; +} + +.container-node__param-groups { + display: flex; + flex-direction: column; + gap: 10px; + grid-column: 1 / -1; +} + +.container-node__param-fieldset { + margin: 0 14px; + padding: 12px; + border: 1px solid #dbe2ea; + border-radius: 16px; + background: rgba(255, 255, 255, 0.72); +} + +.container-node__param-legend { + padding: 0 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #475569; +} + .container-node__param-chip { min-width: 0; border: 1px solid #dbe2ea; diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index 8b46fb0..ddbd321 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -208,6 +208,94 @@ } + @if (parameterFieldGroups.length) { +
+ @for (group of parameterFieldGroups; track group.key) { +
+ {{ group.legend }} + @if (group.fields.length) { +
+ @for (field of group.fields; track field.path) { +
+
+ {{ field.label }} + @if (field.type === 'boolean' && !isReadonly) { + + } @else if (!isReadonly) { + + } @else if (field.expandable) { + + } +
+ {{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }} +
+ } +
+ } + @for (contentField of group.richContentFields; track contentField.path) { +
+
+ {{ contentField.label }} + @if (contentField.expandable && isReadonly) { + + } @else if (!isReadonly) { + + } +
+
+ @for (part of contentField.parts; track $index) { + @if (part.isDynamicInput) { + {{ formatDynamicInputToken(part.text) }} + } @else { + {{ part.text }} + } + } +
+
+ } +
+ } +
+ } @for (field of parameterFields; track field.path) {
@@ -261,6 +349,15 @@ (click)="openMainContentPreview(contentField, $event)"> + } @else if (!isReadonly) { + }
diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index c91c005..1afe824 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -13,15 +13,28 @@ import { EditorStateHolder } from '@stores/flow-editor'; import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag'; import { firstValueFrom } from 'rxjs'; import { extractSchemaRequirements, SchemaRequirements } from '../schema-requirements'; -import { evaluateUiConditionRule, getValueByPath, parentPath, pathToLabel, readEffectiveUiVisibleConditionRule, readUiConditionRule, resolveNodeIcon, resolveSchemaPath, resolveSchemaRef, schemaFieldLabel, shouldSkipSchemaField, splitTemplatedTextParts, valueToDisplayString } from '../node-utility'; +import { type UiConditionRule, evaluateUiConditionRule, getValueByPath, parentPath, pathToLabel, resolveNodeIcon, resolveSchemaPath, schemaFieldLabel, splitTemplatedTextParts, valueToDisplayString } from '../node-utility'; +import { + type SchemaFieldType, + type SchemaFieldGroup, + type SchemaFieldUiMeta, + type SchemaNodeOptionsSource, + buildTemplatedRichContentParts, + collectSchemaLeafFields, + getSchemaPathUiMeta, + groupSchemaFields, + isLongTextValue, + isSchemaPathEnabled, + isSchemaPathVisible, + schemaEnumOptions, + schemaFieldTypeFromSchema, + schemaNodeOptionsSource, + toSchemaFieldUiMeta +} from '../schema-driven-fields'; -type ContainerFieldType = 'string' | 'number' | 'integer' | 'boolean' | 'unknown'; +type ContainerFieldType = SchemaFieldType; -type NodeOptionsSource = { - collection: 'inputs' | 'outputs'; - valueField: string; - labelField: string; -}; +type NodeOptionsSource = SchemaNodeOptionsSource; type ContainerFieldDefinition = { path: string; @@ -31,7 +44,12 @@ type ContainerFieldDefinition = { nodeOptionsSource: NodeOptionsSource | null; widget: 'textarea' | null; structural: boolean; - enabledWhen: ReturnType[]; + visibleWhen: UiConditionRule[]; + enabledWhen: UiConditionRule[]; + group: string | null; + placeholder?: string; + tip?: string; + rows?: number; }; type ContainerFieldView = { @@ -53,6 +71,8 @@ type RichContentView = { parts: { text: string; isDynamicInput: boolean }[]; }; +type ContainerFieldGroupView = SchemaFieldGroup; + type StructuredRetrieverConfig = { retrieverName: string; retrieverUrl: string; @@ -86,6 +106,7 @@ export class ContainerNodeComponent { importLoading = false; private importErrorMessage: string | null = null; parameterFields: ContainerFieldView[] = []; + parameterFieldGroups: ContainerFieldGroupView[] = []; richContentFields: RichContentView[] = []; schemaReady = false; private schemaLoading = false; @@ -268,11 +289,11 @@ export class ContainerNodeComponent { } hasParameterFields() { - return this.parameterFields.length > 0; + return this.parameterFields.length > 0 || this.parameterFieldGroups.some((group) => group.fields.length > 0); } hasMainContent() { - return this.richContentFields.length > 0; + return this.richContentFields.length > 0 || this.parameterFieldGroups.some((group) => group.richContentFields.length > 0); } formatDynamicInputToken(token: string): string { @@ -440,7 +461,9 @@ export class ContainerNodeComponent { label: definition.label, type: this.toDialogFieldType(definition), required: this.missingRequiredParams.includes(definition.label), - rows: definition.widget === 'textarea' ? 6 : undefined, + placeholder: definition.placeholder, + tip: definition.tip, + rows: definition.widget === 'textarea' ? definition.rows ?? 6 : undefined, options: this.resolveSelectableOptions(definition) }; @@ -760,7 +783,7 @@ export class ContainerNodeComponent { private refreshParameterFields() { const config = this.configuration ?? {}; const richContentPaths = new Set(this.richContentPaths()); - this.richContentFields = this.richContentPaths() + const allRichContentFields = this.richContentPaths() .filter((path) => this.isFieldVisible(path)) .map((path) => { const rawValue = String(getValueByPath(config, path) ?? ''); @@ -768,13 +791,13 @@ export class ContainerNodeComponent { path, label: this.containerFieldDefinitions.find((field) => field.path === path)?.label ?? pathToLabel(path), rawValue, - expandable: this.isLongTextValue(rawValue), + expandable: isLongTextValue(rawValue), parts: this.toRichContentParts(path) }; }) .filter((field) => field.parts.length > 0); - this.parameterFields = this.containerFieldDefinitions + const orderedFields = this.containerFieldDefinitions .filter((field) => !this.isContainerTypeField(field.path)) .filter((field) => this.isFieldVisible(field.path)) .filter((field) => !richContentPaths.has(field.path)) @@ -783,55 +806,44 @@ export class ContainerNodeComponent { label: field.label, value: valueToDisplayString(getValueByPath(config, field.path)), wide: field.widget === 'textarea' || field.label.length >= 18, - expandable: this.isLongTextValue(valueToDisplayString(getValueByPath(config, field.path))), + expandable: isLongTextValue(valueToDisplayString(getValueByPath(config, field.path))), enabled: this.isFieldEnabled(field.path), type: field.type, booleanValue: getValueByPath(config, field.path) === true })); + + const grouped = groupSchemaFields({ + fields: orderedFields, + richContentFields: allRichContentFields, + resolveGroupLabel: (path) => getSchemaPathUiMeta(this.containerSchema, path).group ?? parentPath(path) + }); + + this.parameterFields = grouped.rootFields; + this.richContentFields = grouped.rootRichContentFields; + this.parameterFieldGroups = grouped.groups; } private buildContainerFieldDefinitions(schema: Record | null): ContainerFieldDefinition[] { - if (!schema) return []; + return collectSchemaLeafFields(schema, ({ key, path, schema: childResolved, ui }) => { + if (key.startsWith('__')) return null; + if (path === 'name' || path === 'subFlow' || this.isContainerTypeField(path)) return null; - const definitions: ContainerFieldDefinition[] = []; - const walk = (node: Record, pathPrefix: string) => { - const resolved = resolveSchemaRef(node, schema); - if (!resolved || typeof resolved !== 'object') return; - - const properties = resolved.properties as Record | undefined; - if (!properties) return; - - for (const [key, childSchema] of Object.entries(properties)) { - const childResolved = resolveSchemaRef(childSchema as Record, schema); - if (shouldSkipSchemaField(key, childResolved)) continue; - const path = pathPrefix ? `${pathPrefix}.${key}` : key; - if (path === 'name' || path === 'subFlow' || this.isContainerTypeField(path)) continue; - - const hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; - if (hasChildren) { - walk(childResolved as Record, path); - continue; - } - - definitions.push({ - path, - label: schemaFieldLabel(path, childResolved), - type: this.toFieldType(childResolved), - enumOptions: Array.isArray(childResolved?.enum) - ? childResolved.enum.filter((item: unknown): item is string => typeof item === 'string') - : [], - nodeOptionsSource: this.toNodeOptionsSource(childResolved), - widget: typeof childResolved?.['x-ui-widget'] === 'string' && String(childResolved['x-ui-widget']).toLowerCase() === 'textarea' - ? 'textarea' - : null, - structural: childResolved?.['x-ui-structural'] === true, - enabledWhen: [readUiConditionRule(childResolved?.['x-ui-enabled-when'])].filter((rule) => !!rule) - }); - } - }; - - walk(schema, ''); - return definitions; + return { + path, + label: schemaFieldLabel(path, childResolved), + type: this.toFieldType(childResolved), + enumOptions: schemaEnumOptions(childResolved), + nodeOptionsSource: this.toNodeOptionsSource(childResolved), + widget: ui.widget, + structural: ui.structural, + visibleWhen: ui.visibleWhen, + enabledWhen: ui.enabledWhen, + group: ui.group, + placeholder: ui.placeholder, + tip: ui.tip, + rows: ui.rows + }; + }); } private richContentPaths(): string[] { @@ -852,49 +864,23 @@ export class ContainerNodeComponent { } private toRichContentParts(path: string): { text: string; isDynamicInput: boolean }[] { - const content = String(getValueByPath(this.configuration ?? {}, path) ?? '').trim(); - if (!content) return []; - - const schema = this.resolveFieldSchema(path); - if (schema?.['x-ui-accept-variable-as-placeholder'] === true) { - return splitTemplatedTextParts(content); - } - - return [{ text: content, isDynamicInput: false }]; + return buildTemplatedRichContentParts(this.configuration ?? {}, path, this.containerSchema, splitTemplatedTextParts); } private isFieldVisible(path: string, visited = new Set()): boolean { if (visited.has(path)) return true; visited.add(path); - - const schema = this.resolveFieldSchema(path); - const rule = readEffectiveUiVisibleConditionRule(schema); - if (!rule) return true; - - return evaluateUiConditionRule(rule, this.configuration ?? {}, (fieldPath) => this.resolveFieldSchema(fieldPath)); + return isSchemaPathVisible(this.containerSchema, path, this.configuration ?? {}); } private isFieldEnabled(path: string, visited = new Set()): boolean { if (visited.has(path)) return true; visited.add(path); - - const parent = parentPath(path); - if (parent && !this.isFieldEnabled(parent, visited)) return false; - - const schema = this.resolveFieldSchema(path); - const rules = [readUiConditionRule(schema?.['x-ui-enabled-when'])].filter((rule) => !!rule); - return rules.every((rule) => { - if (!rule) return true; - return evaluateUiConditionRule(rule, this.configuration ?? {}, (fieldPath) => this.resolveFieldSchema(fieldPath)); - }); + return isSchemaPathEnabled(this.containerSchema, path, this.configuration ?? {}); } private toFieldType(schema: Record | null): ContainerFieldType { - const type = typeof schema?.['type'] === 'string' ? String(schema['type']) : 'unknown'; - if (type === 'string' || type === 'number' || type === 'integer' || type === 'boolean') { - return type; - } - return 'unknown'; + return schemaFieldTypeFromSchema(schema); } private toDialogFieldType(definition: ContainerFieldDefinition): NodeSettingField['type'] { @@ -905,26 +891,7 @@ export class ContainerNodeComponent { } private toNodeOptionsSource(schema: Record | null | undefined): NodeOptionsSource | null { - if (!schema || typeof schema !== 'object') return null; - const raw = schema['x-ui-options-from-node']; - if (!raw || typeof raw !== 'object') return null; - - const collection = (raw as Record)['collection']; - const valueField = (raw as Record)['valueField']; - const labelField = (raw as Record)['labelField']; - if ((collection !== 'inputs' && collection !== 'outputs') - || typeof valueField !== 'string' - || typeof labelField !== 'string' - || valueField.trim().length === 0 - || labelField.trim().length === 0) { - return null; - } - - return { - collection, - valueField: valueField.trim(), - labelField: labelField.trim() - }; + return schemaNodeOptionsSource(schema); } private resolveSelectableOptions(definition: ContainerFieldDefinition): NodeSettingOption[] | undefined { @@ -953,10 +920,6 @@ export class ContainerNodeComponent { .filter((option: NodeSettingOption | null): option is NodeSettingOption => option != null); } - private isLongTextValue(value: string): boolean { - return String(value ?? '').trim().length > 80; - } - private getEditorInitialValue(definition: ContainerFieldDefinition): string | boolean { const raw = getValueByPath(this.configuration ?? {}, definition.path); if (definition.type === 'boolean') return raw === true; @@ -1120,4 +1083,15 @@ export class ContainerNodeComponent { return resolveSchemaPath(this.containerSchema, path); } + private toFieldUiMeta( + schema: Record | null | undefined, + inheritedUi?: Pick + ) { + return toSchemaFieldUiMeta(schema, inheritedUi); + } + + private getFieldUiMeta(path: string) { + return getSchemaPathUiMeta(this.containerSchema, path); + } + } diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index bfa9cfe..ad5674f 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -29,9 +29,6 @@ import { parentPath, pathToLabel, readUiConditionRule, - readEffectiveUiVisibleConditionRule, - readUiLabel, - readUiGroup, resolveNodeIcon, resolveSchemaRef, resolveSchemaPath, @@ -43,8 +40,24 @@ import { validateUniqueByConstraint, valueToDisplayString } from '../node-utility'; +import { + type SchemaFieldType, + type SchemaFieldUiMeta, + type SchemaNodeOptionsSource, + buildTemplatedRichContentParts, + collectSchemaLeafFields, + getSchemaPathUiMeta, + groupSchemaFields, + isLongTextValue, + isSchemaPathEnabled, + isSchemaPathVisible, + schemaEnumOptions, + schemaFieldTypeFromSchema, + schemaNodeOptionsSource, + toSchemaFieldUiMeta +} from '../schema-driven-fields'; -type FieldType = 'string' | 'number' | 'integer' | 'boolean' | 'unknown'; +type FieldType = SchemaFieldType; type RetrieverDependency = { key: string; @@ -52,11 +65,7 @@ type RetrieverDependency = { source: 'field' | 'context'; }; -type NodeOptionsSource = { - collection: 'inputs' | 'outputs'; - valueField: string; - labelField: string; -}; +type NodeOptionsSource = SchemaNodeOptionsSource; type EditableFieldDefinition = { path: string; @@ -69,23 +78,7 @@ type EditableFieldDefinition = { retrieverUrl: string | null; retrieverStructuredData: boolean; retrieverDependsOn: RetrieverDependency[]; - ui: { - widget: 'textarea' | null; - acceptVariableAsPlaceholder: boolean; - structural: boolean; - bindableAsInput: boolean; - inputName: string | null; - inputType: string | null; - inputMultiple: boolean | null; - structuralReason?: string; - label?: string; - placeholder?: string; - tip?: string; - rows?: number; - visibleWhen: UiConditionRule[]; - enabledWhen: UiConditionRule[]; - group: string | null; - }; + ui: SchemaFieldUiMeta; }; type EditableFieldView = { @@ -670,221 +663,52 @@ export class GenericNodeComponent { } private buildEditableFieldDefinitions(schema: Record | null): EditableFieldDefinition[] { - if (!schema) return []; + return collectSchemaLeafFields(schema, ({ key, path, pathPrefix, schema: childResolved, ui }) => { + if (childResolved?.['type'] === 'array') return null; + if (key === 'type' || key === 'name' || key.startsWith('__')) return null; - const definitions: EditableFieldDefinition[] = []; - const seen = new Set(); - - const walk = ( - node: Record, - pathPrefix: string, - inheritedUi?: { visibleWhen: UiConditionRule[]; enabledWhen: UiConditionRule[]; group: string | null } - ) => { - const resolved = resolveSchemaRef(node, schema); - if (!resolved || typeof resolved !== 'object') return; - - const properties = resolved.properties as Record | undefined; - if (!properties) return; - - for (const [key, childSchema] of Object.entries(properties)) { - const childResolved = resolveSchemaRef(childSchema as Record, schema); - const path = pathPrefix ? `${pathPrefix}.${key}` : key; - if (childResolved?.type === 'array') { - continue; - } - const hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; - const childUi = this.toFieldUiMeta(childResolved, inheritedUi); - - if (hasChildren) { - walk(childResolved as Record, path, { - visibleWhen: childUi.visibleWhen, - enabledWhen: childUi.enabledWhen, - group: childUi.group - }); - continue; - } - - if (key === 'type' || key === 'name' || key.startsWith('__')) continue; - if (seen.has(path)) continue; - - seen.add(path); - definitions.push({ - path, - label: schemaFieldLabel(path, childResolved), - type: this.toFieldType(childResolved?.type), - enumOptions: this.toEnumOptions(childResolved), - nodeOptionsSource: this.toNodeOptionsSource(childResolved), - retrieverBlockType: this.toRetrieverBlockType(childResolved), - retrieverKey: this.toRetrieverKey(childResolved), - retrieverUrl: this.toRetrieverUrl(childResolved), - retrieverStructuredData: childResolved?.['x-retriever-structured-data'] === true, - retrieverDependsOn: this.toRetrieverDependsOn(childResolved, pathPrefix), - ui: childUi - }); - } - }; - - walk(schema, ''); - return definitions; + return { + path, + label: schemaFieldLabel(path, childResolved), + type: this.toFieldType(childResolved), + enumOptions: this.toEnumOptions(childResolved), + nodeOptionsSource: this.toNodeOptionsSource(childResolved), + retrieverBlockType: this.toRetrieverBlockType(childResolved), + retrieverKey: this.toRetrieverKey(childResolved), + retrieverUrl: this.toRetrieverUrl(childResolved), + retrieverStructuredData: childResolved?.['x-retriever-structured-data'] === true, + retrieverDependsOn: this.toRetrieverDependsOn(childResolved, pathPrefix), + ui + }; + }); } private buildArrayFieldDefinitions(schema: Record | null): ArrayFieldDefinition[] { - if (!schema) return []; + return collectSchemaLeafFields(schema, ({ key, path, schema: childResolved, ui }) => { + if (childResolved?.['type'] !== 'array') return null; + if (key === 'type' || key === 'name' || key.startsWith('__')) return null; - const definitions: ArrayFieldDefinition[] = []; - const seen = new Set(); - - const walk = ( - node: Record, - pathPrefix: string, - inheritedUi?: { visibleWhen: UiConditionRule[]; enabledWhen: UiConditionRule[]; group: string | null } - ) => { - const resolved = resolveSchemaRef(node, schema); - if (!resolved || typeof resolved !== 'object') return; - - const properties = resolved.properties as Record | undefined; - if (!properties) return; - - for (const [key, childSchema] of Object.entries(properties)) { - const childResolved = resolveSchemaRef(childSchema as Record, schema); - const path = pathPrefix ? `${pathPrefix}.${key}` : key; - const childUi = this.toFieldUiMeta(childResolved, inheritedUi); - - if (childResolved?.type === 'array') { - if (key === 'type' || key === 'name' || key.startsWith('__') || seen.has(path)) { - continue; - } - seen.add(path); - definitions.push({ - path, - label: schemaFieldLabel(path, childResolved), - itemSchema: this.resolveArrayItemSchema(childResolved, schema), - uniqueBy: typeof childResolved?.['x-ui-unique-by'] === 'string' && String(childResolved['x-ui-unique-by']).trim().length > 0 - ? String(childResolved['x-ui-unique-by']).trim() - : null, - ui: { - structural: childUi.structural, - visibleWhen: childUi.visibleWhen, - enabledWhen: childUi.enabledWhen, - group: childUi.group - } - }); - continue; + return { + path, + label: schemaFieldLabel(path, childResolved), + itemSchema: this.resolveArrayItemSchema(childResolved, schema ?? {}), + uniqueBy: typeof childResolved?.['x-ui-unique-by'] === 'string' && String(childResolved['x-ui-unique-by']).trim().length > 0 + ? String(childResolved['x-ui-unique-by']).trim() + : null, + ui: { + structural: ui.structural, + visibleWhen: ui.visibleWhen, + enabledWhen: ui.enabledWhen, + group: ui.group } - - const hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; - if (hasChildren) { - walk(childResolved as Record, path, { - visibleWhen: childUi.visibleWhen, - enabledWhen: childUi.enabledWhen, - group: childUi.group - }); - } - } - }; - - walk(schema, ''); - return definitions; - } - - private toFieldUiMeta( - schema: Record | null | undefined, - inheritedUi?: { visibleWhen: UiConditionRule[]; enabledWhen: UiConditionRule[]; group: string | null } - ) { - const rawWidget = typeof schema?.['x-ui-widget'] === 'string' - ? String(schema['x-ui-widget']).toLowerCase().trim() - : ''; - const normalizedWidget: 'textarea' | null = - rawWidget === 'textarea' || rawWidget === 'text-area' ? 'textarea' : null; - const placeholder = typeof schema?.['x-ui-placeholder'] === 'string' - ? String(schema['x-ui-placeholder']) - : undefined; - const tip = schemaFieldDescription(schema) ?? undefined; - const rowsRaw = schema?.['x-ui-rows']; - const rows = typeof rowsRaw === 'number' && Number.isFinite(rowsRaw) && rowsRaw > 0 - ? Math.trunc(rowsRaw) - : undefined; - const acceptVariableAsPlaceholder = schema?.['x-ui-accept-variable-as-placeholder'] === true; - const structural = schema?.['x-ui-structural'] === true; - const bindableAsInput = schema?.['x-ui-bindable-as-input'] === true; - const inputName = typeof schema?.['x-ui-input-name'] === 'string' - ? String(schema['x-ui-input-name']) - : null; - const inputType = typeof schema?.['x-ui-input-type'] === 'string' - ? String(schema['x-ui-input-type']).toUpperCase() - : null; - const inputMultiple = typeof schema?.['x-ui-input-multiple'] === 'boolean' - ? Boolean(schema['x-ui-input-multiple']) - : null; - const structuralReason = typeof schema?.['x-ui-structural-reason'] === 'string' - ? String(schema['x-ui-structural-reason']) - : undefined; - const visibleWhen = readEffectiveUiVisibleConditionRule(schema); - const enabledWhen = readUiConditionRule(schema?.['x-ui-enabled-when']); - const label = readUiLabel(schema?.['x-ui-label']) ?? undefined; - const isObjectLike = schema?.['type'] === 'object' || !!schema?.['properties']; - const group = readUiGroup(schema?.['x-ui-group']) - ?? (isObjectLike ? label ?? null : null) - ?? inheritedUi?.group - ?? null; - - return { - widget: normalizedWidget, - acceptVariableAsPlaceholder, - structural, - bindableAsInput, - inputName, - inputType, - inputMultiple, - structuralReason, - label, - placeholder, - tip, - rows, - visibleWhen: [ - ...(inheritedUi?.visibleWhen ?? []), - ...(visibleWhen ? [visibleWhen] : []) - ], - enabledWhen: [ - ...(inheritedUi?.enabledWhen ?? []), - ...(enabledWhen ? [enabledWhen] : []) - ], - group - }; - } - - private getFieldUiMeta(path: string) { - const root = this.blockSchema; - if (!root) return this.toFieldUiMeta(null); - - let current: Record | null = root; - let inheritedUi = { visibleWhen: [] as UiConditionRule[], enabledWhen: [] as UiConditionRule[], group: null as string | null }; - - for (const segment of path.split('.')) { - if (!current) return this.toFieldUiMeta(null, inheritedUi); - const resolved = resolveSchemaRef(current, root); - if (/^\d+$/.test(segment)) { - const items = resolved?.items; - if (!items || typeof items !== 'object') return this.toFieldUiMeta(null, inheritedUi); - current = resolveSchemaRef(items as Record, root); - } else { - const properties = resolved?.properties as Record | undefined; - if (!properties || !properties[segment]) return this.toFieldUiMeta(null, inheritedUi); - current = resolveSchemaRef(properties[segment] as Record, root); - } - const nextUi = this.toFieldUiMeta(current, inheritedUi); - inheritedUi = { - visibleWhen: nextUi.visibleWhen, - enabledWhen: nextUi.enabledWhen, - group: nextUi.group }; - } - - return this.toFieldUiMeta(current, inheritedUi); + }, { + includeArrays: true + }); } private isStructuralField(path: string): boolean { - return this.getFieldUiMeta(path).structural; + return getSchemaPathUiMeta(this.blockSchema, path).structural; } private resolveFieldSchema(path: string): Record | null { @@ -892,10 +716,12 @@ export class GenericNodeComponent { } private toFieldType(type: unknown): FieldType { - if (type === 'string') return 'string'; - if (type === 'number') return 'number'; - if (type === 'integer') return 'integer'; - if (type === 'boolean') return 'boolean'; + if (type && typeof type === 'object' && !Array.isArray(type)) { + return schemaFieldTypeFromSchema(type as Record); + } + if (type === 'string' || type === 'number' || type === 'integer' || type === 'boolean') { + return type; + } return 'unknown'; } @@ -918,32 +744,11 @@ export class GenericNodeComponent { } private toEnumOptions(schema: Record | null | undefined): string[] { - const raw = schema?.['enum']; - if (!Array.isArray(raw)) return []; - return raw.filter((value): value is string => typeof value === 'string'); + return schemaEnumOptions(schema); } private toNodeOptionsSource(schema: Record | null | undefined): NodeOptionsSource | null { - if (!schema || typeof schema !== 'object') return null; - const raw = schema['x-ui-options-from-node']; - if (!raw || typeof raw !== 'object') return null; - - const collection = (raw as Record)['collection']; - const valueField = (raw as Record)['valueField']; - const labelField = (raw as Record)['labelField']; - if ((collection !== 'inputs' && collection !== 'outputs') - || typeof valueField !== 'string' - || typeof labelField !== 'string' - || valueField.trim().length === 0 - || labelField.trim().length === 0) { - return null; - } - - return { - collection, - valueField: valueField.trim(), - labelField: labelField.trim() - }; + return schemaNodeOptionsSource(schema); } private toRetrieverUrl(schema: Record | null | undefined): string | null { @@ -1160,10 +965,6 @@ export class GenericNodeComponent { .filter((option): option is NodeSettingOption => option != null); } - private isLongTextValue(value: string): boolean { - return String(value ?? '').trim().length > 80; - } - private async openReadonlyTextDialog(label: string, value: string) { await this.settingsDialog.open({ title: label, @@ -1186,8 +987,6 @@ export class GenericNodeComponent { private refreshParameterFields() { const config = this.blockConfiguration ?? {}; const richContentPaths = new Set(this.richContentPaths()); - const grouped = new Map(); - const rootFields: EditableFieldView[] = []; this.richContentFields = this.richContentPaths() .filter((path) => this.isPathVisible(path)) @@ -1197,7 +996,7 @@ export class GenericNodeComponent { path, label: this.fieldDisplayLabel(path), rawValue, - expandable: this.isLongTextValue(rawValue), + expandable: isLongTextValue(rawValue), parts: this.toRichContentParts(path) }; }); @@ -1218,7 +1017,7 @@ export class GenericNodeComponent { label: definition.label, value: this.fieldDisplayValue(definition, value), wide: this.shouldRenderWideField(definition.label, definition.ui.widget === 'textarea'), - expandable: this.isLongTextValue(this.fieldDisplayValue(definition, value)), + expandable: isLongTextValue(this.fieldDisplayValue(definition, value)), enabled: this.isPathEnabled(definition.path), type: definition.type, booleanValue: value === true @@ -1226,23 +1025,15 @@ export class GenericNodeComponent { }).filter((field) => !richContentPaths.has(field.path)) .filter((field) => this.isPathVisible(field.path)); - for (const field of orderedFields) { - const groupLabel = this.getFieldUiMeta(field.path).group ?? parentPath(field.path); - const groupKey = groupLabel ? `group:${groupLabel}` : null; - if (!groupKey || !groupLabel) { - rootFields.push(field); - continue; - } - if (!grouped.has(groupKey)) { - grouped.set(groupKey, []); - } - grouped.get(groupKey)!.push(field); - } - this.parameterFields = rootFields; - this.parameterFieldGroups = Array.from(grouped.entries()).map(([key, fields]) => ({ - key, - legend: key.startsWith('group:') ? key.slice('group:'.length) : this.fieldDisplayLabel(key), - fields + const groupedFields = groupSchemaFields({ + fields: orderedFields, + resolveGroupLabel: (path) => getSchemaPathUiMeta(this.blockSchema, path).group ?? parentPath(path) + }); + this.parameterFields = groupedFields.rootFields; + this.parameterFieldGroups = groupedFields.groups.map((group) => ({ + key: group.key, + legend: group.legend, + fields: group.fields })); this.refreshView(); return; @@ -1256,29 +1047,23 @@ export class GenericNodeComponent { label: this.fieldDisplayLabel(entry.path), value: valueToDisplayString(entry.value), wide: this.shouldRenderWideField(this.fieldDisplayLabel(entry.path), false), - expandable: this.isLongTextValue(valueToDisplayString(entry.value)), + expandable: isLongTextValue(valueToDisplayString(entry.value)), enabled: this.isPathEnabled(entry.path), type: (typeof entry.value === 'boolean' ? 'boolean' : 'unknown') as FieldType, booleanValue: entry.value === true })); - for (const field of fallbackFields) { - const parentKey = parentPath(field.path); - if (!parentKey) { - rootFields.push(field); - continue; - } - if (!grouped.has(parentKey)) { - grouped.set(parentKey, []); - } - grouped.get(parentKey)!.push(field); - } + const groupedFallback = groupSchemaFields({ + fields: fallbackFields, + resolveGroupLabel: (path) => parentPath(path), + resolveLegend: (groupLabel) => this.fieldDisplayLabel(groupLabel) + }); - this.parameterFields = rootFields; - this.parameterFieldGroups = Array.from(grouped.entries()).map(([key, fields]) => ({ - key, - legend: this.fieldDisplayLabel(key), - fields + this.parameterFields = groupedFallback.rootFields; + this.parameterFieldGroups = groupedFallback.groups.map((group) => ({ + key: group.key, + legend: group.legend, + fields: group.fields })); this.refreshView(); @@ -1908,57 +1693,16 @@ export class GenericNodeComponent { return textareaPaths; } - return this.findMainContentCandidatePaths(this.blockSchema); + return collectSchemaLeafFields(this.blockSchema, ({ path, schema }) => { + if (schema?.['type'] === 'array') return null; + return getSchemaPathUiMeta(this.blockSchema, path).widget === 'textarea' ? path : null; + }); } private toRichContentParts(path: string): { text: string; isDynamicInput: boolean }[] { const content = toStringOrNull(this.getByPath(this.blockConfiguration ?? {}, path)); if (!content) return []; - - const ui = this.getFieldUiMeta(path); - if (ui.widget === 'textarea' && ui.acceptVariableAsPlaceholder) { - return splitTemplatedTextParts(content); - } - - return [{ text: content, isDynamicInput: false }]; - } - - private findMainContentCandidatePaths(schema: Record | null): string[] { - if (!schema) return []; - - const paths: string[] = []; - const seen = new Set(); - - const walk = (node: Record, pathPrefix: string) => { - const resolved = resolveSchemaRef(node, schema); - if (!resolved || typeof resolved !== 'object') return; - - const properties = resolved.properties as Record | undefined; - if (!properties) return; - - for (const [key, childSchema] of Object.entries(properties)) { - const childResolved = resolveSchemaRef(childSchema as Record, schema); - const path = pathPrefix ? `${pathPrefix}.${key}` : key; - const hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; - - if (hasChildren) { - walk(childResolved as Record, path); - continue; - } - - const rawWidget = typeof childResolved?.['x-ui-widget'] === 'string' - ? String(childResolved['x-ui-widget']).toLowerCase().trim() - : ''; - const isTextarea = rawWidget === 'textarea' || rawWidget === 'text-area'; - if (!isTextarea || seen.has(path)) continue; - - seen.add(path); - paths.push(path); - } - }; - - walk(schema, ''); - return paths; + return buildTemplatedRichContentParts(this.blockConfiguration ?? {}, path, this.blockSchema, splitTemplatedTextParts); } private getByPath(source: Record, path: string): unknown { @@ -2171,7 +1915,7 @@ export class GenericNodeComponent { } private isPathVisible(path: string): boolean { - return this.isFieldConditionSatisfied(path); + return isSchemaPathVisible(this.blockSchema, path, this.blockConfiguration); } private editorFlowId(): string | null { @@ -2271,12 +2015,7 @@ export class GenericNodeComponent { private isPathEnabled(path: string, visited = new Set()): boolean { if (visited.has(path)) return true; visited.add(path); - - const ui = this.getFieldUiMeta(path); - return ui.enabledWhen.every((rule) => { - if (!rule) return true; - return evaluateUiConditionRule(rule, this.blockConfiguration, (fieldPath) => this.resolveFieldSchema(fieldPath)); - }); + return isSchemaPathEnabled(this.blockSchema, path, this.blockConfiguration); } private isFieldVisible(field: EditableFieldDefinition): boolean { @@ -2311,11 +2050,17 @@ export class GenericNodeComponent { private isFieldConditionSatisfied(path: string, visited = new Set()): boolean { if (visited.has(path)) return true; visited.add(path); + return isSchemaPathVisible(this.blockSchema, path, this.blockConfiguration); + } - const ui = this.getFieldUiMeta(path); - return ui.visibleWhen.every((rule) => { - if (!rule) return true; - return evaluateUiConditionRule(rule, this.blockConfiguration, (fieldPath) => this.resolveFieldSchema(fieldPath)); - }); + private toFieldUiMeta( + schema: Record | null | undefined, + inheritedUi?: Pick + ) { + return toSchemaFieldUiMeta(schema, inheritedUi); + } + + private getFieldUiMeta(path: string) { + return getSchemaPathUiMeta(this.blockSchema, path); } } diff --git a/src/app/shared/nodes/schema-driven-fields.ts b/src/app/shared/nodes/schema-driven-fields.ts new file mode 100644 index 0000000..773c983 --- /dev/null +++ b/src/app/shared/nodes/schema-driven-fields.ts @@ -0,0 +1,358 @@ +import { + type UiConditionRule, + evaluateUiConditionRule, + getValueByPath, + readEffectiveUiVisibleConditionRule, + readUiConditionRule, + readUiGroup, + readUiLabel, + resolveSchemaRef, + resolveSchemaPath, + schemaFieldDescription +} from './node-utility'; + +export type SchemaFieldType = 'string' | 'number' | 'integer' | 'boolean' | 'unknown'; + +export type SchemaNodeOptionsSource = { + collection: 'inputs' | 'outputs'; + valueField: string; + labelField: string; +}; + +export type SchemaFieldUiMeta = { + widget: 'textarea' | null; + acceptVariableAsPlaceholder: boolean; + structural: boolean; + bindableAsInput: boolean; + inputName: string | null; + inputType: string | null; + inputMultiple: boolean | null; + structuralReason?: string; + label?: string; + placeholder?: string; + tip?: string; + rows?: number; + visibleWhen: UiConditionRule[]; + enabledWhen: UiConditionRule[]; + group: string | null; +}; + +type SchemaUiInheritance = Pick; + +type SchemaLeafFieldContext = { + key: string; + path: string; + pathPrefix: string; + schema: Record | null; + ui: SchemaFieldUiMeta; +}; + +export type SchemaFieldGroup = { + key: string; + legend: string; + fields: TField[]; + richContentFields: TRichContent[]; +}; + +export function toSchemaFieldUiMeta( + schema: Record | null | undefined, + inheritedUi?: SchemaUiInheritance +): SchemaFieldUiMeta { + const rawWidget = typeof schema?.['x-ui-widget'] === 'string' + ? String(schema['x-ui-widget']).toLowerCase().trim() + : ''; + const normalizedWidget: 'textarea' | null = + rawWidget === 'textarea' || rawWidget === 'text-area' ? 'textarea' : null; + const placeholder = typeof schema?.['x-ui-placeholder'] === 'string' + ? String(schema['x-ui-placeholder']) + : undefined; + const tip = schemaFieldDescription(schema) ?? undefined; + const rowsRaw = schema?.['x-ui-rows']; + const rows = typeof rowsRaw === 'number' && Number.isFinite(rowsRaw) && rowsRaw > 0 + ? Math.trunc(rowsRaw) + : undefined; + const acceptVariableAsPlaceholder = schema?.['x-ui-accept-variable-as-placeholder'] === true; + const structural = schema?.['x-ui-structural'] === true; + const bindableAsInput = schema?.['x-ui-bindable-as-input'] === true; + const inputName = typeof schema?.['x-ui-input-name'] === 'string' + ? String(schema['x-ui-input-name']) + : null; + const inputType = typeof schema?.['x-ui-input-type'] === 'string' + ? String(schema['x-ui-input-type']).toUpperCase() + : null; + const inputMultiple = typeof schema?.['x-ui-input-multiple'] === 'boolean' + ? Boolean(schema['x-ui-input-multiple']) + : null; + const structuralReason = typeof schema?.['x-ui-structural-reason'] === 'string' + ? String(schema['x-ui-structural-reason']) + : undefined; + const visibleWhen = readEffectiveUiVisibleConditionRule(schema); + const enabledWhen = readUiConditionRule(schema?.['x-ui-enabled-when']); + const label = readUiLabel(schema?.['x-ui-label']) ?? undefined; + const isObjectLike = schema?.['type'] === 'object' || !!schema?.['properties']; + const group = readUiGroup(schema?.['x-ui-group']) + ?? (isObjectLike ? label ?? null : null) + ?? inheritedUi?.group + ?? null; + + return { + widget: normalizedWidget, + acceptVariableAsPlaceholder, + structural, + bindableAsInput, + inputName, + inputType, + inputMultiple, + structuralReason, + label, + placeholder, + tip, + rows, + visibleWhen: [ + ...(inheritedUi?.visibleWhen ?? []), + ...(visibleWhen ? [visibleWhen] : []) + ], + enabledWhen: [ + ...(inheritedUi?.enabledWhen ?? []), + ...(enabledWhen ? [enabledWhen] : []) + ], + group + }; +} + +export function getSchemaPathUiMeta(root: Record | null | undefined, path: string): SchemaFieldUiMeta { + if (!root) return toSchemaFieldUiMeta(null); + + let current: Record | null = root; + let inheritedUi: SchemaUiInheritance = { + visibleWhen: [], + enabledWhen: [], + group: null + }; + + for (const segment of path.split('.')) { + if (!current) return toSchemaFieldUiMeta(null, inheritedUi); + + const resolved = resolveSchemaRef(current, root); + if (/^\d+$/.test(segment)) { + const items = resolved?.items; + if (!items || typeof items !== 'object') return toSchemaFieldUiMeta(null, inheritedUi); + current = resolveSchemaRef(items as Record, root); + } else { + const properties = resolved?.properties as Record | undefined; + if (!properties || !properties[segment]) return toSchemaFieldUiMeta(null, inheritedUi); + current = resolveSchemaRef(properties[segment] as Record, root); + } + + const nextUi = toSchemaFieldUiMeta(current, inheritedUi); + inheritedUi = { + visibleWhen: nextUi.visibleWhen, + enabledWhen: nextUi.enabledWhen, + group: nextUi.group + }; + } + + return toSchemaFieldUiMeta(current, inheritedUi); +} + +export function collectSchemaLeafFields( + root: Record | null | undefined, + mapLeaf: (context: SchemaLeafFieldContext) => T | null, + options?: { + includeArrays?: boolean; + shouldSkip?: (context: { key: string; path: string; schema: Record | null }) => boolean; + } +): T[] { + if (!root) return []; + + const fields: T[] = []; + const seen = new Set(); + + const walk = ( + node: Record, + pathPrefix: string, + inheritedUi?: SchemaUiInheritance + ) => { + const resolved = resolveSchemaRef(node, root); + if (!resolved || typeof resolved !== 'object') return; + + const properties = resolved.properties as Record | undefined; + if (!properties) return; + + for (const [key, childSchema] of Object.entries(properties)) { + const childResolved = resolveSchemaRef(childSchema as Record, root); + const path = pathPrefix ? `${pathPrefix}.${key}` : key; + if (options?.shouldSkip?.({ key, path, schema: childResolved })) continue; + + const childUi = toSchemaFieldUiMeta(childResolved, inheritedUi); + const isArray = childResolved?.type === 'array'; + const hasChildren = !!childResolved?.properties || childResolved?.type === 'object'; + + if (isArray && options?.includeArrays !== true) { + continue; + } + + if (hasChildren && !isArray) { + walk(childResolved as Record, path, { + visibleWhen: childUi.visibleWhen, + enabledWhen: childUi.enabledWhen, + group: childUi.group + }); + continue; + } + + if (seen.has(path)) continue; + seen.add(path); + + const mapped = mapLeaf({ + key, + path, + pathPrefix, + schema: childResolved, + ui: childUi + }); + if (mapped != null) { + fields.push(mapped); + } + } + }; + + walk(root, ''); + return fields; +} + +export function groupSchemaFields( + params: { + fields: TField[]; + richContentFields?: TRichContent[]; + resolveGroupLabel: (path: string) => string | null; + resolveLegend?: (groupLabel: string) => string; + } +): { + rootFields: TField[]; + rootRichContentFields: TRichContent[]; + groups: Array>; +} { + const grouped = new Map>(); + const rootFields: TField[] = []; + const rootRichContentFields: TRichContent[] = []; + const resolveLegend = params.resolveLegend ?? ((groupLabel: string) => groupLabel); + + for (const field of params.fields) { + const groupLabel = params.resolveGroupLabel(field.path); + if (!groupLabel) { + rootFields.push(field); + continue; + } + const groupKey = `group:${groupLabel}`; + if (!grouped.has(groupKey)) { + grouped.set(groupKey, { + key: groupKey, + legend: resolveLegend(groupLabel), + fields: [], + richContentFields: [] + }); + } + grouped.get(groupKey)!.fields.push(field); + } + + for (const field of params.richContentFields ?? []) { + const groupLabel = params.resolveGroupLabel(field.path); + if (!groupLabel) { + rootRichContentFields.push(field); + continue; + } + const groupKey = `group:${groupLabel}`; + if (!grouped.has(groupKey)) { + grouped.set(groupKey, { + key: groupKey, + legend: resolveLegend(groupLabel), + fields: [], + richContentFields: [] + }); + } + grouped.get(groupKey)!.richContentFields.push(field); + } + + return { + rootFields, + rootRichContentFields, + groups: Array.from(grouped.values()).filter((group) => group.fields.length > 0 || group.richContentFields.length > 0) + }; +} + +export function isSchemaPathVisible( + root: Record | null | undefined, + path: string, + config: Record | null | undefined +): boolean { + const ui = getSchemaPathUiMeta(root, path); + return ui.visibleWhen.every((rule) => evaluateUiConditionRule(rule, config, (fieldPath) => resolveSchemaPath(root, fieldPath))); +} + +export function isSchemaPathEnabled( + root: Record | null | undefined, + path: string, + config: Record | null | undefined +): boolean { + const ui = getSchemaPathUiMeta(root, path); + return ui.enabledWhen.every((rule) => evaluateUiConditionRule(rule, config, (fieldPath) => resolveSchemaPath(root, fieldPath))); +} + +export function schemaFieldTypeFromSchema(schema: Record | null | undefined): SchemaFieldType { + const type = typeof schema?.['type'] === 'string' ? String(schema['type']) : 'unknown'; + if (type === 'string' || type === 'number' || type === 'integer' || type === 'boolean') { + return type; + } + return 'unknown'; +} + +export function schemaEnumOptions(schema: Record | null | undefined): string[] { + const raw = schema?.['enum']; + if (!Array.isArray(raw)) return []; + return raw.filter((value): value is string => typeof value === 'string'); +} + +export function schemaNodeOptionsSource(schema: Record | null | undefined): SchemaNodeOptionsSource | null { + if (!schema || typeof schema !== 'object') return null; + const raw = schema['x-ui-options-from-node']; + if (!raw || typeof raw !== 'object') return null; + + const collection = (raw as Record)['collection']; + const valueField = (raw as Record)['valueField']; + const labelField = (raw as Record)['labelField']; + if ((collection !== 'inputs' && collection !== 'outputs') + || typeof valueField !== 'string' + || typeof labelField !== 'string' + || valueField.trim().length === 0 + || labelField.trim().length === 0) { + return null; + } + + return { + collection, + valueField: valueField.trim(), + labelField: labelField.trim() + }; +} + +export function isLongTextValue(value: string): boolean { + return String(value ?? '').trim().length > 80; +} + +export function buildTemplatedRichContentParts( + config: Record | null | undefined, + path: string, + root: Record | null | undefined, + splitParts: (content: string) => Array<{ text: string; isDynamicInput: boolean }> +): Array<{ text: string; isDynamicInput: boolean }> { + const content = String(getValueByPath(config ?? {}, path) ?? '').trim(); + if (!content) return []; + + const ui = getSchemaPathUiMeta(root, path); + if (ui.widget === 'textarea' && ui.acceptVariableAsPlaceholder) { + return splitParts(content); + } + + return [{ text: content, isDynamicInput: false }]; +}