Split containers into dedicated services and sidebar list

This commit is contained in:
Lucio Lelii 2026-03-15 06:07:10 +01:00
parent 25e133ac04
commit 533a1e7f43
23 changed files with 1011 additions and 262 deletions

View File

@ -21,8 +21,22 @@
[ngClass]="{'cursor-pointer enabled' : open != 'flows'}"
class="bi bi-lightning-charge-fill text-indigo-500 text-xl p-2"
(click)="open != 'flows' && openSide('flows')"></i>
<i [ngClass]="{'cursor-pointer text-gray-600 enabled' : !blockDisabled()}"
class="bi bi-boxes text-gray-200 text-xl p-2" (click)="!blockDisabled() && openSide('blocks')"></i>
<i
[ngClass]="{
'cursor-pointer text-gray-600 enabled': !blockDisabled() && open != 'blocks',
'cursor-none bg-gray-200 text-sky-700': open == 'blocks',
'text-gray-200': blockDisabled()
}"
class="bi bi-boxes text-xl p-2"
(click)="!blockDisabled() && open != 'blocks' && openSide('blocks')"></i>
<i
[ngClass]="{
'cursor-pointer text-gray-600 enabled': !blockDisabled() && open != 'containers',
'cursor-none bg-gray-200 text-cyan-700': open == 'containers',
'text-gray-200': blockDisabled()
}"
class="bi bi-box-seam text-xl p-2"
(click)="!blockDisabled() && open != 'containers' && openSide('containers')"></i>
</div>
</div>
@ -51,6 +65,11 @@
<app-blocks-list></app-blocks-list>
</app-group-holder>
}
@case ('containers') {
<app-group-holder title="Containers" icon="bi-box-seam">
<app-containers-list></app-containers-list>
</app-group-holder>
}
}
</div>

View File

