Refactor schema-driven node fields and fix validation highlights

This commit is contained in:
Lucio Lelii 2026-04-17 17:42:32 +02:00
parent e6f6cda845
commit a377c1d793
10 changed files with 790 additions and 477 deletions

View File

@ -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: []
}]);
});
});

View File

@ -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, unknown>): 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

View File

@ -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 {

View File

@ -22,14 +22,17 @@
<section class="validation-list">
@for (error of errors(); track trackByError($index, error)) {
<article class="validation-item">
<article
class="validation-item"
[class.validation-item-clickable]="canFocusError(error)"
[attr.role]="canFocusError(error) ? 'button' : null"
[attr.tabindex]="canFocusError(error) ? 0 : null"
[attr.aria-label]="canFocusError(error) ? 'Highlight related nodes for this validation error' : null"
(click)="focusError(error)"
(keydown.enter)="focusError(error)"
(keydown.space)="focusError(error); $event.preventDefault()">
<div class="validation-item-head">
<span class="validation-code">{{ error.code || 'VALIDATION_ERROR' }}</span>
@if ((error.relatedNodeIds?.length ?? 0) > 0) {
<button type="button" class="validation-focus" mat-stroked-button (click)="focusError(error)">
Highlight
</button>
}
</div>
<p class="validation-message">{{ error.message }}</p>
</article>

View File

@ -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);
}

View File

@ -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;

View File

@ -208,6 +208,94 @@
</div>
</div>
}
@if (parameterFieldGroups.length) {
<div class="container-node__param-groups">
@for (group of parameterFieldGroups; track group.key) {
<fieldset class="container-node__param-fieldset">
<legend class="container-node__param-legend">{{ group.legend }}</legend>
@if (group.fields.length) {
<div class="container-node__params container-node__params--grouped">
@for (field of group.fields; track field.path) {
<div class="container-node__param-chip" [class.container-node__param-chip--wide]="field.wide" [class.container-node__param-chip--disabled]="!field.enabled">
<div class="container-node__param-head">
<span class="container-node__param-key">{{ field.label }}</span>
@if (field.type === 'boolean' && !isReadonly) {
<button
type="button"
class="container-node__bool-toggle"
[class.container-node__bool-toggle--on]="field.booleanValue"
[disabled]="!field.enabled"
[attr.aria-pressed]="field.booleanValue"
title="Toggle field"
(pointerdown)="$event.stopPropagation()"
(click)="toggleBooleanParameter(field.path, $event)">
<span class="container-node__bool-toggle-thumb"></span>
</button>
} @else if (!isReadonly) {
<button
type="button"
class="container-node__param-edit"
[disabled]="!field.enabled"
title="Edit field"
(pointerdown)="$event.stopPropagation()"
(click)="openParameterEditor(field.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (field.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openFieldPreview(field, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<span class="container-node__param-value" [class.container-node__param-value--clamped]="field.expandable">{{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }}</span>
</div>
}
</div>
}
@for (contentField of group.richContentFields; track contentField.path) {
<div class="container-node__param-chip container-node__param-chip--wide">
<div class="container-node__param-head">
<span class="container-node__param-key">{{ contentField.label }}</span>
@if (contentField.expandable && isReadonly) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openMainContentPreview(contentField, $event)">
<i class="bi bi-eye"></i>
</button>
} @else if (!isReadonly) {
<button
type="button"
class="container-node__param-edit"
title="Edit field"
(pointerdown)="$event.stopPropagation()"
(click)="openParameterEditor(contentField.path, $event)">
<i class="bi bi-pen"></i>
</button>
}
</div>
<div class="container-node__param-value" [class.container-node__param-value--clamped]="contentField.expandable">
@for (part of contentField.parts; track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ formatDynamicInputToken(part.text) }}</span>
} @else {
<span>{{ part.text }}</span>
}
}
</div>
</div>
}
</fieldset>
}
</div>
}
@for (field of parameterFields; track field.path) {
<div class="container-node__param-chip" [class.container-node__param-chip--wide]="field.wide" [class.container-node__param-chip--disabled]="!field.enabled">
<div class="container-node__param-head">
@ -261,6 +349,15 @@
(click)="openMainContentPreview(contentField, $event)">
<i class="bi bi-eye"></i>
</button>
} @else if (!isReadonly) {
<button
type="button"
class="container-node__param-edit"
title="Edit field"
(pointerdown)="$event.stopPropagation()"
(click)="openParameterEditor(contentField.path, $event)">
<i class="bi bi-pen"></i>
</button>
}
</div>
<div class="container-node__param-value" [class.container-node__param-value--clamped]="contentField.expandable">

View File

@ -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<typeof readUiConditionRule>[];
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<ContainerFieldView, RichContentView>;
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<string, any> | 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<string, any>, pathPrefix: string) => {
const resolved = resolveSchemaRef(node, schema);
if (!resolved || typeof resolved !== 'object') return;
const properties = resolved.properties as Record<string, any> | undefined;
if (!properties) return;
for (const [key, childSchema] of Object.entries(properties)) {
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, 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<string, any>, 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<string>()): 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<string>()): 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<string, any> | 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<string, any> | 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<string, unknown>)['collection'];
const valueField = (raw as Record<string, unknown>)['valueField'];
const labelField = (raw as Record<string, unknown>)['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<string, any> | null | undefined,
inheritedUi?: Pick<SchemaFieldUiMeta, 'visibleWhen' | 'enabledWhen' | 'group'>
) {
return toSchemaFieldUiMeta(schema, inheritedUi);
}
private getFieldUiMeta(path: string) {
return getSchemaPathUiMeta(this.containerSchema, path);
}
}

