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.`;
+ }
}