feat: add execution groups and schema-driven flow fields

This commit is contained in:
Lucio Lelii 2026-07-20 16:22:44 +02:00
parent 4c4b57781f
commit a258064eb5
21 changed files with 1065 additions and 298 deletions

View File

@ -2,7 +2,7 @@
<div class="flex flex-row flex-1 overflow-hidden gap-2">
<app-tasks-executions-list
class="w-[360px] shrink-0"
[executions]="executions()"
[groups]="groups()"
[selectedExecutionId]="selectedExecutionId()"
(executionSelected)="selectExecution($event)"
(executionDeleteRequested)="removeExecution($event)">

View File

@ -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<TaskExecutionListItem[]>(() =>
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<TaskExecutionGroupListItem[]>(() =>
this.executionGroups().map((group) => this.toGroupListItem(group))
);
readonly selectedExecutionId = signal<string | null>(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();

View File

@ -4,9 +4,9 @@ import { FlowData, FlowNode } from "./flow";
export type HFNodeData = FlowNode & {
deleteNode?: () => Promise<void>;
replaceWithCreatedNode?: (block: FlowNode) => Promise<void>;
assignSelectedBlocksToContainer?: (blockIds?: string[]) => Promise<void>;
assignImportedSubflow?: (subFlow: FlowData, validationUrl?: string | null) => Promise<void>;
clearContainerSubflow?: () => Promise<void>;
assignSelectedBlocksToContainer?: (blockIds?: string[], targetPath?: string, validationUrl?: string | null) => Promise<void>;
assignImportedSubflow?: (subFlow: FlowData, targetPath?: string, validationUrl?: string | null) => Promise<void>;
clearContainerSubflow?: (targetPath?: string) => Promise<void>;
cloneNode?: () => Promise<void>;
__readonly?: boolean;
__needsServerCreate?: boolean;

View File

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

View File

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

View File

@ -115,7 +115,7 @@ export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase
context?: Record<string, string>,
retrieverUrl?: string | null
): Observable<RetrieverStructuredItem<T>[]> {
if (key === 'subFlow') {
if (key === 'subFlow' || retrieverUrl?.includes('/Flows/subFlow/items')) {
return of(this.subFlowItems as unknown as RetrieverStructuredItem<T>[]);
}
if (

View File

@ -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<TaskExecution[]>;
abstract retrieveTaskExecutionGroups(): Observable<TaskExecutionGroup[]>;
abstract retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]>;
abstract createTaskExecution(flowId: string): Observable<TaskExecution>;
abstract rerunTaskExecution(executionId: string): Observable<TaskExecution>;
abstract deleteTaskExecution(executionId: string): Observable<void>;
abstract startTaskExecution(executionId: string): Observable<TaskExecution>;
abstract simulateTaskExecution(executionId: string, simulator: LLMDescriptor): Observable<TaskExecution>;

View File

@ -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<TaskExecutionGroup[]> {
return of(this.buildExecutionGroups());
}
override retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]> {
const execution = this.findExecution(executionId);
return of(this.buildExecutionEvents(execution));
}
override createTaskExecution(flowId: string): Observable<TaskExecution> {
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<TaskExecution> {
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<void> {
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<string, TaskExecution[]>();
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();

View File

@ -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<TaskExecutionGroup[]> {
return this.http.get<unknown>(`${environment.apiUrl}/executions/groups`).pipe(
map((raw) => Array.isArray(raw) ? raw.map((item) => this.mapExecutionGroup(item)) : [])
);
}
override retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]> {
return this.http.get<ExecutionEventLogEntry[]>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/events`);
}
@ -25,6 +31,12 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
);
}
override rerunTaskExecution(executionId: string): Observable<TaskExecution> {
return this.http.post<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/rerun`, null).pipe(
map((raw) => this.mapExecution(raw))
);
}
override deleteTaskExecution(executionId: string): Observable<void> {
return this.http.delete<void>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}`);
}
@ -191,6 +203,36 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
};
}
private mapExecutionGroup(raw: unknown): TaskExecutionGroup {
const group = (raw ?? {}) as Partial<TaskExecutionGroup> & Record<string, unknown>;
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<string, unknown> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
return { ...(raw as Record<string, unknown>) };
@ -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;
}
}

View File

@ -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<typeof setInterval> | null = null;
private _taskExecutions = signal<TaskExecution[]>([]);
private _taskExecutionGroups = signal<TaskExecutionGroup[]>([]);
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(() => {

View File

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

View File

@ -353,20 +353,21 @@
</div>
}
@for (flowField of flowFields; track flowField.path) {
<div
class="container-node__dropzone"
[class.container-node__dropzone--active]="!isReadonly && selectedCount > 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) {
<div class="container-node__replace-confirm" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<div class="container-node__replace-confirm-text">Replace current subflow?</div>
<div class="container-node__replace-confirm-text">Replace current {{ flowField.label }}?</div>
<div class="container-node__replace-confirm-note">The existing embedded flow will be removed and replaced by the dropped selection.</div>
<div class="container-node__replace-confirm-actions">
<button type="button" class="container-node__replace-confirm-cancel" (click)="cancelReplaceSubflow($event)">Cancel</button>
<button type="button" class="container-node__replace-confirm-action" (click)="confirmReplaceSubflow($event)">Replace</button>
<button type="button" class="container-node__replace-confirm-action" (click)="confirmReplaceSubflow(flowField, $event)">Replace</button>
</div>
</div>
} @else if (isAssigning) {
@ -375,6 +376,12 @@
<span>Updating container...</span>
</div>
} @else {
<div class="container-node__dropzone-title">{{ flowField.label }}</div>
@if (flowField.ui.tip) {
<div class="container-node__dropzone-description">
{{ flowField.ui.tip }}
</div>
}
<div class="container-node__dropzone-main">
<span class="container-node__dropzone-empty">Drag a flow here or import from flow</span>
</div>
@ -382,17 +389,17 @@
Use the selection box in the editor, then drag the floating selection badge into this area.
</div>
}
@if (subFlowBlockCount > 0 && !replaceConfirmOpen && !isAssigning) {
@if (flowField.blockCount > 0 && !flowField.replaceConfirmOpen && !isAssigning) {
<div class="container-node__dropzone-actions">
<button type="button" class="container-node__subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflowPreview($event)">
<button type="button" class="container-node__subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflowPreview(flowField, $event)">
View Flow
</button>
</div>
}
@if (!isReadonly && !replaceConfirmOpen && !isAssigning) {
@if (!isReadonly && !flowField.replaceConfirmOpen && !isAssigning) {
<div class="container-node__dropzone-actions">
<button type="button" class="container-node__import" (pointerdown)="$event.stopPropagation()" (click)="importSubflow($event)">
@if (importLoading) {
<button type="button" class="container-node__import" (pointerdown)="$event.stopPropagation()" (click)="importSubflow(flowField, $event)">
@if (flowField.importLoading) {
<span class="container-node__spinner" aria-hidden="true"></span>
<span>Loading flows...</span>
} @else {
@ -402,6 +409,7 @@
</div>
}
</div>
}
@if (assignmentErrorMessage) {
<div class="container-node__message container-node__message--error">

View File

@ -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<ContainerDisplayItem>;
type ContainerDisplaySection = SchemaDisplaySection<ContainerDisplayItem>;
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<string, any> | 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<string, unknown>;
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<FlowData>(
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<string, unknown> => !!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<string, unknown> => !!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<string, unknown>;
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<StructuredRetrieverConfig | null> {
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<string, unknown>)['subFlow']
: null;
if (!propertySchema || typeof propertySchema !== 'object' || Array.isArray(propertySchema)) {
return null;
}
const fieldSchema = propertySchema as Record<string, unknown>;
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<string, any> | 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<string, any> | 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)
});
}

View File

@ -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<string, any> | null | undefined): SchemaFlowDataFieldDefinition[] {
if (!root) return [];
const fields: SchemaFlowDataFieldDefinition[] = [];
const seen = new Set<string>();
const walk = (node: Record<string, any>, 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, any>): 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<string, unknown>;
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<string, any>, root: Record<string, any>): 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<string, unknown> => !!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<string, unknown> => !!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<string, unknown>;
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 };
}

View File

@ -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',

View File

@ -330,9 +330,11 @@
@if (hasViewableSubflow()) {
<div class="llm-subflow-section">
<button type="button" class="llm-subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflowPreview($event)">
View Subflow
@for (subflow of viewableSubflows(); track subflow.path) {
<button type="button" class="llm-subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflowPreview(subflow.path, $event)">
View {{ subflow.label }}
</button>
}
</div>
}
</div>

View File

@ -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<string, any> | 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<string, unknown>;
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<string, unknown> => !!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<string, unknown> => !!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<string, unknown>;
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';
}

View File

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

View File

@ -2,9 +2,9 @@
<div class="tasks-list-search">
<mat-form-field appearance="outline">
<mat-label>Search executions</mat-label>
<mat-label>Search history</mat-label>
<mat-icon matPrefix fontIcon="search"></mat-icon>
<input matInput placeholder="Search executions..." [(ngModel)]="searchTerm" />
<input matInput placeholder="Search history..." [(ngModel)]="searchTerm" />
</mat-form-field>
</div>
@ -27,50 +27,88 @@
</div>
<div class="tasks-list-items">
@if (!filteredExecutions().length) {
@if (!filteredGroups().length) {
<div class="tasks-list-empty">No executions available</div>
} @else {
<mat-list class="tasks-list-list">
@for (execution of filteredExecutions(); track execution.id) {
@for (group of filteredGroups(); track group.id) {
<mat-list-item class="tasks-list-list-item">
<mat-card
class="tasks-list-item"
[class.border-blue-300]="execution.id === selectedExecutionId()"
[class.bg-blue-50]="execution.id === selectedExecutionId()"
[class.border-slate-200]="execution.id !== selectedExecutionId()"
[class.bg-white]="execution.id !== selectedExecutionId()"
(click)="selectExecution(execution.id)">
<div class="flex items-start justify-between gap-2">
<div class="tasks-list-item-meta">
<div class="tasks-list-item-title">{{ execution.title }}</div>
<div class="tasks-list-item-subtitle">{{ execution.flowName }}</div>
@if (execution.simulated) {
<div class="mt-1">
<span class="tasks-list-simulated-badge">Simulated</span>
<mat-card class="tasks-list-group" [class.tasks-list-group-expanded]="isGroupExpanded(group.id)">
<button type="button" class="tasks-list-group-header" (click)="toggleGroup(group.id, $event)">
<mat-icon class="tasks-list-group-chevron" [fontIcon]="isGroupExpanded(group.id) ? 'expand_less' : 'expand_more'"></mat-icon>
<div class="tasks-list-group-main">
<div class="tasks-list-group-title">{{ group.name }}</div>
<div class="tasks-list-group-subtitle">{{ group.sourceFlowId }}</div>
</div>
<div class="tasks-list-group-summary">
<span class="tasks-list-count">{{ group.executionCount }} runs</span>
<span class="tasks-list-status" [ngClass]="statusBadgeClass(group.latestStatus)">
{{ group.latestStatus }}
</span>
</div>
</button>
<div class="tasks-list-group-meta">
<span>Latest {{ group.lastExecutionTimeLabel }}</span>
<span>Run {{ runNumberLabel(group.latestRunNumber) }}</span>
</div>
@if (isGroupExpanded(group.id)) {
<div class="tasks-list-executions">
@for (execution of group.executions; track execution.id) {
<div
class="tasks-list-execution"
[class.tasks-list-execution-selected]="execution.id === selectedExecutionId()"
(click)="selectExecution(execution.id)">
<div class="tasks-list-execution-head">
<div class="tasks-list-execution-title">
<span class="tasks-list-run">{{ runNumberLabel(execution.runNumber) }}</span>
<span class="tasks-list-execution-id">{{ execution.id }}</span>
</div>
<span class="tasks-list-status" [ngClass]="statusBadgeClass(execution.status)">
{{ execution.status }}
</span>
</div>
<div class="tasks-list-execution-details">
<span>{{ execution.startedAt }}</span>
@if (execution.rerunOfExecutionId) {
<span>Rerun of {{ execution.rerunOfExecutionId }}</span>
}
@if (execution.simulated) {
<span class="tasks-list-simulated-badge">Simulated</span>
}
</div>
<div class="tasks-list-execution-actions">
<button
type="button"
mat-icon-button
class="!h-8 !w-8 enabled:!text-emerald-700 disabled:!text-slate-300"
matTooltip="Rerun execution"
aria-label="Rerun execution"
[disabled]="!canRerun(execution.status)"
(click)="requestRerunExecution(execution.id, $event)">
<mat-icon fontIcon="replay"></mat-icon>
</button>
<button
type="button"
mat-icon-button
class="!h-8 !w-8 enabled:!text-red-600 disabled:!text-red-200"
matTooltip="Delete execution"
aria-label="Delete execution"
[disabled]="isDeleteDisabled(execution.status)"
(click)="requestDeleteExecution(execution.id, $event)">
<mat-icon fontIcon="delete"></mat-icon>
</button>
</div>
}
</div>
<span class="tasks-list-status"
[ngClass]="statusBadgeClass(execution.status)">
{{ execution.status }}
</span>
</div>
<div class="tasks-list-item-footer">
<span>{{ execution.startedAt }}</span>
<div class="tasks-list-item-actions">
<span>{{ execution.duration || '-' }}</span>
<button
type="button"
mat-icon-button
class="!h-8 !w-8 enabled:!text-red-600 disabled:!text-red-200"
matTooltip="Delete execution"
aria-label="Delete execution"
[disabled]="isDeleteDisabled(execution.status)"
(click)="requestDeleteExecution(execution.id, $event)">
<mat-icon fontIcon="delete"></mat-icon>
</button>
</div>
}
</div>
} @else {
<button type="button" class="tasks-list-latest" (click)="selectExecution(group.latestExecutionId)">
<span>Open latest execution</span>
<mat-icon fontIcon="arrow_forward"></mat-icon>
</button>
}
</mat-card>
</mat-list-item>
}

View File

@ -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<TaskExecutionListItem[]>([]);
readonly groups = input<TaskExecutionGroupListItem[]>([]);
readonly selectedExecutionId = input<string | null>(null);
readonly executionSelected = output<string>();
readonly executionDeleteRequested = output<string>();
readonly executionRerunRequested = output<string>();
readonly searchTerm = model<string>('');
readonly filter = signal<TaskExecutionFilter>('all');
readonly orderBy = signal<string | null>('startedAt');
readonly orderBy = signal<string | null>('lastExecutionTime');
readonly orderDir = signal<orderDirType>('desc');
readonly expandedGroupIds = signal<Set<string>>(new Set<string>());
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;

View File

@ -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<HFSchemes>;
@ -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<string, unknown>)["subFlow"];
deleteSchemaValueByPath(nextConfiguration as Record<string, unknown>, targetPath);
const replacement = {
...cloneValue(currentNode.data),
inputs: [],
@ -252,7 +253,7 @@ export async function addBlockToEditor(
};
const applyContainerSubflow = async (
candidateSubFlow: FlowData,
options?: { selectedIds?: Set<string>; validationUrl?: string | null; preValidate?: boolean; source?: 'drag' | 'import' }
options?: { selectedIds?: Set<string>; 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<string, unknown>;
const targetPath = options?.targetPath ?? 'subFlow';
const nextConfiguration: Record<string, unknown> = {
...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<string>();
@ -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;