View File

@ -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<string, any> | 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<string>();
const walk = (
node: Record<string, any>,
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<string, any> | undefined;
if (!properties) return;
for (const [key, childSchema] of Object.entries(properties)) {
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, 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<string, any>, 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<string, any> | 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<string>();
const walk = (
node: Record<string, any>,
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<string, any> | undefined;
if (!properties) return;
for (const [key, childSchema] of Object.entries(properties)) {
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, 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<string, any>, path, {
visibleWhen: childUi.visibleWhen,
enabledWhen: childUi.enabledWhen,
group: childUi.group
});
}
}
};
walk(schema, '');
return definitions;
}
private toFieldUiMeta(
schema: Record<string, any> | 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<string, any> | 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<string, any>, root);
} else {
const properties = resolved?.properties as Record<string, unknown> | undefined;
if (!properties || !properties[segment]) return this.toFieldUiMeta(null, inheritedUi);
current = resolveSchemaRef(properties[segment] as Record<string, any>, 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<string, any> | 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<string, any>);
}
if (type === 'string' || type === 'number' || type === 'integer' || type === 'boolean') {
return type;
}
return 'unknown';
}
@ -918,32 +744,11 @@ export class GenericNodeComponent {
}
private toEnumOptions(schema: Record<string, any> | 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<string, any> | 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<string, unknown>)['collection'];
const valueField = (raw as Record<string, unknown>)['valueField'];
const labelField = (raw as Record<string, unknown>)['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<string, any> | 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<string, EditableFieldView[]>();
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<string, any> | null): string[] {
if (!schema) return [];
const paths: string[] = [];
const seen = new Set<string>();
const walk = (node: Record<string, any>, pathPrefix: string) => {
const resolved = resolveSchemaRef(node, schema);
if (!resolved || typeof resolved !== 'object') return;
const properties = resolved.properties as Record<string, any> | undefined;
if (!properties) return;
for (const [key, childSchema] of Object.entries(properties)) {
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, schema);
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
if (hasChildren) {
walk(childResolved as Record<string, any>, 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<string, any>, 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<string>()): 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<string>()): 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<string, any> | null | undefined,
inheritedUi?: Pick<SchemaFieldUiMeta, 'visibleWhen' | 'enabledWhen' | 'group'>
) {
return toSchemaFieldUiMeta(schema, inheritedUi);
}
private getFieldUiMeta(path: string) {
return getSchemaPathUiMeta(this.blockSchema, path);
}
}

View File

@ -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<SchemaFieldUiMeta, 'visibleWhen' | 'enabledWhen' | 'group'>;
type SchemaLeafFieldContext = {
key: string;
path: string;
pathPrefix: string;
schema: Record<string, any> | null;
ui: SchemaFieldUiMeta;
};
export type SchemaFieldGroup<TField, TRichContent = never> = {
key: string;
legend: string;
fields: TField[];
richContentFields: TRichContent[];
};
export function toSchemaFieldUiMeta(
schema: Record<string, any> | 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<string, any> | null | undefined, path: string): SchemaFieldUiMeta {
if (!root) return toSchemaFieldUiMeta(null);
let current: Record<string, any> | 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<string, any>, root);
} else {
const properties = resolved?.properties as Record<string, unknown> | undefined;
if (!properties || !properties[segment]) return toSchemaFieldUiMeta(null, inheritedUi);
current = resolveSchemaRef(properties[segment] as Record<string, any>, root);
}
const nextUi = toSchemaFieldUiMeta(current, inheritedUi);
inheritedUi = {
visibleWhen: nextUi.visibleWhen,
enabledWhen: nextUi.enabledWhen,
group: nextUi.group
};
}
return toSchemaFieldUiMeta(current, inheritedUi);
}
export function collectSchemaLeafFields<T>(
root: Record<string, any> | null | undefined,
mapLeaf: (context: SchemaLeafFieldContext) => T | null,
options?: {
includeArrays?: boolean;
shouldSkip?: (context: { key: string; path: string; schema: Record<string, any> | null }) => boolean;
}
): T[] {
if (!root) return [];
const fields: T[] = [];
const seen = new Set<string>();
const walk = (
node: Record<string, any>,
pathPrefix: string,
inheritedUi?: SchemaUiInheritance
) => {
const resolved = resolveSchemaRef(node, root);
if (!resolved || typeof resolved !== 'object') return;
const properties = resolved.properties as Record<string, any> | undefined;
if (!properties) return;
for (const [key, childSchema] of Object.entries(properties)) {
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, 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<string, any>, 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<TField extends { path: string }, TRichContent extends { path: string }>(
params: {
fields: TField[];
richContentFields?: TRichContent[];
resolveGroupLabel: (path: string) => string | null;
resolveLegend?: (groupLabel: string) => string;
}
): {
rootFields: TField[];
rootRichContentFields: TRichContent[];
groups: Array<SchemaFieldGroup<TField, TRichContent>>;
} {
const grouped = new Map<string, SchemaFieldGroup<TField, TRichContent>>();
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<string, any> | null | undefined,
path: string,
config: Record<string, any> | 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<string, any> | null | undefined,
path: string,
config: Record<string, any> | 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<string, any> | 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<string, any> | 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<string, any> | 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<string, unknown>)['collection'];
const valueField = (raw as Record<string, unknown>)['valueField'];
const labelField = (raw as Record<string, unknown>)['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<string, any> | null | undefined,
path: string,
root: Record<string, any> | 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 }];
}