@ -2,17 +2,18 @@ import { Component, computed, inject, signal } from '@angular/core';
import { GroupHolder } from '@shared/group-holder/group-holder';
import { FlowsList } from '@shared/flows-list/flows-list';
import { BlocksList } from '@shared/blocks-list/blocks-list';
import { ContainersList } from '@shared/containers-list/containers-list';
import { EditorStateHolder } from '@stores/flow-editor';
import { CommonModule } from '@angular/common';
import { FlowsService } from '@services/flows/flows';
import { ListState } from '@stores/list-state';
import { finalize } from 'rxjs';
type OpenedId = 'flows' | 'blocks';
type OpenedId = 'flows' | 'blocks' | 'containers';
@Component({
selector: 'app-editor-sidebar',
imports: [GroupHolder, FlowsList, BlocksList, CommonModule],
imports: [GroupHolder, FlowsList, BlocksList, ContainersList, CommonModule],
templateUrl: './editor-sidebar.html',
styleUrl: './editor-sidebar.css',
providers:[ListState]

View File

@ -1,18 +1,12 @@
import { BlockType, BlockTypeName, FlowBlock, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow";
import { BlockType, BlockTypeName, FlowBlock } from "@models/flow";
import { Observable } from "rxjs";
export abstract class BlocksCallServiceBase {
abstract retrieveAllBlocksTypes() : Observable<BlockType[]>;
abstract retrieveAllContainerTypes() : Observable<BlockType[]>;
abstract createEmptyBlock(blockType: BlockTypeName) : Observable<FlowBlock>;
abstract createEmptyContainer(containerType: BlockTypeName) : Observable<FlowContainer>;
abstract updateBlock(blockId : string, configuration : any) : Observable<FlowBlock>;
abstract validateContainerSubflow(subFlow: FlowData) : Observable<FlowSubflowValidationResult>;
}

View File

@ -1,4 +1,4 @@
import { BlockType, FlowBlock, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow";
import { BlockType, FlowBlock } from "@models/flow";
import { Observable, of } from "rxjs";
import { BlocksCallServiceBase } from "./block-call.base";
@ -135,64 +135,10 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
},
];
private readonly containerTypes: BlockType[] = [
{
"type": "GenericContainer",
"family": "container",
"description": "Container block with an embedded validated subflow",
"userInteractive": false,
"configurationType": "GenericContainerConfiguration",
"configurationClass": "it.cnr.isti.workflow.manager.blocks.configurations.GenericContainerConfiguration",
"schema": {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "GenericContainerConfiguration",
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": [
"GenericContainerConfiguration"
],
"default": "GenericContainerConfiguration"
},
"name": {
"type": "string",
"default": "Container"
},
"subFlow": {
"type": "object",
"default": {
"blocks": [],
"containers": [],
"connections": []
}
},
"publicInputs": {
"type": "array",
"default": []
},
"publicOutputs": {
"type": "array",
"default": []
}
},
"required": [
"type",
"name"
]
}
}
];
override retrieveAllBlocksTypes(): Observable<BlockType[]> {
return of(this.blockTypes);
}
override retrieveAllContainerTypes(): Observable<BlockType[]> {
return of(this.containerTypes);
}
override createEmptyBlock(blockType: string): Observable<FlowBlock> {
const descriptor = this.blockTypes.find((b) => b.type === blockType);
const typeName = descriptor?.type ?? blockType ?? "LLMBlock";
@ -223,27 +169,6 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
return of(block);
}
override createEmptyContainer(containerType: string): Observable<FlowContainer> {
const descriptor = this.containerTypes.find((b) => b.type === containerType);
const typeName = descriptor?.type ?? containerType ?? "GenericContainer";
const schema = descriptor?.schema as Record<string, any> | null;
const specificConfiguration = schema
? this.buildObjectFromSchema(schema, schema)
: {};
return of({
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`,
name: String((specificConfiguration as any)?.name ?? typeName),
position: undefined,
inputs: [],
outputs: [],
specificConfiguration,
typeName,
nodeFamily: 'container'
});
}
override updateBlock(blockId: string, configuration: any): Observable<FlowBlock> {
const typeName = configuration?.typeName ?? "LLMBlock";
const io = this.defaultIOForBlockType(typeName);
@ -260,35 +185,6 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
return of(block);
}
override validateContainerSubflow(subFlow: FlowData): Observable<FlowSubflowValidationResult> {
const blocks = Array.isArray(subFlow?.blocks) ? subFlow.blocks : [];
if (!blocks.length) {
return of({
valid: false,
errors: [{ entity: 'flow', field: 'blocks', message: 'Subflow cannot be empty' }],
openInputs: [],
openOutputs: []
});
}
const nestedContainer = (subFlow?.containers ?? []).find((container) => container?.typeName === 'GenericContainer');
if (nestedContainer) {
return of({
valid: false,
errors: [{
entity: 'block',
id: nestedContainer.id,
field: 'type',
message: 'Nested GenericContainer blocks are not supported'
}],
openInputs: [],
openOutputs: []
});
}
return of({ valid: true, errors: [], openInputs: [], openOutputs: [] });
}
private defaultIOForBlockType(typeName: string) {
if (typeName === "SourceBlock") {
return {

View File

@ -1,4 +1,4 @@
import { BlockType, FlowBlock, FlowContainer, FlowData, FlowSubflowValidationResult, NodeFamily } from "@models/flow";
import { BlockType, FlowBlock } from "@models/flow";
import { HttpClient } from "@angular/common/http";
import { inject } from "@angular/core";
import { environment } from "@environment";
@ -8,13 +8,12 @@ import { BlocksCallServiceBase } from "./block-call.base";
export class BlocksCallService extends BlocksCallServiceBase {
private readonly http = inject(HttpClient);
private blockTypesCache: BlockType[] | null = null;
private containerTypesCache: BlockType[] | null = null;
override retrieveAllBlocksTypes(): Observable<BlockType[]> {
return this.http
.get<unknown[]>(`${environment.apiUrl}/blocks/types`)
.pipe(
map((raw) => (Array.isArray(raw) ? raw.map((value) => this.blockTypeFromApi(value, 'block')) : [])),
map((raw) => (Array.isArray(raw) ? raw.map((value) => this.blockTypeFromApi(value)) : [])),
map((types) => {
this.blockTypesCache = types;
return types;
@ -22,29 +21,17 @@ export class BlocksCallService extends BlocksCallServiceBase {
);
}
override retrieveAllContainerTypes(): Observable<BlockType[]> {
return this.http
.get<unknown[]>(`${environment.apiUrl}/containers/types`)
.pipe(
map((raw) => (Array.isArray(raw) ? raw.map((value) => this.blockTypeFromApi(value, 'container')) : [])),
map((types) => {
this.containerTypesCache = types;
return types;
})
);
}
override createEmptyBlock(blockType: string): Observable<FlowBlock> {
return this.getBlockTypesForCreate().pipe(
take(1),
switchMap((types) => {
const descriptor = types.find((type) => type.type === blockType);
const exampleEndpoint = this.resolveExampleEndpoint('block', blockType, descriptor);
const exampleEndpoint = this.resolveExampleEndpoint(blockType, descriptor);
if (exampleEndpoint) {
return this.http
.get<unknown>(exampleEndpoint)
.pipe(map((raw) => this.flowNodeFromApi(raw, descriptor?.type ?? blockType, 'block') as FlowBlock));
.pipe(map((raw) => this.flowBlockFromApi(raw, descriptor?.type ?? blockType)));
}
const configuration = descriptor
@ -54,21 +41,7 @@ export class BlocksCallService extends BlocksCallServiceBase {
return this.http
.post<unknown>(`${environment.apiUrl}/blocks`, payload)
.pipe(map((raw) => this.flowNodeFromApi(raw, descriptor?.type ?? blockType, 'block', payload) as FlowBlock));
})
);
}
override createEmptyContainer(containerType: string): Observable<FlowContainer> {
return this.getContainerTypesForCreate().pipe(
take(1),
switchMap((types) => {
const descriptor = types.find((type) => type.type === containerType);
const exampleEndpoint = this.resolveExampleEndpoint('container', containerType, descriptor);
return this.http
.get<unknown>(exampleEndpoint)
.pipe(map((raw) => this.flowNodeFromApi(raw, descriptor?.type ?? containerType, 'container') as FlowContainer));
.pipe(map((raw) => this.flowBlockFromApi(raw, descriptor?.type ?? blockType, payload)));
})
);
}
@ -104,15 +77,6 @@ export class BlocksCallService extends BlocksCallServiceBase {
);
}
override validateContainerSubflow(subFlow: FlowData): Observable<FlowSubflowValidationResult> {
return this.http
.post<unknown>(
`${environment.apiUrl}/containers/types/GenericContainer/validate-subflow`,
{ subFlow }
)
.pipe(map((raw) => this.subflowValidationFromApi(raw)));
}
private getBlockTypesForCreate(): Observable<BlockType[]> {
if (this.blockTypesCache) {
return of(this.blockTypesCache);
@ -120,18 +84,11 @@ export class BlocksCallService extends BlocksCallServiceBase {
return this.retrieveAllBlocksTypes();
}
private getContainerTypesForCreate(): Observable<BlockType[]> {
if (this.containerTypesCache) {
return of(this.containerTypesCache);
}
return this.retrieveAllContainerTypes();
}
private blockTypeFromApi(raw: unknown, family: NodeFamily): BlockType {
private blockTypeFromApi(raw: unknown): BlockType {
const value = this.toRecord(raw);
return {
type: String(value["type"] ?? value["blockType"] ?? value["name"] ?? "LLMBlock"),
family,
family: 'block',
description: String(value["description"] ?? ""),
userInteractive: Boolean(value["userInteractive"] ?? value["interactive"] ?? false),
hasExampleBlock: Boolean(value["hasExampleBlock"] ?? false),
@ -142,49 +99,9 @@ export class BlocksCallService extends BlocksCallServiceBase {
};
}
private subflowValidationFromApi(raw: unknown): FlowSubflowValidationResult {
const value = this.toRecord(raw);
const rawErrors = Array.isArray(value['errors']) ? value['errors'] : [];
return {
valid: Boolean(value['valid'] ?? false),
errors: rawErrors
.map((item) => this.toRecord(item))
.map((item) => ({
entity: this.toNullableString(item['entity']) ?? undefined,
id: this.toNullableString(item['id']) ?? undefined,
field: this.toNullableString(item['field']) ?? undefined,
message: String(item['message'] ?? 'Invalid subflow')
})),
openInputs: this.toPorts(value['openInputs'], []).map((port) => ({
...port,
targetBlockId: this.toNullableString(this.toRecord(port)['targetBlockId']) ?? undefined,
targetInputName: this.toNullableString(this.toRecord(port)['targetInputName']) ?? undefined,
blockId: this.toNullableString(this.toRecord(port)['blockId']) ?? undefined,
inputName: this.toNullableString(this.toRecord(port)['inputName']) ?? undefined
})),
openOutputs: this.toPorts(value['openOutputs'], []).map((port) => ({
...port,
sourceBlockId: this.toNullableString(this.toRecord(port)['sourceBlockId']) ?? undefined,
sourceOutputName: this.toNullableString(this.toRecord(port)['sourceOutputName']) ?? undefined,
blockId: this.toNullableString(this.toRecord(port)['blockId']) ?? undefined,
outputName: this.toNullableString(this.toRecord(port)['outputName']) ?? undefined
}))
};
}
private flowBlockFromApi(raw: unknown, fallbackTypeName = "LLMBlock", fallbackConfig?: Record<string, unknown>): FlowBlock {
return this.flowNodeFromApi(raw, fallbackTypeName, 'block', fallbackConfig) as FlowBlock;
}
private flowNodeFromApi(
raw: unknown,
fallbackTypeName = "LLMBlock",
family: NodeFamily = 'block',
fallbackConfig?: Record<string, unknown>
) {
const root = this.toRecord(raw);
const value = this.toRecord(root[family] ?? root["node"] ?? root["block"] ?? root["container"] ?? root["data"] ?? root);
const value = this.toRecord(root["block"] ?? root["node"] ?? root["data"] ?? root);
const specificConfigurationRaw = value["specificConfiguration"] ?? value["configuration"] ?? value["blockConfiguration"] ?? fallbackConfig ?? {};
const specificConfiguration = this.toRecord(specificConfigurationRaw);
const typeName = String(value["typeName"] ?? value["blockType"] ?? specificConfiguration["typeName"] ?? fallbackTypeName);
@ -198,7 +115,7 @@ export class BlocksCallService extends BlocksCallServiceBase {
outputs: this.toPorts(value["outputs"], io.outputs),
specificConfiguration,
typeName,
nodeFamily: family
nodeFamily: 'block'
};
}
@ -274,12 +191,11 @@ export class BlocksCallService extends BlocksCallServiceBase {
};
}
private resolveExampleEndpoint(family: NodeFamily, typeName: string, descriptor?: BlockType): string {
private resolveExampleEndpoint(typeName: string, descriptor?: BlockType): string {
if (descriptor?.hasExampleBlock && descriptor.exampleBlockEndpoint) {
return descriptor.exampleBlockEndpoint;
}
const base = family === 'container' ? 'containers' : 'blocks';
return `${environment.apiUrl}/${base}/types/${encodeURIComponent(typeName)}/example`;
return `${environment.apiUrl}/blocks/types/${encodeURIComponent(typeName)}/example`;
}
private toUpdateBlockError(error: unknown, blockType: string): Error {

View File

@ -1,8 +1,8 @@
import { computed, Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { BlockType, BlockTypeName, FlowData, FlowNode, NodeFamily } from '@models/flow';
import { BlockType, BlockTypeName, FlowBlock } from '@models/flow';
import { BlocksCallServiceBase } from './block-call.base';
import { catchError, finalize, firstValueFrom, forkJoin, map, Observable, of, shareReplay, throwError } from 'rxjs';
import { catchError, finalize, firstValueFrom, map, Observable, of, shareReplay, throwError } from 'rxjs';
@Injectable({
providedIn: 'root',
@ -12,12 +12,17 @@ export class BlocksService {
toInit: boolean = true;
private loadingPromise: Promise<void> | null = null;
private readonly emptyBlockCache = new Map<string, FlowNode>();
private readonly pendingEmptyBlockRequests = new Map<string, Observable<FlowNode>>();
private readonly emptyBlockCache = new Map<string, FlowBlock>();
private readonly pendingEmptyBlockRequests = new Map<string, Observable<FlowBlock>>();
private readonly pendingServerSyncCount = signal(0);
private _blockTypes = signal<BlockType[]>([]);
readonly hasPendingServerSync = computed(() => this.pendingServerSyncCount() > 0);
readonly blockTypes = this._blockTypes.asReadonly();
hasLoadedBlockTypes() {
return this._blockTypes().length > 0 || !this.toInit;
}
async getAllBlocksTypes() {
if (this.toInit) {
@ -33,12 +38,9 @@ export class BlocksService {
return this.loadingPromise;
}
this.loadingPromise = firstValueFrom(forkJoin({
blocks: this.blocksCallService.retrieveAllBlocksTypes(),
containers: this.blocksCallService.retrieveAllContainerTypes()
}))
.then(({ blocks, containers }) => {
this._blockTypes.set([...blocks, ...containers]);
this.loadingPromise = firstValueFrom(this.blocksCallService.retrieveAllBlocksTypes())
.then((blockTypes) => {
this._blockTypes.set(blockTypes);
this.clearEmptyBlockCache();
})
.catch((err) => {
@ -56,31 +58,27 @@ export class BlocksService {
const current = this._blockTypes().find((blockType) => blockType.type === typeName);
if (current) return current;
const { blocks, containers } = await firstValueFrom(forkJoin({
blocks: this.blocksCallService.retrieveAllBlocksTypes(),
containers: this.blocksCallService.retrieveAllContainerTypes()
}));
const blockTypes = [...blocks, ...containers];
const blockTypes = await firstValueFrom(this.blocksCallService.retrieveAllBlocksTypes());
this._blockTypes.set(blockTypes);
this.clearEmptyBlockCache();
return blockTypes.find((blockType) => blockType.type === typeName);
}
createEmptyBlock(blockType: BlockTypeName, family?: NodeFamily) {
const cacheKey = `${family ?? 'auto'}:${String(blockType)}`;
createEmptyBlock(blockType: BlockTypeName) {
const cacheKey = String(blockType);
const cached = this.emptyBlockCache.get(cacheKey);
if (cached) {
return of(this.cloneEmptyNode(cached));
return of(this.cloneEmptyBlock(cached));
}
const pending = this.pendingEmptyBlockRequests.get(cacheKey);
if (pending) {
return pending.pipe(map((block) => this.cloneEmptyNode(block)));
return pending.pipe(map((block) => this.cloneEmptyBlock(block)));
}
const request = this.createEmptyNodeRequest(blockType, family).pipe(
const request = this.blocksCallService.createEmptyBlock(blockType).pipe(
map((block) => {
this.emptyBlockCache.set(cacheKey, this.cloneEmptyNode(block));
this.emptyBlockCache.set(cacheKey, this.cloneEmptyBlock(block));
return block;
}),
finalize(() => {
@ -92,7 +90,7 @@ export class BlocksService {
this.pendingEmptyBlockRequests.set(cacheKey, request);
return request.pipe(
map((block) => this.cloneEmptyNode(block)),
map((block) => this.cloneEmptyBlock(block)),
catchError((err) => {
console.error('Create empty block failed', err);
return throwError(() => err);
@ -113,28 +111,12 @@ export class BlocksService {
);
}
validateContainerSubflow(subFlow: FlowData) {
return this.blocksCallService.validateContainerSubflow(this.deepClone(subFlow)).pipe(
catchError((err) => {
console.error('Validate container subflow failed', err);
return throwError(() => err);
})
);
}
private clearEmptyBlockCache() {
this.emptyBlockCache.clear();
this.pendingEmptyBlockRequests.clear();
}
private createEmptyNodeRequest(blockType: BlockTypeName, family?: NodeFamily): Observable<FlowNode> {
const normalizedFamily = family ?? this._blockTypes().find((type) => type.type === blockType)?.family ?? 'block';
return normalizedFamily === 'container'
? this.blocksCallService.createEmptyContainer(blockType)
: this.blocksCallService.createEmptyBlock(blockType);
}
private cloneEmptyNode(block: FlowNode): FlowNode {
private cloneEmptyBlock(block: FlowBlock): FlowBlock {
const clone = this.deepClone(block);
return {
...clone,

View File

@ -0,0 +1,10 @@
import { BlockType, BlockTypeName, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow";
import { Observable } from "rxjs";
export abstract class ContainersCallServiceBase {
abstract retrieveAllContainerTypes(): Observable<BlockType[]>;
abstract createEmptyContainer(containerType: BlockTypeName): Observable<FlowContainer>;
abstract validateContainerSubflow(subFlow: FlowData): Observable<FlowSubflowValidationResult>;
}

View File

@ -0,0 +1,114 @@
import { BlockType, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow";
import { Observable, of } from "rxjs";
import { ContainersCallServiceBase } from "./container-call.base";
export class ContainersCallServiceFake extends ContainersCallServiceBase {
private readonly containerTypes: BlockType[] = [
{
type: "GenericContainer",
family: "container",
description: "Container node with an embedded validated subflow",
userInteractive: false,
configurationType: "GenericContainerConfiguration",
configurationClass: "it.cnr.isti.workflow.manager.blocks.configurations.GenericContainerConfiguration",
schema: {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "GenericContainerConfiguration",
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": ["GenericContainerConfiguration"],
"default": "GenericContainerConfiguration"
},
"name": {
"type": "string",
"default": "Container"
},
"subFlow": {
"type": "object",
"default": {
"blocks": [],
"containers": [],
"connections": []
}
},
"publicInputs": {
"type": "array",
"default": []
},
"publicOutputs": {
"type": "array",
"default": []
}
},
"required": ["type", "name"]
}
}
];
override retrieveAllContainerTypes(): Observable<BlockType[]> {
return of(this.containerTypes);
}
override createEmptyContainer(containerType: string): Observable<FlowContainer> {
const descriptor = this.containerTypes.find((container) => container.type === containerType);
return of({
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`,
name: String((descriptor?.schema as any)?.properties?.name?.default ?? containerType),
position: undefined,
inputs: [],
outputs: [],
specificConfiguration: {
type: "GenericContainerConfiguration",
name: "Container",
subFlow: {
blocks: [],
containers: [],
connections: []
},
publicInputs: [],
publicOutputs: []
},
typeName: descriptor?.type ?? containerType,
nodeFamily: 'container'
});
}
override validateContainerSubflow(subFlow: FlowData): Observable<FlowSubflowValidationResult> {
const blocks = Array.isArray(subFlow?.blocks) ? subFlow.blocks : [];
const containers = Array.isArray(subFlow?.containers) ? subFlow.containers : [];
if (!blocks.length && !containers.length) {
return of({
valid: false,
errors: [{ entity: 'flow', field: 'blocks', message: 'Subflow cannot be empty' }],
openInputs: [],
openOutputs: []
});
}
const nestedContainer = containers.find((container) => container?.typeName === 'GenericContainer');
if (nestedContainer) {
return of({
valid: false,
errors: [{
entity: 'container',
id: nestedContainer.id,
field: 'type',
message: 'Nested GenericContainer nodes are not supported'
}],
openInputs: [],
openOutputs: []
});
}
return of({
valid: true,
errors: [],
openInputs: [],
openOutputs: []
});
}
}

