From a258064eb515b2c46488259ccf457d1a3db1d8b1 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Mon, 20 Jul 2026 16:22:44 +0200 Subject: [PATCH] feat: add execution groups and schema-driven flow fields --- .../tasks-executor/tasks-executor.html | 2 +- .../layouts/tasks-executor/tasks-executor.ts | 64 ++++- src/app/models/nodes.ts | 6 +- src/app/models/task-execution.ts | 16 ++ .../containers/containers-call.spec.ts | 33 +++ .../retriever/field-retriever-call.fake.ts | 2 +- .../task-executions-call.base.ts | 4 +- .../task-executions-call.fake.ts | 102 ++++++- .../task-executions/task-executions-call.ts | 58 +++- .../task-executions/task-executions.ts | 28 +- .../nodes/container-node/container-node.css | 23 +- .../nodes/container-node/container-node.html | 30 ++- .../nodes/container-node/container-node.ts | 250 +++++++++--------- .../shared/nodes/flow-data-schema-fields.ts | 169 ++++++++++++ .../shared/nodes/schema-driven-fields.spec.ts | 67 +++++ .../nodes/task-step-node/task-step-node.html | 6 +- .../nodes/task-step-node/task-step-node.ts | 127 +++++---- .../tasks-executions-list.css | 151 ++++++++++- .../tasks-executions-list.html | 116 +++++--- .../tasks-executions-list.ts | 83 +++++- src/app/utilities/rete-editor.ts | 26 +- 21 files changed, 1065 insertions(+), 298 deletions(-) create mode 100644 src/app/shared/nodes/flow-data-schema-fields.ts 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 @@
diff --git a/src/app/layouts/tasks-executor/tasks-executor.ts b/src/app/layouts/tasks-executor/tasks-executor.ts index 71aae30..6f9ce55 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.ts +++ b/src/app/layouts/tasks-executor/tasks-executor.ts @@ -1,10 +1,11 @@ import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; -import { normalizeExecutionStatus, TaskExecution } from '@models/task-execution'; +import { normalizeExecutionStatus, TaskExecution, TaskExecutionGroup } from '@models/task-execution'; import { ActivatedRoute, Router } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop'; import { TaskExecutionListItem, + TaskExecutionGroupListItem, TasksExecutionsListComponent } from '@shared/tasks-executions-list/tasks-executions-list'; import { TaskExecutionViewerComponent } from '@shared/task-execution-viewer/task-execution-viewer'; @@ -33,18 +34,11 @@ export class TasksExecutor { ); readonly executionDetails = this.taskExecutionsService.taskExecutions; + readonly executionGroups = this.taskExecutionsService.taskExecutionGroups; readonly pendingExecutionCreation = this.taskExecutionsService.pendingExecutionCreation; - readonly executions = computed(() => - this.executionDetails().map((execution) => ({ - id: execution.id, - title: execution.name, - flowName: execution.name, - status: normalizeExecutionStatus(execution.context.status), - startedAt: this.formatDateTime(execution.creationTime), - duration: this.formatDuration(execution.context.startTime ?? null, execution.context.endTime ?? null), - simulated: execution.interactionSimulationEnabled === true - })) + readonly groups = computed(() => + this.executionGroups().map((group) => this.toGroupListItem(group)) ); readonly selectedExecutionId = signal(null); @@ -88,8 +82,8 @@ export class TasksExecutor { effect(() => { if (this.requestedExecutionId()) return; if (this.selectedExecutionId()) return; - const first = this.executions()[0]; - if (first) this.selectedExecutionId.set(first.id); + const first = this.groups()[0]; + if (first?.latestExecutionId) this.selectedExecutionId.set(first.latestExecutionId); }); } @@ -118,6 +112,50 @@ export class TasksExecutor { }); } + rerunExecution(id: string) { + this.taskExecutionsService.rerunExecution(id).subscribe({ + next: (execution) => this.selectExecution(execution.id), + error: (err) => console.error('Error rerunning execution:', err) + }); + } + + private toGroupListItem(group: TaskExecutionGroup): TaskExecutionGroupListItem { + const executions = (group.executions ?? []).map((execution, index) => + this.toExecutionListItem(execution, index + 1) + ); + const latestExecution = executions.find((execution) => execution.id === group.latestExecutionId) + ?? executions[executions.length - 1] + ?? null; + + return { + id: group.id, + sourceFlowId: group.sourceFlowId, + name: group.name, + executionCount: group.executionCount, + lastExecutionTime: group.lastExecutionTime, + lastExecutionTimeLabel: this.formatDateTime(group.lastExecutionTime), + latestExecutionId: group.latestExecutionId || latestExecution?.id || '', + latestStatus: latestExecution?.status ?? 'CREATED', + latestRunNumber: latestExecution?.runNumber ?? null, + executions + }; + } + + private toExecutionListItem(execution: TaskExecution, fallbackRunNumber: number): TaskExecutionListItem { + return { + id: execution.id, + title: execution.name, + flowName: String(execution.sourceFlowId ?? execution.flowId ?? execution.name ?? ''), + status: normalizeExecutionStatus(execution.context.status), + startedAt: this.formatDateTime(execution.creationTime), + creationTime: execution.creationTime, + runNumber: typeof execution.runNumber === 'number' ? execution.runNumber : fallbackRunNumber, + rerunOfExecutionId: execution.rerunOfExecutionId ?? null, + duration: this.formatDuration(execution.context.startTime ?? null, execution.context.endTime ?? null), + simulated: execution.interactionSimulationEnabled === true + }; + } + private formatDateTime(timestamp: number): string { const date = new Date(timestamp); const yyyy = date.getFullYear(); diff --git a/src/app/models/nodes.ts b/src/app/models/nodes.ts index bb3558c..82a440c 100644 --- a/src/app/models/nodes.ts +++ b/src/app/models/nodes.ts @@ -4,9 +4,9 @@ import { FlowData, FlowNode } from "./flow"; export type HFNodeData = FlowNode & { deleteNode?: () => Promise; replaceWithCreatedNode?: (block: FlowNode) => Promise; - assignSelectedBlocksToContainer?: (blockIds?: string[]) => Promise; - assignImportedSubflow?: (subFlow: FlowData, validationUrl?: string | null) => Promise; - clearContainerSubflow?: () => Promise; + assignSelectedBlocksToContainer?: (blockIds?: string[], targetPath?: string, validationUrl?: string | null) => Promise; + assignImportedSubflow?: (subFlow: FlowData, targetPath?: string, validationUrl?: string | null) => Promise; + clearContainerSubflow?: (targetPath?: string) => Promise; cloneNode?: () => Promise; __readonly?: boolean; __needsServerCreate?: boolean; diff --git a/src/app/models/task-execution.ts b/src/app/models/task-execution.ts index da5647f..0331e4a 100644 --- a/src/app/models/task-execution.ts +++ b/src/app/models/task-execution.ts @@ -21,6 +21,10 @@ export type TaskExecution = { id: string; name: string; creationTime: number; + flowId?: string | null; + sourceFlowId?: string | null; + runNumber?: number | null; + rerunOfExecutionId?: string | null; context: TaskExecutionContext; interactionSimulationEnabled?: boolean; simulationAvailable?: boolean; @@ -33,6 +37,18 @@ export type TaskExecution = { missingGlobalInputKeys?: string[]; }; +export type TaskExecutionGroup = { + id: string; + sourceFlowId: string; + name: string; + firstExecutionId: string; + latestExecutionId: string; + creationTime: number; + lastExecutionTime: number; + executionCount: number; + executions: TaskExecution[]; +}; + export type TaskExecutionAuthorizationRequirement = { key: string; provider: string; diff --git a/src/app/services/containers/containers-call.spec.ts b/src/app/services/containers/containers-call.spec.ts index 7a3fa7b..2dc1a5a 100644 --- a/src/app/services/containers/containers-call.spec.ts +++ b/src/app/services/containers/containers-call.spec.ts @@ -144,6 +144,39 @@ describe('ContainersCallService', () => { ); }); + it('posts subflow validation to the field-specific validation url including query params', async () => { + const request = firstValueFrom(service.validateContainerSubflow({ + blocks: [], + containers: [], + connections: [], + dependencies: [] + }, '/containers/validate-subflow?type=LOOP_GUARD')); + + const validationRequest = httpMock.expectOne(`${environment.apiUrl}/containers/validate-subflow?type=LOOP_GUARD`); + expect(validationRequest.request.method).toBe('POST'); + expect(validationRequest.request.body).toEqual({ + subFlow: { + blocks: [], + containers: [], + connections: [], + dependencies: [] + } + }); + validationRequest.flush({ + valid: true, + errors: [], + openInputs: [], + openOutputs: [] + }); + + await expect(request).resolves.toEqual({ + valid: true, + errors: [], + openInputs: [], + openOutputs: [] + }); + }); + it('fills missing required boolean fields using descriptor schema defaults', async () => { const typesRequest = firstValueFrom(service.retrieveAllContainerTypes()); diff --git a/src/app/services/retriever/field-retriever-call.fake.ts b/src/app/services/retriever/field-retriever-call.fake.ts index b140380..4f05a13 100644 --- a/src/app/services/retriever/field-retriever-call.fake.ts +++ b/src/app/services/retriever/field-retriever-call.fake.ts @@ -115,7 +115,7 @@ export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase context?: Record, retrieverUrl?: string | null ): Observable[]> { - if (key === 'subFlow') { + if (key === 'subFlow' || retrieverUrl?.includes('/Flows/subFlow/items')) { return of(this.subFlowItems as unknown as RetrieverStructuredItem[]); } if ( diff --git a/src/app/services/task-executions/task-executions-call.base.ts b/src/app/services/task-executions/task-executions-call.base.ts index 8fdd755..5255690 100644 --- a/src/app/services/task-executions/task-executions-call.base.ts +++ b/src/app/services/task-executions/task-executions-call.base.ts @@ -1,11 +1,13 @@ import { LLMDescriptor } from '@models/flow'; -import { ExecutionEventLogEntry, TaskExecution } from '@models/task-execution'; +import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution'; import { Observable } from 'rxjs'; export abstract class TaskExecutionsCallServiceBase { abstract retrieveAllTaskExecutions(): Observable; + abstract retrieveTaskExecutionGroups(): Observable; abstract retrieveExecutionEvents(executionId: string): Observable; abstract createTaskExecution(flowId: string): Observable; + abstract rerunTaskExecution(executionId: string): Observable; abstract deleteTaskExecution(executionId: string): Observable; abstract startTaskExecution(executionId: string): Observable; abstract simulateTaskExecution(executionId: string, simulator: LLMDescriptor): Observable; diff --git a/src/app/services/task-executions/task-executions-call.fake.ts b/src/app/services/task-executions/task-executions-call.fake.ts index e2268f4..aafb692 100644 --- a/src/app/services/task-executions/task-executions-call.fake.ts +++ b/src/app/services/task-executions/task-executions-call.fake.ts @@ -1,5 +1,5 @@ import { LLMDescriptor } from '@models/flow'; -import { ExecutionEventLogEntry, TaskExecution } from '@models/task-execution'; +import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution'; import { Observable, of } from 'rxjs'; import { TaskExecutionsCallServiceBase } from './task-executions-call.base'; @@ -451,16 +451,24 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase return of(this.data.map((execution) => this.withSimulationAvailability(execution))); } + override retrieveTaskExecutionGroups(): Observable { + return of(this.buildExecutionGroups()); + } + override retrieveExecutionEvents(executionId: string): Observable { const execution = this.findExecution(executionId); return of(this.buildExecutionEvents(execution)); } override createTaskExecution(flowId: string): Observable { + const sourceFlowId = flowId || 'Execution'; const execution: TaskExecution = { id: crypto.randomUUID(), - name: flowId || 'Execution', + name: sourceFlowId, creationTime: Date.now(), + flowId: sourceFlowId, + sourceFlowId, + runNumber: this.nextRunNumber(sourceFlowId), simulationAvailable: false, context: { inputs: {}, @@ -478,6 +486,38 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase return of(this.withSimulationAvailability(execution)); } + override rerunTaskExecution(executionId: string): Observable { + const source = this.findExecution(executionId); + const sourceFlowId = this.executionSourceFlowId(source); + const now = Date.now(); + const execution: TaskExecution = { + ...this.cloneExecution(source), + id: crypto.randomUUID(), + name: source.name, + creationTime: now, + flowId: source.flowId ?? sourceFlowId, + sourceFlowId, + runNumber: this.nextRunNumber(sourceFlowId), + rerunOfExecutionId: source.id, + interactionSimulationEnabled: false, + interactionSimulationDescriptor: undefined, + context: { + ...this.cloneExecution(source).context, + inputs: {}, + result: {}, + partialResult: {}, + startTime: null, + endTime: null, + errors: {}, + warnings: {}, + status: 'CREATED', + waitingSteps: [] + } + }; + this.data.unshift(execution); + return of(this.withSimulationAvailability(execution)); + } + override deleteTaskExecution(executionId: string): Observable { const index = this.data.findIndex((item) => item.id === executionId); if (index >= 0) { @@ -695,6 +735,64 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase return execution; } + private buildExecutionGroups(): TaskExecutionGroup[] { + const grouped = new Map(); + for (const execution of this.data.map((item) => this.withSimulationAvailability(item))) { + const sourceFlowId = this.executionSourceFlowId(execution); + if (!grouped.has(sourceFlowId)) grouped.set(sourceFlowId, []); + grouped.get(sourceFlowId)!.push(execution); + } + + return Array.from(grouped.entries()) + .map(([sourceFlowId, executions]) => { + const ordered = [...executions].sort((left, right) => + (this.executionRunNumber(left) - this.executionRunNumber(right)) + || ((left.creationTime ?? 0) - (right.creationTime ?? 0)) + ); + const firstExecution = ordered[0]; + const latestExecution = ordered[ordered.length - 1]; + return { + id: sourceFlowId, + sourceFlowId, + name: latestExecution?.name ?? sourceFlowId, + firstExecutionId: firstExecution?.id ?? '', + latestExecutionId: latestExecution?.id ?? '', + creationTime: firstExecution?.creationTime ?? 0, + lastExecutionTime: latestExecution?.creationTime ?? 0, + executionCount: ordered.length, + executions: ordered + }; + }) + .sort((left, right) => right.lastExecutionTime - left.lastExecutionTime); + } + + private executionSourceFlowId(execution: TaskExecution): string { + return String(execution.sourceFlowId ?? execution.flowId ?? execution.name ?? execution.id).trim() || execution.id; + } + + private executionRunNumber(execution: TaskExecution): number { + const value = Number(execution.runNumber); + return Number.isFinite(value) && value > 0 ? value : 1; + } + + private nextRunNumber(sourceFlowId: string): number { + const runs = this.data + .filter((execution) => this.executionSourceFlowId(execution) === sourceFlowId) + .map((execution) => this.executionRunNumber(execution)); + return runs.length ? Math.max(...runs) + 1 : 1; + } + + private cloneExecution(execution: TaskExecution): TaskExecution { + if (typeof globalThis.structuredClone === 'function') { + try { + return globalThis.structuredClone(execution); + } catch { + // Fall back to JSON clone for plain fake execution data. + } + } + return JSON.parse(JSON.stringify(execution)) as TaskExecution; + } + private withSimulationAvailability(execution: TaskExecution): TaskExecution { execution.simulationAvailable = Object.values(execution.context.steps ?? {}).some((step) => { const typeName = String(step.node?.typeName ?? '').trim(); diff --git a/src/app/services/task-executions/task-executions-call.ts b/src/app/services/task-executions/task-executions-call.ts index b2015ad..5c47680 100644 --- a/src/app/services/task-executions/task-executions-call.ts +++ b/src/app/services/task-executions/task-executions-call.ts @@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { inject } from '@angular/core'; import { environment } from '@environment'; import { LLMDescriptor } from '@models/flow'; -import { ExecutionEventLogEntry, TaskExecution } from '@models/task-execution'; +import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution'; import { map, Observable } from 'rxjs'; import { TaskExecutionsCallServiceBase } from './task-executions-call.base'; @@ -15,6 +15,12 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { ); } + override retrieveTaskExecutionGroups(): Observable { + return this.http.get(`${environment.apiUrl}/executions/groups`).pipe( + map((raw) => Array.isArray(raw) ? raw.map((item) => this.mapExecutionGroup(item)) : []) + ); + } + override retrieveExecutionEvents(executionId: string): Observable { return this.http.get(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/events`); } @@ -25,6 +31,12 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { ); } + override rerunTaskExecution(executionId: string): Observable { + return this.http.post(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/rerun`, null).pipe( + map((raw) => this.mapExecution(raw)) + ); + } + override deleteTaskExecution(executionId: string): Observable { return this.http.delete(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}`); } @@ -191,6 +203,36 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { }; } + private mapExecutionGroup(raw: unknown): TaskExecutionGroup { + const group = (raw ?? {}) as Partial & Record; + const executions = Array.isArray(group['executions']) + ? group['executions'].map((item) => this.mapExecution(item)) + : []; + const latestExecution = executions.find((execution) => execution.id === group['latestExecutionId']) + ?? executions[executions.length - 1] + ?? null; + const firstExecution = executions.find((execution) => execution.id === group['firstExecutionId']) + ?? executions[0] + ?? null; + const sourceFlowId = this.toNonEmptyString(group['sourceFlowId']) + ?? this.toNonEmptyString(latestExecution?.sourceFlowId) + ?? this.toNonEmptyString(latestExecution?.flowId) + ?? this.toNonEmptyString(group['id']) + ?? ''; + + return { + id: this.toNonEmptyString(group['id']) ?? sourceFlowId, + sourceFlowId, + name: this.toNonEmptyString(group['name']) ?? latestExecution?.name ?? sourceFlowId, + firstExecutionId: this.toNonEmptyString(group['firstExecutionId']) ?? firstExecution?.id ?? '', + latestExecutionId: this.toNonEmptyString(group['latestExecutionId']) ?? latestExecution?.id ?? '', + creationTime: this.toTimestamp(group['creationTime'], firstExecution?.creationTime ?? 0), + lastExecutionTime: this.toTimestamp(group['lastExecutionTime'], latestExecution?.creationTime ?? 0), + executionCount: this.toNumber(group['executionCount'], executions.length), + executions + }; + } + private normalizeGlobalInputValues(raw: unknown): Record { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; return { ...(raw as Record) }; @@ -217,4 +259,18 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { return acc; }, {}); } + + private toNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; + } + + private toTimestamp(value: unknown, fallback: number): number { + const timestamp = typeof value === 'number' ? value : Number(value); + return Number.isFinite(timestamp) ? timestamp : fallback; + } + + private toNumber(value: unknown, fallback: number): number { + const number = typeof value === 'number' ? value : Number(value); + return Number.isFinite(number) ? number : fallback; + } } diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts index e85e966..3dde227 100644 --- a/src/app/services/task-executions/task-executions.ts +++ b/src/app/services/task-executions/task-executions.ts @@ -1,7 +1,7 @@ import { DestroyRef, inject, Injectable, signal } from '@angular/core'; import { environment } from '@environment'; import { LLMDescriptor } from '@models/flow'; -import { ExecutionEventLogEntry, getExecutionStatusGroup, TaskExecution } from '@models/task-execution'; +import { ExecutionEventLogEntry, getExecutionStatusGroup, TaskExecution, TaskExecutionGroup } from '@models/task-execution'; import { catchError, finalize, Observable, tap, throwError } from 'rxjs'; import { TaskExecutionsCallServiceBase } from './task-executions-call.base'; @@ -16,9 +16,11 @@ export class TaskExecutionsService { private refreshInFlight = false; private pollTimer: ReturnType | null = null; private _taskExecutions = signal([]); + private _taskExecutionGroups = signal([]); private _pendingExecutionCreation = signal(false); taskExecutions = this._taskExecutions.asReadonly(); + taskExecutionGroups = this._taskExecutionGroups.asReadonly(); pendingExecutionCreation = this._pendingExecutionCreation.asReadonly(); init() { @@ -32,11 +34,13 @@ export class TaskExecutionsService { if (this.refreshInFlight) return; this.refreshInFlight = true; - this.taskExecutionsCallService.retrieveAllTaskExecutions().pipe( + this.taskExecutionsCallService.retrieveTaskExecutionGroups().pipe( finalize(() => { this.refreshInFlight = false; }) - ).subscribe((taskExecutions) => { + ).subscribe((groups) => { + const taskExecutions = this.flattenGroups(groups); + this._taskExecutionGroups.set([...groups]); this._taskExecutions.set([...taskExecutions]); this.updatePollingState(taskExecutions); }); @@ -62,6 +66,18 @@ export class TaskExecutionsService { ); } + rerunExecution(executionId: string) { + this._pendingExecutionCreation.set(true); + return this.taskExecutionsCallService.rerunTaskExecution(executionId).pipe( + finalize(() => this._pendingExecutionCreation.set(false)), + tap(() => this.refresh()), + catchError((err) => { + console.error('Rerun execution failed', err); + return throwError(() => err); + }) + ); + } + deleteExecution(executionId: string) { return this.withRefreshAndErrorHandling( this.taskExecutionsCallService.deleteTaskExecution(executionId), @@ -199,6 +215,12 @@ export class TaskExecutionsService { this.stopPolling(); } + private flattenGroups(groups: TaskExecutionGroup[]): TaskExecution[] { + return groups + .flatMap((group) => group.executions ?? []) + .sort((left, right) => (right.creationTime ?? 0) - (left.creationTime ?? 0)); + } + private startPolling() { if (this.pollTimer) return; this.pollTimer = setInterval(() => { diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css index a3ad869..64ed55c 100644 --- a/src/app/shared/nodes/container-node/container-node.css +++ b/src/app/shared/nodes/container-node/container-node.css @@ -608,6 +608,27 @@ flex-wrap: wrap; } +.container-node__dropzone-title { + align-self: center; + max-width: 100%; + font-size: 17px; + line-height: 1.2; + font-weight: 900; + color: #db2777; + text-align: center; + overflow-wrap: anywhere; +} + +.container-node__dropzone-description { + max-width: 320px; + align-self: center; + font-size: 12px; + line-height: 1.45; + font-weight: 600; + color: #334155; + text-align: center; +} + .container-node__replace-confirm { display: flex; flex-direction: column; @@ -695,7 +716,7 @@ } .container-node__dropzone-empty { - font-size: 16px; + font-size: 14px; } .container-node__subflow-view { diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index e03d71f..7206654 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -353,20 +353,21 @@
} + @for (flowField of flowFields; track flowField.path) {
- @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 { +
{{ flowField.label }}
+ @if (flowField.ui.tip) { +
+ {{ flowField.ui.tip }} +
+ }
Drag a flow here or import from flow
@@ -382,17 +389,17 @@ Use the selection box in the editor, then drag the floating selection badge into this area. } - @if (subFlowBlockCount > 0 && !replaceConfirmOpen && !isAssigning) { + @if (flowField.blockCount > 0 && !flowField.replaceConfirmOpen && !isAssigning) {
-
} - @if (!isReadonly && !replaceConfirmOpen && !isAssigning) { + @if (!isReadonly && !flowField.replaceConfirmOpen && !isAssigning) {
-
} + } @if (assignmentErrorMessage) {
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 | null = null; private blockDescriptor: BlockType | null = null; private arrayFieldDefinitions: ArrayFieldDefinition[] = []; + private flowFieldDefinitions: SchemaFlowDataFieldDefinition[] = []; ngOnInit() { this.outputs = []; @@ -305,13 +312,19 @@ export class TaskStepNodeComponent { } hasViewableSubflow(): boolean { - const subFlow = this.subFlow(); - return !!subFlow && ( - (subFlow.blocks?.length ?? 0) > 0 || - (subFlow.containers?.length ?? 0) > 0 || - (subFlow.connections?.length ?? 0) > 0 || - (subFlow.dependencies?.length ?? 0) > 0 - ); + return this.viewableSubflows().length > 0; + } + + viewableSubflows(): Array<{ path: string; label: string; flow: FlowData }> { + if (!this.isContainerNode()) return []; + + return this.resolveFlowFieldDefinitions() + .map((field) => ({ + path: field.path, + label: field.label, + flow: this.flowAtPath(field.path) + })) + .filter((item): item is { path: string; label: string; flow: FlowData } => item.flow != null); } hasExecutionDependencyPorts(): boolean { @@ -443,13 +456,15 @@ export class TaskStepNodeComponent { }); } - openSubflowPreview(event?: Event) { + openSubflowPreview(path?: string, event?: Event) { event?.preventDefault(); event?.stopPropagation(); - const subFlow = this.subFlow(); + const candidatePath = path ?? this.viewableSubflows()[0]?.path; + const subFlow = candidatePath ? this.flowAtPath(candidatePath) : null; if (!subFlow) return; const sourceName = this.name || this.nodeTitle(); - this.subflowPreview.open(subFlow, `${sourceName} subflow`, sourceName); + const label = this.resolveFlowFieldDefinitions().find((field) => field.path === candidatePath)?.label ?? 'Subflow'; + this.subflowPreview.open(subFlow, `${sourceName} ${label}`, sourceName); } openFieldPreview(field: DisplayField, event?: Event) { @@ -589,6 +604,7 @@ export class TaskStepNodeComponent { TaskStepNodeComponent.globalFieldUiMetaCache.delete(typeKey); TaskStepNodeComponent.globalFieldLabelCache.delete(typeKey); } + this.flowFieldDefinitions = collectSchemaFlowDataFields(this.blockSchema); this.arrayFieldDefinitions = this.extractArrayFieldDefinitions(this.blockSchema); this.rebuildDisplayState(); this.schemaReady = true; @@ -821,7 +837,7 @@ export class TaskStepNodeComponent { const path = pathPrefix ? `${pathPrefix}.${key}` : key; if (childResolved?.['type'] === 'array') { - if (shouldSkipSchemaField(key, childResolved) || key === 'name' || seen.has(path)) { + if (shouldSkipSchemaField(key, childResolved) || key === 'name' || isFlowDataFieldPath(path, this.resolveFlowFieldDefinitions()) || seen.has(path)) { continue; } seen.add(path); @@ -842,61 +858,44 @@ export class TaskStepNodeComponent { walk(schema, ''); return this.isContainerNode() - ? definitions.filter((definition) => !definition.path.startsWith('subFlow.')) + ? definitions.filter((definition) => !isFlowDataFieldPath(definition.path, this.resolveFlowFieldDefinitions())) : definitions; } - private subFlow(): FlowData | null { - if (!this.isContainerNode()) return null; + private resolveFlowFieldDefinitions(): SchemaFlowDataFieldDefinition[] { + if (this.flowFieldDefinitions.length) return this.flowFieldDefinitions; - const raw = this.blockConfiguration?.['subFlow']; - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; - - const candidate = raw 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 }; + const config = this.blockConfiguration ?? {}; + return Object.keys(config) + .filter((key) => normalizeFlowDataValue(config[key])) + .map((key) => ({ + path: key, + label: key === 'subFlow' ? 'Subflow' : pathToLabel(key), + retrieverBlockType: null, + retrieverKey: null, + retrieverUrl: null, + retrieverStructuredData: false, + retrieverDependsOn: [], + validationUrl: null, + validationType: null, + requiresAuth: false, + ui: { + widget: null, + acceptVariableAsPlaceholder: false, + structural: true, + bindableAsInput: false, + inputName: null, + inputType: null, + inputMultiple: null, + visibleWhen: [], + enabledWhen: [], + group: 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, - nodeFamily: 'block', - position: this.normalizePosition(item['position']) - })) 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, - nodeFamily: 'container', - position: this.normalizePosition(item['position']) - })) 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 flowAtPath(path: string): FlowData | null { + return normalizeFlowDataValue(this.getByPath(this.blockConfiguration ?? {}, path)); } private shouldHideConfigPath(path: string): boolean { @@ -911,8 +910,7 @@ export class TaskStepNodeComponent { return this.isContainerNode() && ( isContainerTypePath - || path === 'subFlow' - || path.startsWith('subFlow.') + || isFlowDataFieldPath(path, this.resolveFlowFieldDefinitions()) ); } @@ -1098,8 +1096,7 @@ export class TaskStepNodeComponent { } const config = this.blockConfiguration; - const subFlow = config?.['subFlow']; - if (subFlow && typeof subFlow === 'object' && !Array.isArray(subFlow)) { + if (config && Object.values(config).some((value) => normalizeFlowDataValue(value))) { return 'container'; } diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.css b/src/app/shared/tasks-executions-list/tasks-executions-list.css index d8da4e6..1ad8235 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.css +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.css @@ -64,11 +64,11 @@ text-align: center; } -.tasks-list-item { +.tasks-list-group { width: 100%; padding: 12px; border: 1px solid #cbd5e1; - border-radius: 14px; + border-radius: 8px; background: radial-gradient(circle at top right, rgba(16, 185, 129, 0.1), transparent 32%), linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); @@ -76,11 +76,12 @@ 0 10px 24px rgba(15, 23, 42, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.72); text-align: left; - cursor: pointer; + cursor: default; transition: transform 0.15s ease, border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; } -.tasks-list-item:hover { +.tasks-list-group:hover, +.tasks-list-group-expanded { transform: translateY(-3px); border-color: #60a5fa; background: @@ -89,11 +90,28 @@ box-shadow: 0 18px 28px rgba(37, 99, 235, 0.14); } -.tasks-list-item-meta { +.tasks-list-group-header { + width: 100%; + border: 0; + padding: 0; + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: start; + gap: 8px; + background: transparent; + text-align: left; + cursor: pointer; +} + +.tasks-list-group-chevron { + color: #2563eb; +} + +.tasks-list-group-main { min-width: 0; } -.tasks-list-item-title { +.tasks-list-group-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -102,7 +120,7 @@ color: #1e293b; } -.tasks-list-item-subtitle { +.tasks-list-group-subtitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -110,6 +128,28 @@ color: #64748b; } +.tasks-list-group-summary { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + flex-wrap: wrap; +} + +.tasks-list-count { + display: inline-flex; + align-items: center; + min-height: 20px; + border: 1px solid #bfdbfe; + border-radius: 999px; + background: #eff6ff; + color: #1d4ed8; + font-size: 10px; + font-weight: 700; + padding: 2px 8px; + white-space: nowrap; +} + .tasks-list-simulated-badge { display: inline-flex; align-items: center; @@ -125,6 +165,9 @@ } .tasks-list-status { + display: inline-flex; + align-items: center; + min-height: 20px; padding: 3px 9px; border-radius: 999px; border-width: 1px; @@ -133,7 +176,7 @@ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.52); } -.tasks-list-item-footer { +.tasks-list-group-meta { display: flex; align-items: center; justify-content: space-between; @@ -145,8 +188,98 @@ border-top: 1px solid rgba(148, 163, 184, 0.18); } -.tasks-list-item-actions { +.tasks-list-executions { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; +} + +.tasks-list-execution { + position: relative; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 10px; + background: rgba(255, 255, 255, 0.82); + cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; +} + +.tasks-list-execution:hover { + border-color: #93c5fd; + background: #f8fbff; + box-shadow: 0 10px 18px rgba(37, 99, 235, 0.1); +} + +.tasks-list-execution-selected { + border-color: #2563eb; + background: #eff6ff; +} + +.tasks-list-execution-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.tasks-list-execution-title { + display: flex; + align-items: center; + min-width: 0; + gap: 6px; +} + +.tasks-list-run { + flex: 0 0 auto; + font-size: 12px; + font-weight: 800; + color: #0f766e; +} + +.tasks-list-execution-id { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + color: #334155; +} + +.tasks-list-execution-details { display: flex; align-items: center; gap: 8px; + margin-top: 6px; + min-width: 0; + flex-wrap: wrap; + font-size: 11px; + color: #64748b; +} + +.tasks-list-execution-actions { + display: flex; + justify-content: flex-end; + gap: 4px; + margin-top: 6px; +} + +.tasks-list-latest { + width: 100%; + border: 1px solid #dbeafe; + border-radius: 8px; + padding: 8px 10px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 10px; + background: #f8fafc; + color: #2563eb; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.tasks-list-latest:hover { + background: #eff6ff; } diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.html b/src/app/shared/tasks-executions-list/tasks-executions-list.html index 0face29..ee2681c 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.html +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.html @@ -2,9 +2,9 @@ @@ -27,50 +27,88 @@
- @if (!filteredExecutions().length) { + @if (!filteredGroups().length) {
No executions available
} @else { - @for (execution of filteredExecutions(); track execution.id) { + @for (group of filteredGroups(); track group.id) { - -
-
-
{{ execution.title }}
-
{{ execution.flowName }}
- @if (execution.simulated) { -
- Simulated + + + +
+ Latest {{ group.lastExecutionTimeLabel }} + Run {{ runNumberLabel(group.latestRunNumber) }} +
+ + @if (isGroupExpanded(group.id)) { +
+ @for (execution of group.executions; track execution.id) { +
+
+
+ {{ runNumberLabel(execution.runNumber) }} + {{ execution.id }} +
+ + {{ execution.status }} + +
+
+ {{ execution.startedAt }} + @if (execution.rerunOfExecutionId) { + Rerun of {{ execution.rerunOfExecutionId }} + } + @if (execution.simulated) { + Simulated + } +
+
+ +
- } -
- - {{ execution.status }} - -
- + } @else { + + }
} diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.ts b/src/app/shared/tasks-executions-list/tasks-executions-list.ts index cc493f2..cf0ed2d 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.ts +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.ts @@ -21,10 +21,26 @@ export type TaskExecutionListItem = { flowName: string; status: TaskExecutionStatus; startedAt: string; + creationTime: number; + runNumber: number | null; + rerunOfExecutionId?: string | null; duration?: string; simulated?: boolean; }; +export type TaskExecutionGroupListItem = { + id: string; + sourceFlowId: string; + name: string; + executionCount: number; + lastExecutionTime: number; + lastExecutionTimeLabel: string; + latestExecutionId: string; + latestStatus: TaskExecutionStatus; + latestRunNumber: number | null; + executions: TaskExecutionListItem[]; +}; + @Component({ selector: 'app-tasks-executions-list', imports: [CommonModule, FormsModule, Ordering, MatButtonModule, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatTooltipModule], @@ -33,14 +49,16 @@ export type TaskExecutionListItem = { changeDetection: ChangeDetectionStrategy.OnPush }) export class TasksExecutionsListComponent { - readonly executions = input([]); + readonly groups = input([]); readonly selectedExecutionId = input(null); readonly executionSelected = output(); readonly executionDeleteRequested = output(); + readonly executionRerunRequested = output(); readonly searchTerm = model(''); readonly filter = signal('all'); - readonly orderBy = signal('startedAt'); + readonly orderBy = signal('lastExecutionTime'); readonly orderDir = signal('desc'); + readonly expandedGroupIds = signal>(new Set()); readonly orderView: OrderViewState = { orderBy: this.orderBy(), @@ -48,26 +66,32 @@ export class TasksExecutionsListComponent { }; readonly orderFields: OrderField[] = [ - { field: 'title', label: 'Title' }, - { field: 'flowName', label: 'Flow Name' }, - { field: 'startedAt', label: 'Started At' }, - { field: 'duration', label: 'Duration' } + { field: 'name', label: 'Name' }, + { field: 'executionCount', label: 'Executions' }, + { field: 'lastExecutionTime', label: 'Latest Run' }, + { field: 'latestStatus', label: 'Status' } ]; - readonly filteredExecutions = computed(() => { + readonly filteredGroups = computed(() => { const term = this.searchTerm().trim().toLowerCase(); const filter = this.filter(); const orderBy = this.orderBy(); const orderDir = this.orderDir(); - const filtered = this.executions().filter((execution) => { - if (!this.matchesFilter(execution.status, filter)) return false; + const filtered = this.groups().filter((group) => { + if (!this.groupMatchesFilter(group, filter)) return false; if (!term) return true; return ( - execution.title.toLowerCase().includes(term) || - execution.flowName.toLowerCase().includes(term) || - execution.id.toLowerCase().includes(term) + group.name.toLowerCase().includes(term) || + group.sourceFlowId.toLowerCase().includes(term) || + group.id.toLowerCase().includes(term) || + group.executions.some((execution) => + execution.title.toLowerCase().includes(term) || + execution.flowName.toLowerCase().includes(term) || + execution.id.toLowerCase().includes(term) || + String(execution.rerunOfExecutionId ?? '').toLowerCase().includes(term) + ) ); }); @@ -99,6 +123,28 @@ export class TasksExecutionsListComponent { this.executionDeleteRequested.emit(executionId); } + requestRerunExecution(executionId: string, event?: Event) { + event?.stopPropagation(); + this.executionRerunRequested.emit(executionId); + } + + toggleGroup(groupId: string, event?: Event) { + event?.stopPropagation(); + this.expandedGroupIds.update((current) => { + const next = new Set(current); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + } + + isGroupExpanded(groupId: string): boolean { + return this.expandedGroupIds().has(groupId); + } + onOrderChanged(event: OrderEvent) { this.orderBy.set(event.orderBy); this.orderDir.set(event.orderDir); @@ -122,6 +168,19 @@ export class TasksExecutionsListComponent { return getExecutionStatusGroup(status) === 'RUNNING'; } + canRerun(status: TaskExecutionStatus): boolean { + return getExecutionStatusGroup(status) === 'FINAL'; + } + + runNumberLabel(runNumber: number | null | undefined): string { + return runNumber == null ? '-' : `#${runNumber}`; + } + + private groupMatchesFilter(group: TaskExecutionGroupListItem, filter: TaskExecutionFilter): boolean { + if (filter === 'all') return true; + return group.executions.some((execution) => this.matchesFilter(execution.status, filter)); + } + private matchesFilter(status: TaskExecutionStatus, filter: TaskExecutionFilter): boolean { if (filter === 'all') return true; return getExecutionStatusGroup(status) === filter; diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 8b22064..95dfe9f 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -26,6 +26,7 @@ import { GenericNodeComponent } from "@shared/nodes/generic-node/generic-node"; import { TaskStepNodeComponent } from "@shared/nodes/task-step-node/task-step-node"; import { CustomSocket } from "@shared/custom-socket/custom-socket"; import { CustomConnectionComponent } from "@shared/custom-connection/custom-connection"; +import { deleteSchemaValueByPath, setSchemaValueByPath } from "@shared/nodes/schema-driven-fields"; import { firstValueFrom } from "rxjs"; type AreaExtra = AngularArea2D; @@ -218,13 +219,13 @@ export async function addBlockToEditor( await editor.removeNode(node.id); }; - const clearContainerSubflow = async () => { + const clearContainerSubflow = async (targetPath = "subFlow") => { const currentNode = editor.getNode(node.id) as HFNode | undefined; if (!currentNode?.data) return; const nextConfiguration = { ...cloneValue(currentNode.data.specificConfiguration ?? {}) }; - delete (nextConfiguration as Record)["subFlow"]; + deleteSchemaValueByPath(nextConfiguration as Record, targetPath); const replacement = { ...cloneValue(currentNode.data), inputs: [], @@ -252,7 +253,7 @@ export async function addBlockToEditor( }; const applyContainerSubflow = async ( candidateSubFlow: FlowData, - options?: { selectedIds?: Set; validationUrl?: string | null; preValidate?: boolean; source?: 'drag' | 'import' } + options?: { selectedIds?: Set; targetPath?: string; validationUrl?: string | null; preValidate?: boolean; source?: 'drag' | 'import' } ) => { if (!resolvedRuntime) return; @@ -288,11 +289,12 @@ export async function addBlockToEditor( } const currentConfiguration = cloneValue(currentLiveNode.data.specificConfiguration ?? {}) as Record; + const targetPath = options?.targetPath ?? 'subFlow'; const nextConfiguration: Record = { ...currentConfiguration, - name: String(currentConfiguration['name'] ?? currentLiveNode.data['name'] ?? 'Container'), - subFlow: candidateSubFlow + name: String(currentConfiguration['name'] ?? currentLiveNode.data['name'] ?? 'Container') }; + setSchemaValueByPath(nextConfiguration, targetPath, candidateSubFlow); const nextPosition = cloneValue(currentLiveNode.data['position'] ?? null); const containerId = String(currentLiveNode.data['id'] ?? ''); @@ -304,7 +306,7 @@ export async function addBlockToEditor( }) ); if (options?.source === 'import') { - console.log('Container create response after subFlow import:', replacementFromServer); + console.log(`Container create response after ${targetPath} import:`, replacementFromServer); } const selectedIds = options?.selectedIds ?? new Set(); @@ -361,7 +363,11 @@ export async function addBlockToEditor( await area.update("node", node.id); } }; - const assignSelectedBlocksToContainer = async (selectedBlockIds?: string[]) => { + const assignSelectedBlocksToContainer = async ( + selectedBlockIds?: string[], + targetPath = 'subFlow', + validationUrl?: string | null + ) => { if (!resolvedRuntime) return; const currentNode = editor.getNode(node.id) as HFNode | undefined; @@ -403,10 +409,10 @@ export async function addBlockToEditor( ) ) }; - await applyContainerSubflow(candidateSubFlow, { selectedIds, preValidate: true }); + await applyContainerSubflow(candidateSubFlow, { selectedIds, targetPath, validationUrl, preValidate: true }); }; - const assignImportedSubflow = async (subFlow: FlowData, validationUrl?: string | null) => { - await applyContainerSubflow(cloneValue(subFlow), { validationUrl, source: 'import' }); + const assignImportedSubflow = async (subFlow: FlowData, targetPath = 'subFlow', validationUrl?: string | null) => { + await applyContainerSubflow(cloneValue(subFlow), { targetPath, validationUrl, preValidate: true, source: 'import' }); }; const replaceWithCreatedNode = async (createdBlock: FlowNode) => { if (!editor.getNode(node.id)) return;