From 533a1e7f43cc37aad29133543ee56cba9abd5348 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Sun, 15 Mar 2026 06:07:10 +0100 Subject: [PATCH] Split containers into dedicated services and sidebar list --- .../main/editor-sidebar/editor-sidebar.html | 23 +- .../main/editor-sidebar/editor-sidebar.ts | 5 +- src/app/services/blocks/block-call.base.ts | 8 +- src/app/services/blocks/blocks-call.fake.ts | 106 +--------- src/app/services/blocks/blocks-call.ts | 106 +--------- src/app/services/blocks/blocks.ts | 60 ++---- .../containers/container-call.base.ts | 10 + .../containers/containers-call.fake.ts | 114 ++++++++++ .../services/containers/containers-call.ts | 165 +++++++++++++++ src/app/services/containers/containers.ts | 130 ++++++++++++ src/app/shared/blocks-list/blocks-list.html | 10 +- src/app/shared/blocks-list/blocks-list.ts | 7 + .../containers-list/containers-list.css | 198 ++++++++++++++++++ .../containers-list/containers-list.html | 62 ++++++ .../shared/containers-list/containers-list.ts | 76 +++++++ .../nodes/container-node/container-node.css | 112 ++++++++++ .../nodes/container-node/container-node.html | 33 ++- .../nodes/container-node/container-node.ts | 29 +++ src/app/shared/rete-editor/rete-editor.ts | 8 +- src/app/utilities/rete-editor.ts | 5 +- src/environments/environment.development.ts | 2 + src/environments/environment.staging.ts | 2 + src/environments/environment.ts | 2 + 23 files changed, 1011 insertions(+), 262 deletions(-) create mode 100644 src/app/services/containers/container-call.base.ts create mode 100644 src/app/services/containers/containers-call.fake.ts create mode 100644 src/app/services/containers/containers-call.ts create mode 100644 src/app/services/containers/containers.ts create mode 100644 src/app/shared/containers-list/containers-list.css create mode 100644 src/app/shared/containers-list/containers-list.html create mode 100644 src/app/shared/containers-list/containers-list.ts diff --git a/src/app/pages/main/editor-sidebar/editor-sidebar.html b/src/app/pages/main/editor-sidebar/editor-sidebar.html index 45de2c1..9570b89 100644 --- a/src/app/pages/main/editor-sidebar/editor-sidebar.html +++ b/src/app/pages/main/editor-sidebar/editor-sidebar.html @@ -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')"> - + + @@ -51,6 +65,11 @@ } + @case ('containers') { + + + + } } diff --git a/src/app/pages/main/editor-sidebar/editor-sidebar.ts b/src/app/pages/main/editor-sidebar/editor-sidebar.ts index bc37b1e..6aa06e2 100644 --- a/src/app/pages/main/editor-sidebar/editor-sidebar.ts +++ b/src/app/pages/main/editor-sidebar/editor-sidebar.ts @@ -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] diff --git a/src/app/services/blocks/block-call.base.ts b/src/app/services/blocks/block-call.base.ts index 63b045d..e27b137 100644 --- a/src/app/services/blocks/block-call.base.ts +++ b/src/app/services/blocks/block-call.base.ts @@ -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; - abstract retrieveAllContainerTypes() : Observable; - abstract createEmptyBlock(blockType: BlockTypeName) : Observable; - abstract createEmptyContainer(containerType: BlockTypeName) : Observable; - abstract updateBlock(blockId : string, configuration : any) : Observable; - abstract validateContainerSubflow(subFlow: FlowData) : Observable; - } diff --git a/src/app/services/blocks/blocks-call.fake.ts b/src/app/services/blocks/blocks-call.fake.ts index 4f409f1..f526aa7 100644 --- a/src/app/services/blocks/blocks-call.fake.ts +++ b/src/app/services/blocks/blocks-call.fake.ts @@ -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 { return of(this.blockTypes); } - override retrieveAllContainerTypes(): Observable { - return of(this.containerTypes); - } - override createEmptyBlock(blockType: string): Observable { 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 { - const descriptor = this.containerTypes.find((b) => b.type === containerType); - const typeName = descriptor?.type ?? containerType ?? "GenericContainer"; - const schema = descriptor?.schema as Record | 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 { 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 { - 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 { diff --git a/src/app/services/blocks/blocks-call.ts b/src/app/services/blocks/blocks-call.ts index b7f666c..2086330 100644 --- a/src/app/services/blocks/blocks-call.ts +++ b/src/app/services/blocks/blocks-call.ts @@ -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 { return this.http .get(`${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 { - return this.http - .get(`${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 { 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(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(`${environment.apiUrl}/blocks`, payload) - .pipe(map((raw) => this.flowNodeFromApi(raw, descriptor?.type ?? blockType, 'block', payload) as FlowBlock)); - }) - ); - } - - override createEmptyContainer(containerType: string): Observable { - 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(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 { - return this.http - .post( - `${environment.apiUrl}/containers/types/GenericContainer/validate-subflow`, - { subFlow } - ) - .pipe(map((raw) => this.subflowValidationFromApi(raw))); - } - private getBlockTypesForCreate(): Observable { if (this.blockTypesCache) { return of(this.blockTypesCache); @@ -120,18 +84,11 @@ export class BlocksCallService extends BlocksCallServiceBase { return this.retrieveAllBlocksTypes(); } - private getContainerTypesForCreate(): Observable { - 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): FlowBlock { - return this.flowNodeFromApi(raw, fallbackTypeName, 'block', fallbackConfig) as FlowBlock; - } - - private flowNodeFromApi( - raw: unknown, - fallbackTypeName = "LLMBlock", - family: NodeFamily = 'block', - fallbackConfig?: Record - ) { 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 { diff --git a/src/app/services/blocks/blocks.ts b/src/app/services/blocks/blocks.ts index 5cba845..fc5bdee 100644 --- a/src/app/services/blocks/blocks.ts +++ b/src/app/services/blocks/blocks.ts @@ -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 | null = null; - private readonly emptyBlockCache = new Map(); - private readonly pendingEmptyBlockRequests = new Map>(); + private readonly emptyBlockCache = new Map(); + private readonly pendingEmptyBlockRequests = new Map>(); private readonly pendingServerSyncCount = signal(0); private _blockTypes = signal([]); 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 { - 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, diff --git a/src/app/services/containers/container-call.base.ts b/src/app/services/containers/container-call.base.ts new file mode 100644 index 0000000..ac2e64e --- /dev/null +++ b/src/app/services/containers/container-call.base.ts @@ -0,0 +1,10 @@ +import { BlockType, BlockTypeName, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow"; +import { Observable } from "rxjs"; + +export abstract class ContainersCallServiceBase { + abstract retrieveAllContainerTypes(): Observable; + + abstract createEmptyContainer(containerType: BlockTypeName): Observable; + + abstract validateContainerSubflow(subFlow: FlowData): Observable; +} diff --git a/src/app/services/containers/containers-call.fake.ts b/src/app/services/containers/containers-call.fake.ts new file mode 100644 index 0000000..9ff9175 --- /dev/null +++ b/src/app/services/containers/containers-call.fake.ts @@ -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 { + return of(this.containerTypes); + } + + override createEmptyContainer(containerType: string): Observable { + 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 { + 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: [] + }); + } +} diff --git a/src/app/services/containers/containers-call.ts b/src/app/services/containers/containers-call.ts new file mode 100644 index 0000000..422b1bd --- /dev/null +++ b/src/app/services/containers/containers-call.ts @@ -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 { + return this.http + .get(`${environment.apiUrl}/containers/types`) + .pipe( + map((raw) => (Array.isArray(raw) ? raw.map((value) => this.containerTypeFromApi(value)) : [])) + ); + } + + override createEmptyContainer(containerType: string): Observable { + return this.http + .get(`${environment.apiUrl}/containers/types/${encodeURIComponent(containerType)}/example`) + .pipe(map((raw) => this.flowContainerFromApi(raw, containerType))); + } + + override validateContainerSubflow(subFlow: FlowData): Observable { + return this.http + .post( + `${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 | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + return raw as Record; + } + + private toRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return value as Record; + } + + 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}`}`; + } +} diff --git a/src/app/services/containers/containers.ts b/src/app/services/containers/containers.ts new file mode 100644 index 0000000..7aaa2d1 --- /dev/null +++ b/src/app/services/containers/containers.ts @@ -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 | null = null; + private readonly emptyContainerCache = new Map(); + private readonly pendingEmptyContainerRequests = new Map>(); + private readonly pendingServerSyncCount = signal(0); + + private _containerTypes = signal([]); + 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 { + 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(value: T): T { + if (typeof globalThis.structuredClone === 'function') { + return globalThis.structuredClone(value); + } + return JSON.parse(JSON.stringify(value)) as T; + } +} diff --git a/src/app/shared/blocks-list/blocks-list.html b/src/app/shared/blocks-list/blocks-list.html index 5b68ca6..64b04a4 100644 --- a/src/app/shared/blocks-list/blocks-list.html +++ b/src/app/shared/blocks-list/blocks-list.html @@ -6,9 +6,9 @@
@@ -31,7 +31,7 @@ (dragstart)="onDragStart($event, block)">
- +
@@ -48,7 +48,7 @@
- {{ block.family === 'container' ? 'Container' : (block.userInteractive ? 'Interactive' : 'Automatic') }} + {{ block.userInteractive ? 'Interactive' : 'Automatic' }}
@@ -57,7 +57,7 @@ - {{ block.family === 'container' ? 'Container node' : (block.userInteractive ? 'Human step' : 'Automated step') }} + {{ block.userInteractive ? 'Human step' : 'Automated step' }} diff --git a/src/app/shared/blocks-list/blocks-list.ts b/src/app/shared/blocks-list/blocks-list.ts index 111624b..04c0ddf 100644 --- a/src/app/shared/blocks-list/blocks-list.ts +++ b/src/app/shared/blocks-list/blocks-list.ts @@ -48,6 +48,13 @@ export class BlocksList extends ListStateViewHolder { 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; diff --git a/src/app/shared/containers-list/containers-list.css b/src/app/shared/containers-list/containers-list.css new file mode 100644 index 0000000..89a8499 --- /dev/null +++ b/src/app/shared/containers-list/containers-list.css @@ -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); +} diff --git a/src/app/shared/containers-list/containers-list.html b/src/app/shared/containers-list/containers-list.html new file mode 100644 index 0000000..864f1dd --- /dev/null +++ b/src/app/shared/containers-list/containers-list.html @@ -0,0 +1,62 @@ +@if (loading()) { +
+ +
+} @else { +
+ + + @if (filteredContainers().length === 0) { +
+ No containers found +
+ } @else { +
+ @for (container of filteredContainers(); track container.type) { + + +
+ +
+
+
+ {{ container.type }} + +
+ + Container + +
+
+ +

{{ container.description }}

+
+ + + Container node + + +
+ } +
+ } +
+} diff --git a/src/app/shared/containers-list/containers-list.ts b/src/app/shared/containers-list/containers-list.ts new file mode 100644 index 0000000..559959c --- /dev/null +++ b/src/app/shared/containers-list/containers-list.ts @@ -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 { + searchTerm = model(''); + + private containersService = inject(ContainersService); + + loading: WritableSignal = signal(true); + containerTypes?: Signal; + + 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); + } +} diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css index 51cd432..4382a51 100644 --- a/src/app/shared/nodes/container-node/container-node.css +++ b/src/app/shared/nodes/container-node/container-node.css @@ -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; diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index 355e832..169d3fc 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -1,4 +1,7 @@ -
+
+ @if (deleteConfirmOpen) { +
+ }
@@ -7,9 +10,31 @@
Generic Container
{{ name }}
- +
+ @if (missingRequiredParams.length) { +
+
+ +
+
+
Missing required fields
+ @for (missing of missingRequiredParams; track missing) { +
{{ missing }}
+ } +
+
+ } + @if (deleteConfirmOpen) { +
+ Delete container? + + +
+ } + +
diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index dc91681..baea90e 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -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(); diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts index b977a27..e1063b8 100644 --- a/src/app/shared/rete-editor/rete-editor.ts +++ b/src/app/shared/rete-editor/rete-editor.ts @@ -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; diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index a3f71ab..04e7f40 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -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; diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 681b192..5787cac 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -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 }; diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts index aeae932..1cee041 100644 --- a/src/environments/environment.staging.ts +++ b/src/environments/environment.staging.ts @@ -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 }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 470a800..9561845 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -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 };