feat: Add subflow export functionality with source name tracking

- Implement export button in subflow preview dialog
- Add sourceName parameter to track flow origin
- Add deriveSourceName() utility to normalize source names
- Improve dialog layout with flexbox for action buttons
- Fix confirm dialog z-index (10000)
- Add exporting state signal for export button UI
This commit is contained in:
Lucio Lelii 2026-05-20 17:13:18 +02:00
parent 3dbb140131
commit 4c4b57781f
7 changed files with 111 additions and 8 deletions

View File

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

View File

@ -1,6 +1,6 @@
@if(!!this.state()) {
<div class="fixed inset-0 z-9999 flex items-center justify-center">
<div class="fixed inset-0 flex items-center justify-center" style="z-index: 10000;">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/40" (click)="close(false)">

View File

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

View File

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

View File

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

View File

@ -8,7 +8,13 @@
<div class="subflow-preview__eyebrow">Read-only Subflow</div>
<h3 class="subflow-preview__title">{{ state()!.title }}</h3>
</div>
<button type="button" mat-stroked-button (click)="close($event)">Close</button>
<div class="subflow-preview__actions">
<button type="button" mat-stroked-button (click)="exportSubflow($event)" [disabled]="exporting()">
<mat-icon>file_upload</mat-icon>
Export
</button>
<button type="button" mat-stroked-button (click)="close($event)">Close</button>
</div>
</div>
<div class="subflow-preview__canvas">

View File

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