diff --git a/src/app/services/dialogs/subflow-preview-dialog.ts b/src/app/services/dialogs/subflow-preview-dialog.ts index 2e0ee8e..8f5c0ae 100644 --- a/src/app/services/dialogs/subflow-preview-dialog.ts +++ b/src/app/services/dialogs/subflow-preview-dialog.ts @@ -4,6 +4,7 @@ import { FlowData } from '@models/flow'; type SubflowPreviewDialogState = { title: string; flowData: FlowData; + sourceName: string; }; @Injectable({ providedIn: 'root' }) @@ -12,14 +13,29 @@ export class SubflowPreviewDialogService { readonly state = this._state.asReadonly(); - open(flowData: FlowData, title?: string) { + open(flowData: FlowData, title?: string, sourceName?: string) { + const normalizedSourceName = sourceName?.trim() || this.deriveSourceName(title); this._state.set({ title: title?.trim() || 'Subflow Preview', - flowData + flowData, + sourceName: normalizedSourceName }); } close() { this._state.set(null); } + + private deriveSourceName(title?: string): string { + const normalizedTitle = title?.trim(); + if (!normalizedTitle) return 'container'; + + const suffix = ' subflow'; + const lowerTitle = normalizedTitle.toLowerCase(); + if (lowerTitle.endsWith(suffix)) { + return normalizedTitle.slice(0, normalizedTitle.length - suffix.length).trim() || 'container'; + } + + return normalizedTitle; + } } diff --git a/src/app/shared/confirm-dialog/confirm-dialog.html b/src/app/shared/confirm-dialog/confirm-dialog.html index 15e2d7b..7906061 100644 --- a/src/app/shared/confirm-dialog/confirm-dialog.html +++ b/src/app/shared/confirm-dialog/confirm-dialog.html @@ -1,6 +1,6 @@ @if(!!this.state()) { -
+
diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index 020f238..4699411 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -677,7 +677,7 @@ export class ContainerNodeComponent implements OnDestroy { event?.preventDefault(); event?.stopPropagation(); if (!this.subFlow) return; - this.subflowPreview.open(this.subFlow, `${this.name} subflow`); + this.subflowPreview.open(this.subFlow, `${this.name} subflow`, this.name); } async openFieldPreview(field: ContainerFieldView, event?: Event) { diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index a24e226..abdd0df 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -448,7 +448,8 @@ export class TaskStepNodeComponent { event?.stopPropagation(); const subFlow = this.subFlow(); if (!subFlow) return; - this.subflowPreview.open(subFlow, `${this.name || this.nodeTitle()} subflow`); + const sourceName = this.name || this.nodeTitle(); + this.subflowPreview.open(subFlow, `${sourceName} subflow`, sourceName); } openFieldPreview(field: DisplayField, event?: Event) { diff --git a/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.css b/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.css index 188e98b..c794f87 100644 --- a/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.css +++ b/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.css @@ -33,6 +33,12 @@ border-bottom: 1px solid #e2e8f0; } +.subflow-preview__actions { + display: flex; + align-items: center; + gap: 10px; +} + .subflow-preview__eyebrow { font-size: 11px; font-weight: 800; diff --git a/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.html b/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.html index 1573e6f..dfe82c3 100644 --- a/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.html +++ b/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.html @@ -8,7 +8,13 @@
Read-only Subflow

{{ state()!.title }}

- +
+ + +
diff --git a/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.ts b/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.ts index 6e8ca3c..4d8eed8 100644 --- a/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.ts +++ b/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.ts @@ -1,20 +1,32 @@ -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; import { ReteEditor } from '@shared/rete-editor/rete-editor'; import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog'; +import { FlowsService } from '@services/flows/flows'; +import { EditorStateHolder } from '@stores/flow-editor'; +import { firstValueFrom } from 'rxjs'; +import { FlowData } from '@models/flow'; +import { NotificationService } from '@services/notifications/notification'; +import { ConfirmDialogService } from '@services/dialogs/confirm-dialog'; @Component({ selector: 'app-subflow-preview-dialog-host', - imports: [CommonModule, MatButtonModule, ReteEditor], + imports: [CommonModule, MatButtonModule, MatIconModule, ReteEditor], templateUrl: './subflow-preview-dialog.html', styleUrl: './subflow-preview-dialog.css', changeDetection: ChangeDetectionStrategy.OnPush }) export class SubflowPreviewDialogHostComponent { private dialog = inject(SubflowPreviewDialogService); + private flowsService = inject(FlowsService); + private editorState = inject(EditorStateHolder); + private notification = inject(NotificationService); + private confirm = inject(ConfirmDialogService); readonly state = this.dialog.state; + readonly exporting = signal(false); readonly flowId = computed(() => { const state = this.state(); return state ? `subflow-preview:${state.title}` : 'subflow-preview'; @@ -25,4 +37,66 @@ export class SubflowPreviewDialogHostComponent { event?.stopPropagation(); this.dialog.close(); } + + async exportSubflow(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + if (this.exporting()) return; + + const state = this.state(); + if (!state) return; + + this.exporting.set(true); + try { + const name = this.nextFlowName(`sub_${state.sourceName}`); + const createdFlow = await firstValueFrom(this.flowsService.createFlow({ + name, + description: `Extracted from ${state.sourceName}`, + data: this.cloneFlowData(state.flowData), + status: 'DRAFT' + })); + + this.notification.show(`Subflow added to flows list as ${name}.`, 'success'); + + const hasUnsavedChanges = this.editorState.hasFlow() && this.editorState.isDirty(); + const openNow = await this.confirm.open(this.buildExportMessage(name, hasUnsavedChanges)); + if (openNow) { + await this.editorState.openDocument(createdFlow, { skipDirtyCheck: true }); + this.dialog.close(); + } + } catch (error) { + console.error('Export subflow failed', error); + this.notification.show('Unable to export subflow. Please retry.', 'error'); + } finally { + this.exporting.set(false); + } + } + + private nextFlowName(baseName: string): string { + const sanitizedBase = baseName.trim() || 'sub_container'; + const existing = new Set(this.flowsService.flows().map((flow) => flow.name)); + if (!existing.has(sanitizedBase)) return sanitizedBase; + + let index = 1; + while (existing.has(`${sanitizedBase}(${index})`)) { + index++; + } + return `${sanitizedBase}(${index})`; + } + + private cloneFlowData(flowData: FlowData): FlowData { + if (typeof structuredClone === 'function') { + return structuredClone(flowData); + } + + return JSON.parse(JSON.stringify(flowData)) as FlowData; + } + + private buildExportMessage(flowName: string, hasUnsavedChanges: boolean): string { + const unsavedWarning = hasUnsavedChanges + ? ' The current flow has unsaved changes: opening now will discard your pending edits.' + : ''; + + return `Subflow added to the flows list as ${flowName}. Do you want to open it now?${unsavedWarning} Note: the container subflow is not updated automatically; once you finish and save this new flow, re-import it into the container.`; + } }