diff --git a/src/app/layouts/tasks-executor/tasks-executor.html b/src/app/layouts/tasks-executor/tasks-executor.html
index 0483fae..c5e2a60 100644
--- a/src/app/layouts/tasks-executor/tasks-executor.html
+++ b/src/app/layouts/tasks-executor/tasks-executor.html
@@ -2,7 +2,7 @@
0"
- [class.container-node__dropzone--filled]="subFlowBlockCount > 0"
- (dragover)="onDropZoneDragOver($event)"
+ [class.container-node__dropzone--filled]="flowField.blockCount > 0"
+ (dragover)="onDropZoneDragOver($event, flowField)"
(dragleave)="onDropZoneDragLeave($event)"
- (drop)="onDropZoneDrop($event)">
- @if (replaceConfirmOpen) {
+ (drop)="onDropZoneDrop($event, flowField)">
+ @if (flowField.replaceConfirmOpen) {
-
Replace current subflow?
+
Replace current {{ flowField.label }}?
The existing embedded flow will be removed and replaced by the dropped selection.
-
+
} @else if (isAssigning) {
@@ -375,6 +376,12 @@
Updating container...
} @else {
+
diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts
index 4699411..0b31063 100644
--- a/src/app/shared/nodes/container-node/container-node.ts
+++ b/src/app/shared/nodes/container-node/container-node.ts
@@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, HostListener, Input, OnDestroy, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
-import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
+import { currentFlowPortValueKind, flowValueKindLabel, FlowData, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
import { NodeSettingField, NodeSettingOption, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { ContainersService } from '@services/containers/containers';
import { FieldRetriever } from '@services/retriever/field-retriever';
@@ -14,6 +14,13 @@ import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag';
import { firstValueFrom } from 'rxjs';
import { extractSchemaRequirements, SchemaRequirements } from '../schema-requirements';
import { evaluateUiConditionRule, getValueByPath, parentPath, pathToLabel, resolveNodeIcon, resolveSchemaPath, splitTemplatedTextParts, valueToDisplayString } from '../node-utility';
+import {
+ collectSchemaFlowDataFields,
+ flowDataNodeCount,
+ isFlowDataFieldPath,
+ normalizeFlowDataValue,
+ type SchemaFlowDataFieldDefinition
+} from '../flow-data-schema-fields';
import {
buildSchemaEditableFieldDefinitions,
buildSchemaFieldViewModel,
@@ -56,12 +63,11 @@ type ContainerFieldGroupView = SchemaDisplayGroup
;
type ContainerDisplaySection = SchemaDisplaySection;
-type StructuredRetrieverConfig = {
- retrieverName: string;
- retrieverUrl: string;
- validationUrl: string | null;
- structuredData: boolean;
- requiresAuth: boolean;
+type ContainerFlowFieldView = SchemaFlowDataFieldDefinition & {
+ flow: FlowData | null;
+ blockCount: number;
+ replaceConfirmOpen: boolean;
+ importLoading: boolean;
};
@Component({
@@ -87,9 +93,10 @@ export class ContainerNodeComponent implements OnDestroy {
private containerSchema: Record | null = null;
private schemaRequirements: SchemaRequirements = { required: [], requiredObjects: [], conditional: [] };
private containerFieldDefinitions: ContainerFieldDefinition[] = [];
+ private containerFlowFieldDefinitions: SchemaFlowDataFieldDefinition[] = [];
deleteConfirmOpen = false;
- replaceConfirmSelection: string[] | null = null;
- importLoading = false;
+ replaceConfirmSelection: { path: string; selection: string[] } | null = null;
+ importLoadingPath: string | null = null;
private importErrorMessage: string | null = null;
parameterFields: ContainerFieldView[] = [];
parameterFieldGroups: ContainerFieldGroupView[] = [];
@@ -313,37 +320,25 @@ export class ContainerNodeComponent implements OnDestroy {
return this.editorState.selectedBlockIds().filter((id) => id !== this.blockId).length;
}
- get subFlow(): FlowData | null {
- const value = this.configuration?.['subFlow'];
- if (!value || typeof value !== 'object') return null;
- const candidate = value as Record;
- const blocks = this.normalizeSubFlowBlocks(candidate['blocks']);
- const containers = this.normalizeSubFlowContainers(candidate['containers']);
- const connections = Array.isArray(candidate['connections'])
- ? candidate['connections'].filter((item): item is FlowData['connections'][number] => !!item && typeof item === 'object')
- : [];
- const dependencies = Array.isArray(candidate['dependencies'])
- ? candidate['dependencies'].filter((item): item is FlowData['dependencies'][number] => !!item && typeof item === 'object')
- : [];
-
- if (!blocks.length && !containers.length && !connections.length && !dependencies.length) {
- return null;
- }
-
- return {
- blocks,
- containers,
- connections,
- dependencies
- };
- }
-
- get subFlowBlockCount() {
- return (this.subFlow?.blocks?.length ?? 0) + (this.subFlow?.containers?.length ?? 0);
+ get flowFields(): ContainerFlowFieldView[] {
+ return this.resolveFlowFieldDefinitions().map((definition) => {
+ const flow = this.flowAtPath(definition.path);
+ return {
+ ...definition,
+ flow,
+ blockCount: flowDataNodeCount(flow),
+ replaceConfirmOpen: this.replaceConfirmSelection?.path === definition.path,
+ importLoading: this.importLoadingPath === definition.path
+ };
+ });
}
get replaceConfirmOpen() {
- return Array.isArray(this.replaceConfirmSelection) && this.replaceConfirmSelection.length > 0;
+ return !!this.replaceConfirmSelection?.selection.length;
+ }
+
+ get hasFlowDropzones() {
+ return this.flowFields.length > 0;
}
get assignmentErrorMessage() {
@@ -358,6 +353,8 @@ export class ContainerNodeComponent implements OnDestroy {
get missingRequiredParams() {
const config = this.configuration ?? {};
+ const flowFieldDefinitions = this.resolveFlowFieldDefinitions();
+ const flowFieldPaths = new Set(flowFieldDefinitions.map((field) => field.path));
const requiredFields = [
...this.schemaRequirements.required,
...this.schemaRequirements.conditional.filter((field) =>
@@ -370,21 +367,25 @@ export class ContainerNodeComponent implements OnDestroy {
return requiredFields
.filter((field, index, fields) => fields.findIndex((candidate) => candidate.path === field.path) === index)
.filter((field) => field.path !== 'name')
- .filter((field) => field.path !== 'subFlow')
- .filter((field) => !field.path.startsWith('subFlow.'))
+ .filter((field) => !isFlowDataFieldPath(field.path, flowFieldDefinitions))
.filter((field) => this.isFieldEnabled(field.path, config))
.filter((field) => this.isMissingValue(getValueByPath(config, field.path)))
.map((field) => field.label)
.concat(
this.schemaRequirements.requiredObjects
.filter((field) => field.path !== 'name')
- .filter((field) => field.path !== 'subFlow')
- .filter((field) => !field.path.startsWith('subFlow.'))
+ .filter((field) => !isFlowDataFieldPath(field.path, flowFieldDefinitions))
.filter((field) => this.isFieldEnabled(field.path, config))
.filter((field) => this.isMissingValue(getValueByPath(config, field.path)))
.map((field) => field.label)
)
- .concat(this.subFlow ? [] : ['Subflow'])
+ .concat(
+ this.schemaRequirements.requiredObjects
+ .filter((field) => flowFieldPaths.has(field.path))
+ .filter((field) => this.isFieldEnabled(field.path, config))
+ .filter((field) => !this.flowAtPath(field.path))
+ .map((field) => field.label)
+ )
.filter((field, index, fields) => fields.indexOf(field) === index);
}
@@ -427,25 +428,25 @@ export class ContainerNodeComponent implements OnDestroy {
return this.toPortLabelParts(this.outputDisplayLabel(outputKey));
}
- async importSubflow(event?: Event) {
+ async importSubflow(flowField: ContainerFlowFieldView, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
- if (this.isReadonly || this.importLoading || this.replaceConfirmOpen) return;
+ if (this.isReadonly || this.importLoadingPath || this.replaceConfirmOpen) return;
- this.importLoading = true;
+ this.importLoadingPath = flowField.path;
this.importErrorMessage = null;
try {
- const retriever = await this.resolveStructuredRetrieverConfig();
+ const retriever = this.resolveStructuredRetrieverConfig(flowField);
if (!retriever || !retriever.structuredData) {
- this.importErrorMessage = 'Subflow import is not available for this container.';
+ this.importErrorMessage = `${flowField.label} import is not available for this container.`;
return;
}
const items = await firstValueFrom(
this.fieldRetriever.retrieveItems(
- retriever.retrieverName || this.typeName,
- 'subFlow',
+ retriever.blockType,
+ retriever.key,
{
context: 'CONTAINER',
validOnly: 'true',
@@ -495,12 +496,12 @@ export class ContainerNodeComponent implements OnDestroy {
return;
}
- await assignImportedSubflow(selectedItem.data, retriever.validationUrl);
+ await assignImportedSubflow(selectedItem.data, flowField.path, retriever.validationUrl);
this.refreshParameterFields();
} catch {
this.importErrorMessage = 'Failed to load importable flows.';
} finally {
- this.importLoading = false;
+ this.importLoadingPath = null;
}
}
@@ -591,16 +592,16 @@ export class ContainerNodeComponent implements OnDestroy {
await this.applyFieldValue(definition, currentValue !== true);
}
- onDropZoneDragOver(event: DragEvent) {
+ onDropZoneDragOver(event: DragEvent, flowField: ContainerFlowFieldView) {
if (this.isReadonly) return;
- if (!this.canAcceptSelectionDrop()) return;
+ if (!this.canAcceptSelectionDrop(flowField.path)) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
}
- onDropZoneDrop(event: DragEvent) {
+ onDropZoneDrop(event: DragEvent, flowField: ContainerFlowFieldView) {
if (this.isReadonly) return;
event.preventDefault();
event.stopPropagation();
@@ -609,13 +610,13 @@ export class ContainerNodeComponent implements OnDestroy {
const payload = this.parseDraggedSelection(raw);
if (!payload.length) return;
- if (this.subFlowBlockCount > 0) {
- this.replaceConfirmSelection = payload;
+ if (flowField.blockCount > 0) {
+ this.replaceConfirmSelection = { path: flowField.path, selection: payload };
this.editorState.stopDraggingSelectedBlocks();
return;
}
- this.assignSelectionToContainer(payload);
+ this.assignSelectionToContainer(flowField, payload);
}
onDropZoneDragLeave(_: DragEvent) {
@@ -623,16 +624,18 @@ export class ContainerNodeComponent implements OnDestroy {
this.editorState.stopDraggingSelectedBlocks();
}
- confirmReplaceSubflow(event?: Event) {
+ confirmReplaceSubflow(flowField: ContainerFlowFieldView, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
- const payload = this.replaceConfirmSelection;
+ const payload = this.replaceConfirmSelection?.path === flowField.path
+ ? this.replaceConfirmSelection.selection
+ : null;
this.replaceConfirmSelection = null;
if (!payload?.length) return;
- this.assignSelectionToContainer(payload);
+ this.assignSelectionToContainer(flowField, payload);
}
cancelReplaceSubflow(event?: Event) {
@@ -673,11 +676,11 @@ export class ContainerNodeComponent implements OnDestroy {
}
}
- openSubflowPreview(event?: Event) {
+ openSubflowPreview(flowField: ContainerFlowFieldView, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
- if (!this.subFlow) return;
- this.subflowPreview.open(this.subFlow, `${this.name} subflow`, this.name);
+ if (!flowField.flow) return;
+ this.subflowPreview.open(flowField.flow, `${this.name} ${flowField.label}`, this.name);
}
async openFieldPreview(field: ContainerFieldView, event?: Event) {
@@ -708,8 +711,49 @@ export class ContainerNodeComponent implements OnDestroy {
return String(this.data?.data?.typeName ?? 'GenericContainer');
}
- private canAcceptSelectionDrop() {
- return !this.isAssigning && !this.replaceConfirmOpen && this.selectedCount > 0;
+ private resolveFlowFieldDefinitions(): SchemaFlowDataFieldDefinition[] {
+ if (this.containerFlowFieldDefinitions.length) return this.containerFlowFieldDefinitions;
+
+ const config = this.configuration ?? {};
+ const configuredFields = Object.keys(config)
+ .filter((key) => normalizeFlowDataValue(config[key]))
+ .map((key) => this.fallbackSubflowDefinition(key, pathToLabel(key)));
+
+ if (configuredFields.length) {
+ return configuredFields;
+ }
+
+ if (!this.schemaReady) {
+ return [this.fallbackSubflowDefinition()];
+ }
+
+ return [];
+ }
+
+ private fallbackSubflowDefinition(path = 'subFlow', label = 'Subflow'): SchemaFlowDataFieldDefinition {
+ return {
+ path,
+ label,
+ retrieverBlockType: 'Flows',
+ retrieverKey: path,
+ retrieverUrl: null,
+ retrieverStructuredData: false,
+ retrieverDependsOn: [],
+ validationUrl: null,
+ validationType: null,
+ requiresAuth: false,
+ ui: getSchemaPathUiMeta(this.containerSchema, path)
+ };
+ }
+
+ private flowAtPath(path: string): FlowData | null {
+ return normalizeFlowDataValue(getValueByPath(this.configuration ?? {}, path));
+ }
+
+ private canAcceptSelectionDrop(path: string) {
+ return !this.isAssigning
+ && (!this.replaceConfirmOpen || this.replaceConfirmSelection?.path === path)
+ && this.selectedCount > 0;
}
private async openReadonlyTextDialog(label: string, value: string) {
@@ -731,12 +775,12 @@ export class ContainerNodeComponent implements OnDestroy {
});
}
- private assignSelectionToContainer(payload: string[]) {
+ private assignSelectionToContainer(flowField: ContainerFlowFieldView, payload: string[]) {
this.importErrorMessage = null;
const assign = this.data?.data?.assignSelectedBlocksToContainer;
if (typeof assign !== 'function' || !payload.length) return;
- void assign(payload);
+ void assign(payload, flowField.path, flowField.validationUrl);
this.editorState.stopDraggingSelectedBlocks();
}
@@ -780,64 +824,21 @@ export class ContainerNodeComponent implements OnDestroy {
return ports.find((item: any) => item?.name === key) ?? null;
}
- private normalizeSubFlowBlocks(raw: unknown): FlowBlock[] {
- if (!Array.isArray(raw)) return [];
-
- return raw
- .filter((item): item is Record => !!item && typeof item === 'object' && !Array.isArray(item))
- .map((item) => ({
- ...item,
- position: this.normalizePosition(item['position']),
- nodeFamily: 'block'
- })) as FlowBlock[];
- }
-
- private normalizeSubFlowContainers(raw: unknown): FlowContainer[] {
- if (!Array.isArray(raw)) return [];
-
- return raw
- .filter((item): item is Record => !!item && typeof item === 'object' && !Array.isArray(item))
- .map((item) => ({
- ...item,
- position: this.normalizePosition(item['position']),
- nodeFamily: 'container'
- })) as FlowContainer[];
- }
-
- private normalizePosition(raw: unknown): { x: number; y: number } | undefined {
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
- const value = raw as Record;
- const x = typeof value['x'] === 'number' ? value['x'] : Number(value['x']);
- const y = typeof value['y'] === 'number' ? value['y'] : Number(value['y']);
- if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined;
- return { x, y };
- }
-
- private async resolveStructuredRetrieverConfig(): Promise {
- const containerType = await this.containersService.getContainerType(this.typeName);
- const schema = containerType?.schema;
- const properties = schema?.['properties'];
- const propertySchema = properties && typeof properties === 'object' && !Array.isArray(properties)
- ? (properties as Record)['subFlow']
- : null;
-
- if (!propertySchema || typeof propertySchema !== 'object' || Array.isArray(propertySchema)) {
- return null;
- }
-
- const fieldSchema = propertySchema as Record;
- const retrieverUrl = typeof fieldSchema['x-retriever-url'] === 'string' ? fieldSchema['x-retriever-url'] : null;
- const retrieverName = typeof fieldSchema['x-retriever-name'] === 'string' ? fieldSchema['x-retriever-name'] : this.typeName;
- if (!retrieverUrl) return null;
+ private resolveStructuredRetrieverConfig(flowField: SchemaFlowDataFieldDefinition): {
+ blockType: string;
+ key: string;
+ retrieverUrl: string;
+ validationUrl: string | null;
+ structuredData: boolean;
+ } | null {
+ if (!flowField.retrieverUrl) return null;
return {
- retrieverName,
- retrieverUrl,
- validationUrl: typeof fieldSchema['x-retriever-validation-url'] === 'string'
- ? fieldSchema['x-retriever-validation-url']
- : null,
- structuredData: fieldSchema['x-retriever-structured-data'] === true,
- requiresAuth: fieldSchema['x-retriever-requires-auth'] === true
+ blockType: flowField.retrieverBlockType ?? this.typeName,
+ key: flowField.retrieverKey ?? flowField.path,
+ retrieverUrl: flowField.retrieverUrl,
+ validationUrl: flowField.validationUrl,
+ structuredData: flowField.retrieverStructuredData
};
}
@@ -849,6 +850,7 @@ export class ContainerNodeComponent implements OnDestroy {
const containerType = this.containersService.peekContainerType(this.typeName) ?? await this.containersService.getContainerType(this.typeName);
this.containerSchema = (containerType?.schema ?? null) as Record | null;
this.schemaRequirements = extractSchemaRequirements(this.containerSchema);
+ this.containerFlowFieldDefinitions = collectSchemaFlowDataFields(this.containerSchema);
this.containerFieldDefinitions = this.buildContainerFieldDefinitions(this.containerSchema);
this.refreshParameterFields();
} finally {
@@ -919,7 +921,7 @@ export class ContainerNodeComponent implements OnDestroy {
private buildContainerFieldDefinitions(schema: Record | null): ContainerFieldDefinition[] {
return buildSchemaEditableFieldDefinitions(schema, {
shouldSkip: ({ key, path }) =>
- key.startsWith('__') || path === 'name' || path === 'subFlow' || this.isContainerTypeField(path)
+ key.startsWith('__') || path === 'name' || isFlowDataFieldPath(path, this.resolveFlowFieldDefinitions()) || this.isContainerTypeField(path)
});
}
diff --git a/src/app/shared/nodes/flow-data-schema-fields.ts b/src/app/shared/nodes/flow-data-schema-fields.ts
new file mode 100644
index 0000000..ff644c2
--- /dev/null
+++ b/src/app/shared/nodes/flow-data-schema-fields.ts
@@ -0,0 +1,169 @@
+import { FlowBlock, FlowContainer, FlowData, FlowGlobalInput } from '@models/flow';
+import { orderedSchemaPropertyEntries, parentPath, resolveSchemaRef, schemaFieldLabel } from './node-utility';
+import { schemaRetrieverMeta, toSchemaFieldUiMeta, type SchemaFieldUiMeta, type SchemaRetrieverDependency } from './schema-driven-fields';
+
+export type SchemaFlowDataFieldDefinition = {
+ path: string;
+ label: string;
+ retrieverBlockType: string | null;
+ retrieverKey: string | null;
+ retrieverUrl: string | null;
+ retrieverStructuredData: boolean;
+ retrieverDependsOn: SchemaRetrieverDependency[];
+ validationUrl: string | null;
+ validationType: string | null;
+ requiresAuth: boolean;
+ ui: SchemaFieldUiMeta;
+};
+
+export function collectSchemaFlowDataFields(root: Record | null | undefined): SchemaFlowDataFieldDefinition[] {
+ if (!root) return [];
+
+ const fields: SchemaFlowDataFieldDefinition[] = [];
+ const seen = new Set();
+
+ const walk = (node: Record, pathPrefix: string) => {
+ const resolved = resolveSchemaRef(node, root);
+ if (!resolved || typeof resolved !== 'object') return;
+
+ for (const { key, schema } of orderedSchemaPropertyEntries(resolved, root)) {
+ if (!schema) continue;
+
+ const path = pathPrefix ? `${pathPrefix}.${key}` : key;
+ const fieldSchema = resolveSchemaRef(schema, root);
+ if (!fieldSchema || typeof fieldSchema !== 'object') continue;
+
+ if (isFlowDataSchema(fieldSchema, root)) {
+ if (!seen.has(path)) {
+ seen.add(path);
+ const retriever = schemaRetrieverMeta(fieldSchema, parentPath(path) ?? '');
+ fields.push({
+ path,
+ label: schemaFieldLabel(path, fieldSchema),
+ ...retriever,
+ validationUrl: resolveSubflowValidationUrl(fieldSchema),
+ validationType: toNonEmptyString(fieldSchema['x-subflow-validation-type']),
+ requiresAuth: fieldSchema['x-retriever-requires-auth'] === true,
+ ui: toSchemaFieldUiMeta(fieldSchema)
+ });
+ }
+ continue;
+ }
+
+ const hasChildren = !!fieldSchema['properties'] || fieldSchema['type'] === 'object';
+ if (hasChildren) {
+ walk(fieldSchema, path);
+ }
+ }
+ };
+
+ walk(root, '');
+ return fields;
+}
+
+function resolveSubflowValidationUrl(schema: Record): string | null {
+ const validationUrl = toNonEmptyString(schema['x-retriever-validation-url']);
+ const validationType = toNonEmptyString(schema['x-subflow-validation-type']);
+ if (!validationUrl) {
+ return validationType
+ ? `/containers/validate-subflow?type=${encodeURIComponent(validationType)}`
+ : null;
+ }
+
+ if (!validationType || hasQueryParam(validationUrl, 'type')) return validationUrl;
+
+ return `${validationUrl}${validationUrl.includes('?') ? '&' : '?'}type=${encodeURIComponent(validationType)}`;
+}
+
+function hasQueryParam(rawUrl: string, key: string): boolean {
+ const queryString = rawUrl.split('?', 2)[1];
+ if (!queryString) return false;
+ return new URLSearchParams(queryString).has(key);
+}
+
+function toNonEmptyString(value: unknown): string | null {
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
+}
+
+export function isFlowDataFieldPath(path: string, fields: Array<{ path: string }>): boolean {
+ return fields.some((field) => path === field.path || path.startsWith(`${field.path}.`));
+}
+
+export function normalizeFlowDataValue(raw: unknown): FlowData | null {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
+
+ const candidate = raw as Record;
+ const blocks = normalizeSubFlowBlocks(candidate['blocks']);
+ const containers = normalizeSubFlowContainers(candidate['containers']);
+ const connections = Array.isArray(candidate['connections'])
+ ? candidate['connections'].filter((item): item is FlowData['connections'][number] => !!item && typeof item === 'object')
+ : [];
+ const dependencies = Array.isArray(candidate['dependencies'])
+ ? candidate['dependencies'].filter((item): item is FlowData['dependencies'][number] => !!item && typeof item === 'object')
+ : [];
+ const globalInputs = Array.isArray(candidate['globalInputs'])
+ ? candidate['globalInputs'].filter((item): item is FlowGlobalInput => !!item && typeof item === 'object')
+ : undefined;
+
+ if (!blocks.length && !containers.length && !connections.length && !dependencies.length) {
+ return null;
+ }
+
+ return {
+ blocks,
+ containers,
+ connections,
+ dependencies,
+ ...(globalInputs ? { globalInputs } : {})
+ };
+}
+
+export function flowDataNodeCount(flowData: FlowData | null): number {
+ return (flowData?.blocks?.length ?? 0) + (flowData?.containers?.length ?? 0);
+}
+
+function isFlowDataSchema(schema: Record, root: Record): boolean {
+ const ref = schema['$ref'];
+ if (typeof ref === 'string' && ref.split('/').at(-1) === 'FlowData') return true;
+
+ const resolved = resolveSchemaRef(schema, root);
+ const properties = resolved?.['properties'];
+ if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return false;
+
+ return ['blocks', 'containers', 'connections'].every((key) =>
+ Object.prototype.hasOwnProperty.call(properties, key)
+ );
+}
+
+function normalizeSubFlowBlocks(raw: unknown): FlowBlock[] {
+ if (!Array.isArray(raw)) return [];
+
+ return raw
+ .filter((item): item is Record => !!item && typeof item === 'object' && !Array.isArray(item))
+ .map((item) => ({
+ ...item,
+ position: normalizePosition(item['position']),
+ nodeFamily: 'block'
+ })) as FlowBlock[];
+}
+
+function normalizeSubFlowContainers(raw: unknown): FlowContainer[] {
+ if (!Array.isArray(raw)) return [];
+
+ return raw
+ .filter((item): item is Record => !!item && typeof item === 'object' && !Array.isArray(item))
+ .map((item) => ({
+ ...item,
+ position: normalizePosition(item['position']),
+ nodeFamily: 'container'
+ })) as FlowContainer[];
+}
+
+function normalizePosition(raw: unknown): { x: number; y: number } | undefined {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
+ const value = raw as Record;
+ const x = typeof value['x'] === 'number' ? value['x'] : Number(value['x']);
+ const y = typeof value['y'] === 'number' ? value['y'] : Number(value['y']);
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined;
+ return { x, y };
+}
diff --git a/src/app/shared/nodes/schema-driven-fields.spec.ts b/src/app/shared/nodes/schema-driven-fields.spec.ts
index 6c8ead2..fbc47ab 100644
--- a/src/app/shared/nodes/schema-driven-fields.spec.ts
+++ b/src/app/shared/nodes/schema-driven-fields.spec.ts
@@ -1,3 +1,7 @@
+import {
+ collectSchemaFlowDataFields,
+ isFlowDataFieldPath
+} from './flow-data-schema-fields';
import {
buildOrderedSchemaDisplay,
buildSchemaEditableFieldDefinitions,
@@ -14,6 +18,69 @@ import {
} from './schema-driven-fields';
describe('schema-driven-fields', () => {
+ it('collects every schema-driven FlowData field', () => {
+ const fields = collectSchemaFlowDataFields({
+ type: 'object',
+ sharedDefinitions: {
+ FlowData: {
+ type: 'object',
+ properties: {
+ blocks: { type: 'array' },
+ containers: { type: 'array' },
+ connections: { type: 'array' },
+ dependencies: { type: 'array' }
+ }
+ }
+ },
+ properties: {
+ subFlow: {
+ $ref: '#/sharedDefinitions/FlowData',
+ 'x-ui-label': 'Internal Flow',
+ 'x-retriever-url': '/secure-retriever/Flows/subFlow/items',
+ 'x-retriever-structured-data': true,
+ 'x-retriever-validation-url': '/containers/validate-subflow',
+ 'x-subflow-validation-type': 'LOOP_BODY'
+ },
+ guardSubFlow: {
+ $ref: '#/sharedDefinitions/FlowData',
+ 'x-ui-label': 'Guard Flow',
+ 'x-retriever-url': '/secure-retriever/Flows/subFlow/items',
+ 'x-retriever-structured-data': true,
+ 'x-retriever-validation-url': '/containers/validate-subflow?type=LOOP_GUARD',
+ 'x-subflow-validation-type': 'LOOP_GUARD'
+ },
+ maxIterations: {
+ type: 'integer'
+ }
+ }
+ });
+
+ expect(fields.map((field) => ({
+ path: field.path,
+ label: field.label,
+ key: field.retrieverKey,
+ validationUrl: field.validationUrl,
+ validationType: field.validationType
+ }))).toEqual([
+ {
+ path: 'subFlow',
+ label: 'Internal Flow',
+ key: 'subFlow',
+ validationUrl: '/containers/validate-subflow?type=LOOP_BODY',
+ validationType: 'LOOP_BODY'
+ },
+ {
+ path: 'guardSubFlow',
+ label: 'Guard Flow',
+ key: 'subFlow',
+ validationUrl: '/containers/validate-subflow?type=LOOP_GUARD',
+ validationType: 'LOOP_GUARD'
+ }
+ ]);
+ expect(isFlowDataFieldPath('guardSubFlow.blocks', fields)).toBe(true);
+ expect(isFlowDataFieldPath('maxIterations', fields)).toBe(false);
+ });
+
it('parses retriever urls including required suffix', () => {
expect(parseSchemaRetrieverUrl('/retriever/LLM/providers')).toEqual({
blockType: 'LLM',
diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html
index b1ad74f..7072908 100644
--- a/src/app/shared/nodes/task-step-node/task-step-node.html
+++ b/src/app/shared/nodes/task-step-node/task-step-node.html
@@ -330,9 +330,11 @@
@if (hasViewableSubflow()) {
-
}
diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts
index abdd0df..b5dc3e2 100644
--- a/src/app/shared/nodes/task-step-node/task-step-node.ts
+++ b/src/app/shared/nodes/task-step-node/task-step-node.ts
@@ -2,13 +2,19 @@ import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
-import { BlockInteractionContract, BlockType, FlowBlock, FlowContainer, FlowData, FlowPort, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
+import { BlockInteractionContract, BlockType, FlowData, FlowPort, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog';
import { HumanInteractionDialogService } from '@services/dialogs/human-interaction-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
+import {
+ collectSchemaFlowDataFields,
+ isFlowDataFieldPath,
+ normalizeFlowDataValue,
+ type SchemaFlowDataFieldDefinition
+} from '../flow-data-schema-fields';
import {
type UiConditionRule,
evaluateUiConditionRule,
@@ -135,6 +141,7 @@ export class TaskStepNodeComponent {
private blockSchema: Record