View File

@ -0,0 +1,165 @@
import { HttpClient } from "@angular/common/http";
import { inject } from "@angular/core";
import { environment } from "@environment";
import {
BlockType,
FlowContainer,
FlowData,
FlowSubflowValidationResult
} from "@models/flow";
import { map, Observable } from "rxjs";
import { ContainersCallServiceBase } from "./container-call.base";
export class ContainersCallService extends ContainersCallServiceBase {
private readonly http = inject(HttpClient);
override retrieveAllContainerTypes(): Observable<BlockType[]> {
return this.http
.get<unknown[]>(`${environment.apiUrl}/containers/types`)
.pipe(
map((raw) => (Array.isArray(raw) ? raw.map((value) => this.containerTypeFromApi(value)) : []))
);
}
override createEmptyContainer(containerType: string): Observable<FlowContainer> {
return this.http
.get<unknown>(`${environment.apiUrl}/containers/types/${encodeURIComponent(containerType)}/example`)
.pipe(map((raw) => this.flowContainerFromApi(raw, containerType)));
}
override validateContainerSubflow(subFlow: FlowData): Observable<FlowSubflowValidationResult> {
return this.http
.post<unknown>(
`${environment.apiUrl}/containers/types/GenericContainer/validate-subflow`,
{ subFlow }
)
.pipe(map((raw) => this.subflowValidationFromApi(raw)));
}
private containerTypeFromApi(raw: unknown): BlockType {
const value = this.toRecord(raw);
return {
type: String(value["type"] ?? value["containerType"] ?? value["name"] ?? "GenericContainer"),
family: 'container',
description: String(value["description"] ?? ""),
userInteractive: Boolean(value["userInteractive"] ?? value["interactive"] ?? false),
hasExampleBlock: Boolean(value["hasExampleBlock"] ?? false),
exampleBlockEndpoint: this.toApiPath(value["exampleBlockEndpoint"]),
configurationType: this.toNullableString(value["configurationType"]),
configurationClass: this.toNullableString(value["configurationClass"]),
schema: this.toSchema(value["schema"] ?? value["configurationSchema"] ?? null)
};
}
private flowContainerFromApi(raw: unknown, fallbackTypeName = "GenericContainer"): FlowContainer {
const root = this.toRecord(raw);
const value = this.toRecord(root["container"] ?? root["node"] ?? root["data"] ?? root);
const specificConfigurationRaw = value["specificConfiguration"] ?? value["configuration"] ?? value["containerConfiguration"] ?? {};
const specificConfiguration = this.toRecord(specificConfigurationRaw);
const typeName = String(value["typeName"] ?? value["containerType"] ?? specificConfiguration["typeName"] ?? fallbackTypeName);
return {
id: String(value["id"] ?? crypto.randomUUID()),
name: String(value["name"] ?? specificConfiguration["name"] ?? typeName),
position: this.toPosition(value["position"]),
inputs: this.toPorts(value["inputs"]),
outputs: this.toPorts(value["outputs"]),
specificConfiguration,
typeName,
nodeFamily: 'container'
};
}
private subflowValidationFromApi(raw: unknown): FlowSubflowValidationResult {
const value = this.toRecord(raw);
const rawErrors = Array.isArray(value['errors']) ? value['errors'] : [];
return {
valid: Boolean(value['valid'] ?? false),
errors: rawErrors
.map((item) => this.toRecord(item))
.map((item) => ({
entity: this.toNullableString(item['entity']) ?? undefined,
id: this.toNullableString(item['id']) ?? undefined,
field: this.toNullableString(item['field']) ?? undefined,
message: String(item['message'] ?? 'Invalid subflow')
})),
openInputs: this.toPorts(value['openInputs']).map((port) => ({
...port,
targetBlockId: this.toNullableString(this.toRecord(port)['targetBlockId']) ?? undefined,
targetInputName: this.toNullableString(this.toRecord(port)['targetInputName']) ?? undefined,
blockId: this.toNullableString(this.toRecord(port)['blockId']) ?? undefined,
inputName: this.toNullableString(this.toRecord(port)['inputName']) ?? undefined
})),
openOutputs: this.toPorts(value['openOutputs']).map((port) => ({
...port,
sourceBlockId: this.toNullableString(this.toRecord(port)['sourceBlockId']) ?? undefined,
sourceOutputName: this.toNullableString(this.toRecord(port)['sourceOutputName']) ?? undefined,
blockId: this.toNullableString(this.toRecord(port)['blockId']) ?? undefined,
outputName: this.toNullableString(this.toRecord(port)['outputName']) ?? undefined
}))
};
}
private toPorts(raw: unknown) {
if (!Array.isArray(raw)) return [];
return raw
.map((port) => this.toRecord(port))
.filter((port) => typeof port["name"] === "string" && (port["name"] as string).length > 0)
.map((port) => {
const type = String(port["type"] ?? "TEXT");
const multiple = Boolean(port["multiple"] ?? false);
return {
...port,
name: String(port["name"]),
type,
multiple,
valueKinds: this.toValueKinds(port["valueKinds"], { type, multiple })
};
});
}
private toValueKinds(raw: unknown, fallback: { type: string; multiple: boolean }) {
if (!Array.isArray(raw)) {
return [{ type: fallback.type, multiple: fallback.multiple }];
}
const kinds = raw
.map((item) => this.toRecord(item))
.filter((item) => typeof item["type"] === "string")
.map((item) => ({
type: String(item["type"] ?? fallback.type),
multiple: Boolean(item["multiple"] ?? false)
}));
return kinds.length ? kinds : [{ type: fallback.type, multiple: fallback.multiple }];
}
private toPosition(raw: unknown): { x: number; y: number } | undefined {
const value = this.toRecord(raw);
const x = value["x"];
const y = value["y"];
if (typeof x !== "number" || typeof y !== "number") return undefined;
return { x, y };
}
private toSchema(raw: unknown): Record<string, unknown> | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
return raw as Record<string, unknown>;
}
private toRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
private toNullableString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
private toApiPath(value: unknown): string | null {
if (typeof value !== "string" || value.length === 0) return null;
if (/^https?:\/\//.test(value)) return value;
return `${environment.apiUrl}${value.startsWith("/") ? value : `/${value}`}`;
}
}

View File

@ -0,0 +1,130 @@
import { computed, Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { BlockType, BlockTypeName, FlowData, FlowNode } from '@models/flow';
import { catchError, finalize, firstValueFrom, map, Observable, of, shareReplay, throwError } from 'rxjs';
import { ContainersCallServiceBase } from './container-call.base';
@Injectable({
providedIn: 'root',
})
export class ContainersService {
containersCallService: ContainersCallServiceBase = new environment.containersCallService();
toInit = true;
private loadingPromise: Promise<void> | null = null;
private readonly emptyContainerCache = new Map<string, FlowNode>();
private readonly pendingEmptyContainerRequests = new Map<string, Observable<FlowNode>>();
private readonly pendingServerSyncCount = signal(0);
private _containerTypes = signal<BlockType[]>([]);
readonly hasPendingServerSync = computed(() => this.pendingServerSyncCount() > 0);
readonly containerTypes = this._containerTypes.asReadonly();
hasLoadedContainerTypes() {
return this._containerTypes().length > 0 || !this.toInit;
}
async getAllContainerTypes() {
if (this.toInit) {
await this.refresh();
this.toInit = false;
}
return this._containerTypes.asReadonly();
}
async refresh(force = false): Promise<void> {
if (this.loadingPromise && !force) {
return this.loadingPromise;
}
this.loadingPromise = firstValueFrom(this.containersCallService.retrieveAllContainerTypes())
.then((containerTypes) => {
this._containerTypes.set(containerTypes);
this.clearEmptyContainerCache();
})
.catch((err) => {
console.error('Retrieve container types failed', err);
throw err;
})
.finally(() => {
this.loadingPromise = null;
});
return this.loadingPromise;
}
async getContainerType(typeName: BlockTypeName) {
const current = this._containerTypes().find((containerType) => containerType.type === typeName);
if (current) return current;
const containerTypes = await firstValueFrom(this.containersCallService.retrieveAllContainerTypes());
this._containerTypes.set(containerTypes);
this.clearEmptyContainerCache();
return containerTypes.find((containerType) => containerType.type === typeName);
}
createEmptyContainer(containerType: BlockTypeName) {
const cacheKey = String(containerType);
const cached = this.emptyContainerCache.get(cacheKey);
if (cached) {
return of(this.cloneEmptyNode(cached));
}
const pending = this.pendingEmptyContainerRequests.get(cacheKey);
if (pending) {
return pending.pipe(map((container) => this.cloneEmptyNode(container)));
}
const request = this.containersCallService.createEmptyContainer(containerType).pipe(
map((container) => {
this.emptyContainerCache.set(cacheKey, this.cloneEmptyNode(container));
return container;
}),
finalize(() => {
this.pendingEmptyContainerRequests.delete(cacheKey);
}),
shareReplay(1)
);
this.pendingEmptyContainerRequests.set(cacheKey, request);
return request.pipe(
map((container) => this.cloneEmptyNode(container)),
catchError((err) => {
console.error('Create empty container failed', err);
return throwError(() => err);
})
);
}
validateContainerSubflow(subFlow: FlowData) {
return this.containersCallService.validateContainerSubflow(this.deepClone(subFlow)).pipe(
catchError((err) => {
console.error('Validate container subflow failed', err);
return throwError(() => err);
})
);
}
private clearEmptyContainerCache() {
this.emptyContainerCache.clear();
this.pendingEmptyContainerRequests.clear();
}
private cloneEmptyNode(node: FlowNode): FlowNode {
const clone = this.deepClone(node);
return {
...clone,
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`,
position: undefined
};
}
private deepClone<T>(value: T): T {
if (typeof globalThis.structuredClone === 'function') {
return globalThis.structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
}

View File

@ -6,9 +6,9 @@
<div class="blocks-list-root">
<div class="blocks-list-search">
<mat-form-field appearance="outline">
<mat-label>Search nodes</mat-label>
<mat-label>Search blocks</mat-label>
<mat-icon matPrefix fontIcon="search"></mat-icon>
<input matInput placeholder="Search blocks and containers..." [(ngModel)]="searchTerm" />
<input matInput placeholder="Search blocks..." [(ngModel)]="searchTerm" />
</mat-form-field>
</div>
@ -31,7 +31,7 @@
(dragstart)="onDragStart($event, block)">
<mat-card-header class="blocks-list-card-header">
<div matCardAvatar class="blocks-list-card-avatar">
<mat-icon [fontIcon]="block.family === 'container' ? 'inventory_2' : (block.userInteractive ? 'touch_app' : 'auto_awesome')"></mat-icon>
<mat-icon [fontIcon]="block.userInteractive ? 'touch_app' : 'auto_awesome'"></mat-icon>
</div>
<div class="blocks-list-card-heading">
<div class="blocks-list-card-title-row">
@ -48,7 +48,7 @@
</button>
</div>
<mat-card-subtitle class="blocks-list-card-subtitle">
{{ block.family === 'container' ? 'Container' : (block.userInteractive ? 'Interactive' : 'Automatic') }}
{{ block.userInteractive ? 'Interactive' : 'Automatic' }}
</mat-card-subtitle>
</div>
</mat-card-header>
@ -57,7 +57,7 @@
</mat-card-content>
<mat-card-actions class="blocks-list-card-actions" align="end">
<mat-chip class="blocks-list-card-chip" [class.blocks-list-card-chip-interactive]="block.userInteractive">
{{ block.family === 'container' ? 'Container node' : (block.userInteractive ? 'Human step' : 'Automated step') }}
{{ block.userInteractive ? 'Human step' : 'Automated step' }}
</mat-chip>
</mat-card-actions>
</mat-card>

View File

@ -48,6 +48,13 @@ export class BlocksList extends ListStateViewHolder<BlockType> {
return;
}
if (this.blocksService.hasLoadedBlockTypes()) {
this.blockTypes = this.blocksService.blockTypes;
this.view.list = this.blockTypes;
this.loading.set(false);
return;
}
this.blocksService.getAllBlocksTypes().then((blockTypesSignal) => {
this.blockTypes = blockTypesSignal;
this.view.list = this.blockTypes;

View File

@ -0,0 +1,198 @@
:host {
display: flex;
flex: 1 1 auto;
min-height: 0;
height: 100%;
}
.blocks-list-loading {
display: flex;
align-items: center;
justify-content: center;
height: 8rem;
}
.blocks-list-root {
display: flex;
flex: 1 1 auto;
flex-direction: column;
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
overflow-x: hidden;
}
.blocks-list-search {
width: 100%;
padding: 8px 0;
}
.blocks-list-empty {
display: flex;
flex: 1;
flex-direction: column;
padding-top: 2.5rem;
margin-top: 2.5rem;
color: #94a3b8;
text-align: center;
}
.blocks-list-items {
display: flex;
flex: 1 1 auto;
flex-direction: column;
align-items: stretch;
gap: 8px;
width: 100%;
min-width: 0;
min-height: 0;
box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
padding: 8px 0 20px;
}
.blocks-list-card {
display: block;
flex-shrink: 0;
width: 100%;
min-width: 0;
overflow: hidden;
cursor: grab;
border: 1px solid #dbe7f5;
border-radius: 16px;
background:
radial-gradient(circle at top right, rgba(56, 189, 248, 0.12), transparent 34%),
linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
box-shadow:
0 12px 26px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
}
.blocks-list-card--container {
border-color: #bae6fd;
background:
radial-gradient(circle at top right, rgba(14, 165, 233, 0.16), transparent 34%),
linear-gradient(180deg, #ffffff 0%, #f0f9ff 100%);
}
.blocks-list-card:active {
cursor: grabbing;
}
.blocks-list-card:hover {
transform: translateY(-2px);
border-color: #38bdf8;
box-shadow: 0 18px 30px rgba(14, 116, 144, 0.14);
}
.blocks-list-card-header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 14px 0;
}
.blocks-list-card-heading {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
}
.blocks-list-card-title-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.blocks-list-card-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
margin: 0;
border-radius: 12px;
color: #075985;
background: linear-gradient(180deg, #e0f2fe 0%, #bae6fd 100%);
}
.blocks-list-card-avatar--container {
color: #155e75;
background: linear-gradient(180deg, #cffafe 0%, #a5f3fc 100%);
}
.blocks-list-card-avatar .mat-icon {
width: 22px;
height: 22px;
font-size: 22px;
}
.blocks-list-card-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.blocks-list-card-info {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(224, 242, 254, 0.95);
color: #0369a1;
cursor: default;
flex-shrink: 0;
}
.blocks-list-card-info .mat-icon {
width: 14px;
height: 14px;
font-size: 14px;
}
.blocks-list-card-subtitle {
color: #475569;
font-size: 12px;
}
.blocks-list-card-content {
padding: 0 14px 8px;
}
.blocks-list-card-copy {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: #64748b;
}
.blocks-list-card-actions {
padding: 0 14px 14px !important;
margin: 0;
}
.blocks-list-card-chip {
min-height: 28px;
font-size: 12px;
font-weight: 600;
}
.blocks-list-card-chip-container {
color: #0c4a6e !important;
background: rgba(224, 242, 254, 0.95) !important;
border: 1px solid rgba(56, 189, 248, 0.72);
}

View File

@ -0,0 +1,62 @@
@if (loading()) {
<div class="blocks-list-loading">
<mat-spinner diameter="32"></mat-spinner>
</div>
} @else {
<div class="blocks-list-root">
<div class="blocks-list-search">
<mat-form-field appearance="outline">
<mat-label>Search containers</mat-label>
<mat-icon matPrefix fontIcon="search"></mat-icon>
<input matInput placeholder="Search containers..." [(ngModel)]="searchTerm" />
</mat-form-field>
</div>
@if (filteredContainers().length === 0) {
<div class="blocks-list-empty">
No containers found
</div>
} @else {
<div class="blocks-list-items">
@for (container of filteredContainers(); track container.type) {
<mat-card
class="blocks-list-card blocks-list-card--container"
draggable="true"
(dragstart)="onDragStart($event, container)">
<mat-card-header class="blocks-list-card-header">
<div matCardAvatar class="blocks-list-card-avatar blocks-list-card-avatar--container">
<mat-icon fontIcon="inventory_2"></mat-icon>
</div>
<div class="blocks-list-card-heading">
<div class="blocks-list-card-title-row">
<mat-card-title class="blocks-list-card-title">{{ container.type }}</mat-card-title>
<button
type="button"
class="blocks-list-card-info"
matTooltip="{{ container.description }}"
aria-label="Show container description"
title=""
(pointerdown)="$event.stopPropagation()"
(click)="$event.stopPropagation()">
<mat-icon fontIcon="info"></mat-icon>
</button>
</div>
<mat-card-subtitle class="blocks-list-card-subtitle">
Container
</mat-card-subtitle>
</div>
</mat-card-header>
<mat-card-content class="blocks-list-card-content">
<p class="blocks-list-card-copy">{{ container.description }}</p>
</mat-card-content>
<mat-card-actions class="blocks-list-card-actions" align="end">
<mat-chip class="blocks-list-card-chip blocks-list-card-chip-container">
Container node
</mat-chip>
</mat-card-actions>
</mat-card>
}
</div>
}
</div>
}

View File

@ -0,0 +1,76 @@
import { Component, computed, inject, model, signal, Signal, WritableSignal } from '@angular/core';
import { BlockType } from '@models/flow';
import { ContainersService } from '@services/containers/containers';
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
import { FormsModule } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
@Component({
selector: 'app-containers-list',
imports: [FormsModule, MatCardModule, MatChipsModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTooltipModule],
templateUrl: './containers-list.html',
styleUrl: './containers-list.css',
})
export class ContainersList extends ListStateViewHolder<BlockType> {
searchTerm = model<string>('');
private containersService = inject(ContainersService);
loading: WritableSignal<boolean> = signal(true);
containerTypes?: Signal<BlockType[]>;
constructor() {
super('containersList', {
defaultOrder: { orderBy: 'name', orderDir: 'asc' } as OrderViewState
});
}
ngOnInit() {
const existingState = this.view;
if (existingState.list) {
this.containerTypes = existingState.list;
this.loading.set(false);
return;
}
if (this.containersService.hasLoadedContainerTypes()) {
this.containerTypes = this.containersService.containerTypes;
this.view.list = this.containerTypes;
this.loading.set(false);
return;
}
this.containersService.getAllContainerTypes().then((containerTypesSignal) => {
this.containerTypes = containerTypesSignal;
this.view.list = this.containerTypes;
}).catch((err) => {
console.error('Error loading container types', err);
}).finally(() => {
this.loading.set(false);
});
}
filteredContainers = computed(() => {
const containers = this.containerTypes ? this.containerTypes() : [];
if (!containers) return [];
const term = this.searchTerm().toLowerCase();
return containers.filter((container) =>
container.type.toLowerCase().includes(term) || container.description.toLowerCase().includes(term)
);
});
onDragStart(event: DragEvent, container: BlockType) {
if (!event.dataTransfer) return;
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData(BLOCK_TYPE_DRAG_MIME, JSON.stringify(container));
event.dataTransfer.setData('text/plain', container.type);
}
}

View File

@ -1,4 +1,5 @@
.container-node {
position: relative;
width: 340px;
border: 1px solid #cbd5e1;
border-radius: 18px;
@ -10,6 +11,15 @@
overflow: hidden;
}
.container-node__delete-overlay {
position: absolute;
inset: 0;
z-index: 12;
border-radius: 18px;
background: rgba(148, 163, 184, 0.38);
backdrop-filter: grayscale(1) saturate(0.2);
}
:host.selected .container-node {
border-color: #0f766e;
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.18), 0 16px 36px rgba(15, 23, 42, 0.18);
@ -66,6 +76,108 @@
color: #f8fafc;
}
.container-node__header-actions {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
position: relative;
}
.container-node__warning-wrap {
position: relative;
}
.container-node__warning {
width: 26px;
height: 26px;
border-radius: 999px;
border: 1px solid #99f6e4;
background: #14b8a6;
color: #ecfeff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.22);
}
.container-node__warning-tooltip {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 220px;
max-width: 320px;
border: 1px solid #99f6e4;
border-radius: 8px;
background: #f0fdfa;
color: #115e59;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
padding: 8px;
z-index: 13;
opacity: 0;
visibility: hidden;
transform: translateY(-2px);
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease;
}
.container-node__warning-wrap:hover .container-node__warning-tooltip {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.container-node__warning-title {
font-size: 11px;
font-weight: 700;
margin-bottom: 4px;
}
.container-node__warning-item {
font-size: 11px;
line-height: 1.3;
}
.container-node__delete-confirm {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 14;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid #fecaca;
background: #fff1f2;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.22);
}
.container-node__delete-confirm-text {
font-size: 12px;
font-weight: 700;
color: #7f1d1d;
}
.container-node__delete-confirm-cancel,
.container-node__delete-confirm-action {
border: 0;
border-radius: 999px;
padding: 6px 10px;
font-size: 11px;
font-weight: 700;
}
.container-node__delete-confirm-cancel {
background: #ffffff;
color: #7f1d1d;
}
.container-node__delete-confirm-action {
background: #dc2626;
color: #ffffff;
}
.container-node__ports {
display: grid;
grid-template-columns: 1fr 1fr;

View File

@ -1,4 +1,7 @@
<div class="container-node" [class.container-node-assigning]="isAssigning">
<div class="container-node" [class.container-node-assigning]="isAssigning" [class.container-node-delete-pending]="deleteConfirmOpen">
@if (deleteConfirmOpen) {
<div class="container-node__delete-overlay"></div>
}
<div class="container-node__header">
<div class="container-node__icon">
<i class="bi bi-box-seam"></i>
@ -7,9 +10,31 @@
<div class="container-node__eyebrow">Generic Container</div>
<div class="container-node__name">{{ name }}</div>
</div>
<button type="button" class="container-node__delete" title="Delete node" (pointerdown)="$event.stopPropagation()" (click)="deleteNode($event)">
<i class="bi bi-trash"></i>
</button>
<div class="container-node__header-actions">
@if (missingRequiredParams.length) {
<div class="container-node__warning-wrap">
<div class="container-node__warning">
<i class="bi bi-exclamation-triangle-fill"></i>
</div>
<div class="container-node__warning-tooltip">
<div class="container-node__warning-title">Missing required fields</div>
@for (missing of missingRequiredParams; track missing) {
<div class="container-node__warning-item">{{ missing }}</div>
}
</div>
</div>
}
@if (deleteConfirmOpen) {
<div class="container-node__delete-confirm" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<span class="container-node__delete-confirm-text">Delete container?</span>
<button type="button" class="container-node__delete-confirm-cancel" (click)="cancelDelete($event)">Cancel</button>
<button type="button" class="container-node__delete-confirm-action" (click)="deleteNode($event)">Delete</button>
</div>
}
<button type="button" class="container-node__delete" title="Delete node" (pointerdown)="$event.stopPropagation()" (click)="deleteNode($event)">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="container-node__ports">

View File

@ -20,6 +20,7 @@ import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag';
export class ContainerNodeComponent {
private editorState = inject(EditorStateHolder);
private subflowPreview = inject(SubflowPreviewDialogService);
deleteConfirmOpen = false;
@Input() data!: any;
@Input() emit!: (data: any) => void;
@ -112,6 +113,24 @@ export class ContainerNodeComponent {
return this.data?.data?.['__containerAssigning'] === true;
}
get missingRequiredParams() {
const missing: string[] = [];
if (!this.name.trim()) {
missing.push('Name');
}
if (!this.subFlow) {
missing.push('Sub Flow');
}
const config = this.configuration ?? {};
if (!Array.isArray(config['publicInputs'])) {
missing.push('Public Inputs');
}
if (!Array.isArray(config['publicOutputs'])) {
missing.push('Public Outputs');
}
return missing;
}
inputDisplayLabel(inputKey: string) {
return pathToLabel(inputKey);
}
@ -168,6 +187,10 @@ export class ContainerNodeComponent {
deleteNode(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!this.deleteConfirmOpen) {
this.deleteConfirmOpen = true;
return;
}
const remove = this.data?.data?.deleteNode;
if (typeof remove === 'function') {
@ -175,6 +198,12 @@ export class ContainerNodeComponent {
}
}
cancelDelete(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.deleteConfirmOpen = false;
}
openSubflowPreview(event?: Event) {
event?.preventDefault();
event?.stopPropagation();

View File

@ -1,6 +1,7 @@
import { Component, ElementRef, Injector, input, OnChanges, OnDestroy, output, signal, SimpleChanges, ViewChild } from '@angular/core';
import { BlockType, FlowData, FlowNode } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
import { CONTAINER_SUBFLOW_DRAG_MIME } from '@shared/nodes/container-node/container-node-drag';
import { EditorStateHolder } from '@stores/flow-editor';
@ -22,7 +23,8 @@ export class ReteEditor implements OnChanges, OnDestroy {
constructor(
private injector: Injector,
private flowState: EditorStateHolder,
private blocksService: BlocksService
private blocksService: BlocksService,
private containersService: ContainersService
) {}
@ViewChild("editor") container!: ElementRef;
@ -95,7 +97,9 @@ export class ReteEditor implements OnChanges, OnDestroy {
this.creatingEmptyBlock = true;
this.creatingEmptyBlockType = blockType.type;
try {
newBlock = await firstValueFrom(this.blocksService.createEmptyBlock(blockType.type, blockType.family));
newBlock = blockType.family === 'container'
? await firstValueFrom(this.containersService.createEmptyContainer(blockType.type))
: await firstValueFrom(this.blocksService.createEmptyBlock(blockType.type));
} catch (error) {
console.error('Failed to create empty block', error);
return;

View File

@ -19,6 +19,7 @@ import {
normalizeFlowPortValueKinds
} from "@models/flow";
import { BlocksService } from "@services/blocks/blocks";
import { ContainersService } from "@services/containers/containers";
import { NodeSettingsDialogService } from "@services/dialogs/node-settings-dialog";
import { EditorStateHolder } from "@stores/flow-editor";
import { ContainerNodeComponent } from "@shared/nodes/container-node/container-node";
@ -37,6 +38,7 @@ export type ReteEditorInstance = {
type ReteRuntimeContext = {
blocksService: BlocksService;
containersService: ContainersService;
flowState: EditorStateHolder;
settingsDialog: NodeSettingsDialogService;
};
@ -55,6 +57,7 @@ export async function createEditor(
const nodeView = options?.nodeView ?? "editor";
const runtime: ReteRuntimeContext = {
blocksService: injector.get(BlocksService),
containersService: injector.get(ContainersService),
flowState: injector.get(EditorStateHolder),
settingsDialog: injector.get(NodeSettingsDialogService)
};
@ -255,7 +258,7 @@ export async function addBlockToEditor(
};
await area.update("node", node.id);
resolvedRuntime.blocksService.validateContainerSubflow(candidateSubFlow).subscribe({
resolvedRuntime.containersService.validateContainerSubflow(candidateSubFlow).subscribe({
next: async (result) => {
const liveNode = editor.getNode(node.id) as HFNode | undefined;
if (!liveNode?.data) return;

View File

@ -1,6 +1,7 @@
import { AssistantCallServiceFake } from "@services/assistant/assistant-call.fake";
import { AuthorizationCallFakeService } from "@services/authorization/authorization-call.fake";
import { BlocksCallServiceFake } from "@services/blocks/blocks-call.fake";
import { ContainersCallServiceFake } from "@services/containers/containers-call.fake";
import { FlowsCallServiceFake } from "@services/flows/flows-call.fake";
import { FieldRetrieverCallServiceFake } from "@services/retriever/field-retriever-call.fake";
import { TaskExecutionsCallServiceFake } from "@services/task-executions/task-executions-call.fake";
@ -13,6 +14,7 @@ export const environment = {
assistantCallService: AssistantCallServiceFake,
flowsCallService: FlowsCallServiceFake,
blocksCallService: BlocksCallServiceFake,
containersCallService: ContainersCallServiceFake,
fieldRetrieverCallService: FieldRetrieverCallServiceFake,
taskExecutionsCallService: TaskExecutionsCallServiceFake
};

View File

@ -1,6 +1,7 @@
import { AssistantCallService } from "@services/assistant/assistant-call";
import { AuthorizationCallService } from "@services/authorization/authorization-call";
import { BlocksCallService } from "@services/blocks/blocks-call";
import { ContainersCallService } from "@services/containers/containers-call";
import { FlowsCallService } from "@services/flows/flows-call";
import { FieldRetrieverCallService } from "@services/retriever/field-retriever-call";
import { TaskExecutionsCallService } from "@services/task-executions/task-executions-call";
@ -13,6 +14,7 @@ export const environment = {
authorizationCallService: AuthorizationCallService,
flowsCallService: FlowsCallService,
blocksCallService: BlocksCallService,
containersCallService: ContainersCallService,
fieldRetrieverCallService: FieldRetrieverCallService,
taskExecutionsCallService: TaskExecutionsCallService
};

View File

@ -1,6 +1,7 @@
import { AssistantCallService } from "@services/assistant/assistant-call";
import { AuthorizationCallService } from "@services/authorization/authorization-call";
import { BlocksCallService } from "@services/blocks/blocks-call";
import { ContainersCallService } from "@services/containers/containers-call";
import { FlowsCallService } from "@services/flows/flows-call";
import { FieldRetrieverCallService } from "@services/retriever/field-retriever-call";
import { TaskExecutionsCallService } from "@services/task-executions/task-executions-call";
@ -13,6 +14,7 @@ export const environment = {
assistantCallService: AssistantCallService,
flowsCallService: FlowsCallService,
blocksCallService: BlocksCallService,
containersCallService: ContainersCallService,
fieldRetrieverCallService: FieldRetrieverCallService,
taskExecutionsCallService: TaskExecutionsCallService
};