Export a flow as a JSON file from the actions menu
A flow could be cloned or nested in a container, but never left the installation it lived in - no copy for a ticket, for another machine, or for outside a database that run_service.sh recreates from scratch on every restart. The file is an envelope, not a bare graph: the name and description survive, and there is somewhere to put a format version. What the server decides for itself stays behind - id, author, published, finalized, projectId, status - so an import can never be a way to mint a public or finalized flow. Node ids inside the graph are kept: each flow stores its own, and rewriting them would mean remapping every connection. Export re-reads the flow rather than trusting the cached row, and unlike opening it does not fall back to that row on failure: a file that looks complete and is not would be worse than an error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
43bcc38b9e
commit
c41ad1092c
|
|
@ -0,0 +1,29 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Hands a file to the browser.
|
||||
*
|
||||
* A service rather than a bare function because this is the only part of the export that touches
|
||||
* the DOM: as a service a spec replaces it with a stub instead of having to fake `document`.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FileDownload {
|
||||
|
||||
saveJson(fileName: string, payload: unknown) {
|
||||
this.save(fileName, JSON.stringify(payload, null, 2), 'application/json');
|
||||
}
|
||||
|
||||
private save(fileName: string, content: string, mimeType: string) {
|
||||
const url = URL.createObjectURL(new Blob([content], { type: mimeType }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.style.display = 'none';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
// Revoked, or the blob is held for the life of the page - but not in the same tick: some
|
||||
// browsers abort a save whose URL is revoked before they have finished reading it.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
import { Flow, FlowData } from '@models/flow';
|
||||
|
||||
import {
|
||||
buildFlowExport,
|
||||
collectNodeTypeNames,
|
||||
flowExportFileName,
|
||||
parseFlowImport,
|
||||
resolveLegacyTypeName
|
||||
} from './flow-transfer';
|
||||
|
||||
function makeFlowData(overrides: Partial<FlowData> = {}): FlowData {
|
||||
return {
|
||||
blocks: [],
|
||||
containers: [],
|
||||
connections: [],
|
||||
dependencies: [],
|
||||
globalInputs: [],
|
||||
lanes: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function makeFlow(overrides: Partial<Flow> = {}): Flow {
|
||||
return {
|
||||
id: 'flow-1',
|
||||
name: 'Bias rerun',
|
||||
description: 'Re-runs the interview flow',
|
||||
visibility: 'PRIVATE',
|
||||
data: makeFlowData(),
|
||||
author: 'lucio.lelii',
|
||||
status: 'DRAFT',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
published: true,
|
||||
finalized: true,
|
||||
projectId: 'project-9',
|
||||
...overrides
|
||||
} as Flow;
|
||||
}
|
||||
|
||||
describe('flow transfer', () => {
|
||||
describe('the exported envelope', () => {
|
||||
it('carries the graph, the name and the description', () => {
|
||||
const data = makeFlowData({ blocks: [{ id: 'b1' } as any] });
|
||||
const file = buildFlowExport(makeFlow({ data }), new Date('2026-09-04T10:00:00.000Z'));
|
||||
|
||||
expect(file).toEqual({
|
||||
formatVersion: 1,
|
||||
exportedAt: '2026-09-04T10:00:00.000Z',
|
||||
name: 'Bias rerun',
|
||||
description: 'Re-runs the interview flow',
|
||||
flow: data
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves behind everything the importing server decides for itself', () => {
|
||||
// Not a style preference: an exported id invites mistaking an import for an update, and
|
||||
// published/finalized/projectId belong to one installation and one user, not to the graph.
|
||||
const file = buildFlowExport(makeFlow()) as unknown as Record<string, unknown>;
|
||||
|
||||
for (const key of ['id', 'author', 'owner', 'published', 'finalized', 'projectId', 'status',
|
||||
'createdAt', 'updatedAt', 'validationErrors']) {
|
||||
expect(key in file).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the node ids as they are', () => {
|
||||
// Each flow stores its own graph, so ids never collide across flows - and rewriting them
|
||||
// would mean remapping every connection and dependency that points at them.
|
||||
const data = makeFlowData({
|
||||
blocks: [{ id: 'block-42' } as any],
|
||||
connections: [{ id: 'c1', sourceId: 'block-42', targetId: 'block-43' } as any]
|
||||
});
|
||||
|
||||
const file = buildFlowExport(makeFlow({ data }));
|
||||
|
||||
expect(file.flow.blocks[0].id).toBe('block-42');
|
||||
expect(file.flow.connections[0].sourceId).toBe('block-42');
|
||||
});
|
||||
|
||||
it('turns the flow name into a readable file name', () => {
|
||||
expect(flowExportFileName('Bias rerun')).toBe('bias-rerun.flow.json');
|
||||
expect(flowExportFileName('Analisi però / 2')).toBe('analisi-pero-2.flow.json');
|
||||
expect(flowExportFileName('***')).toBe('flow.flow.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reading a file back', () => {
|
||||
it('round trips an exported flow, subflows included', () => {
|
||||
// A container's subflow lives inside its specificConfiguration, so it has to survive the
|
||||
// trip without any code of its own. This is the test that says so.
|
||||
const data = makeFlowData({
|
||||
blocks: [{ id: 'b1', typeName: 'LLMBlock' } as any],
|
||||
containers: [{
|
||||
id: 'c1',
|
||||
typeName: 'IteratorContainer',
|
||||
specificConfiguration: { subFlow: { blocks: [{ id: 'inner' }] } }
|
||||
} as any]
|
||||
});
|
||||
|
||||
const file = buildFlowExport(makeFlow({ data }));
|
||||
const parsed = parseFlowImport(JSON.stringify(file), 'ignored');
|
||||
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (!parsed.ok) return;
|
||||
expect(parsed.name).toBe('Bias rerun');
|
||||
expect(parsed.description).toBe('Re-runs the interview flow');
|
||||
expect(parsed.data).toEqual(data);
|
||||
expect((parsed.data.containers[0] as any).specificConfiguration.subFlow.blocks[0].id).toBe('inner');
|
||||
});
|
||||
|
||||
it('accepts a bare graph, naming it after the file', () => {
|
||||
// The backend hands whole graphs around in this shape - the container import retriever does
|
||||
// exactly that - so a JSON copied from there still imports.
|
||||
const parsed = parseFlowImport(JSON.stringify({ blocks: [{ id: 'b1' }] }), 'copied-graph');
|
||||
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (!parsed.ok) return;
|
||||
expect(parsed.name).toBe('copied-graph');
|
||||
expect(parsed.data.blocks).toHaveLength(1);
|
||||
expect(parsed.data.lanes).toEqual([]);
|
||||
});
|
||||
|
||||
it('fills in the collections a hand-written file leaves out', () => {
|
||||
const parsed = parseFlowImport(JSON.stringify({ formatVersion: 1, name: 'Small', flow: { blocks: [] } }), 'x');
|
||||
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (!parsed.ok) return;
|
||||
expect(parsed.data).toEqual({
|
||||
blocks: [], containers: [], connections: [], dependencies: [], globalInputs: [], lanes: []
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed JSON', () => {
|
||||
const parsed = parseFlowImport('{ "blocks": [', 'x');
|
||||
|
||||
expect(parsed.ok).toBe(false);
|
||||
if (parsed.ok) return;
|
||||
expect(parsed.message).toContain('not valid JSON');
|
||||
});
|
||||
|
||||
it('rejects JSON that is not a flow', () => {
|
||||
expect(parseFlowImport('[1, 2, 3]', 'x').ok).toBe(false);
|
||||
expect(parseFlowImport('{"hello":"world"}', 'x').ok).toBe(false);
|
||||
expect(parseFlowImport('{"formatVersion":1,"name":"n"}', 'x').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a file from a newer format instead of guessing', () => {
|
||||
const parsed = parseFlowImport(JSON.stringify({ formatVersion: 2, flow: { blocks: [] } }), 'x');
|
||||
|
||||
expect(parsed.ok).toBe(false);
|
||||
if (parsed.ok) return;
|
||||
expect(parsed.message).toContain('newer version');
|
||||
});
|
||||
|
||||
it('falls back to the file name when the envelope has no usable name', () => {
|
||||
const parsed = parseFlowImport(JSON.stringify({ formatVersion: 1, name: ' ', flow: { blocks: [] } }), 'from-disk');
|
||||
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (!parsed.ok) return;
|
||||
expect(parsed.name).toBe('from-disk');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the type names a file refers to', () => {
|
||||
it('reaches the nodes inside a container subflow', () => {
|
||||
// A type this server does not know is just as fatal inside a subflow as at the top level.
|
||||
const data = makeFlowData({
|
||||
blocks: [{ typeName: 'LLMBlock' } as any],
|
||||
containers: [{
|
||||
id: 'c1',
|
||||
name: 'Loop',
|
||||
typeName: 'IteratorContainer',
|
||||
specificConfiguration: {
|
||||
subFlow: makeFlowData({ blocks: [{ typeName: 'MysteryBlock' } as any] })
|
||||
}
|
||||
} as any]
|
||||
});
|
||||
|
||||
expect(collectNodeTypeNames(data).blocks).toEqual(['LLMBlock', 'MysteryBlock']);
|
||||
});
|
||||
|
||||
it('maps the two type names the server has renamed', () => {
|
||||
// BlockTypes.get still answers to both, so refusing them client-side would reject a file
|
||||
// the server would have accepted.
|
||||
expect(resolveLegacyTypeName('ChatHumanInteraction')).toBe('ChatInteraction');
|
||||
expect(resolveLegacyTypeName('ExclusiveMergeBlock')).toBe('BranchRejoinBlock');
|
||||
expect(resolveLegacyTypeName('LLMBlock')).toBe('LLMBlock');
|
||||
});
|
||||
|
||||
it('lists each block and container type once', () => {
|
||||
const data = makeFlowData({
|
||||
blocks: [
|
||||
{ typeName: 'LLMBlock' } as any,
|
||||
{ typeName: 'LLMBlock' } as any,
|
||||
{ typeName: 'EndBlock' } as any,
|
||||
{ } as any
|
||||
],
|
||||
containers: [{ typeName: 'IteratorContainer' } as any]
|
||||
});
|
||||
|
||||
expect(collectNodeTypeNames(data)).toEqual({
|
||||
blocks: ['LLMBlock', 'EndBlock'],
|
||||
containers: ['IteratorContainer']
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import { Flow, FlowData } from '@models/flow';
|
||||
import { listFlowSubflows, resolveFlowSubflow } from '@utilities/flow-subflows';
|
||||
|
||||
/**
|
||||
* Carrying a flow in and out of the app as a file.
|
||||
*
|
||||
* Pure on purpose: no DOM, no services, no HTTP. Saving the file and creating the flow are the
|
||||
* caller's job, which keeps the format itself testable without a TestBed.
|
||||
*/
|
||||
|
||||
export const FLOW_EXPORT_FORMAT_VERSION = 1;
|
||||
|
||||
/**
|
||||
* The exported envelope.
|
||||
*
|
||||
* Not the bare `FlowData`, so the name and description survive the trip and there is somewhere to
|
||||
* put the version. The shape mirrors the `ImportedFlow` record the backend seed importer already
|
||||
* reads from its own `flows.json`.
|
||||
*/
|
||||
export type FlowExportFile = {
|
||||
formatVersion: number;
|
||||
exportedAt: string;
|
||||
name: string;
|
||||
description: string;
|
||||
flow: FlowData;
|
||||
};
|
||||
|
||||
export type FlowImportParse =
|
||||
| { ok: true; name: string; description: string; data: FlowData }
|
||||
| { ok: false; message: string };
|
||||
|
||||
/**
|
||||
* What travels is the graph and its name. Everything else is deliberately left behind:
|
||||
*
|
||||
* - `id`, `createdAt`, `updatedAt`: the server assigns its own on import, and carrying an id
|
||||
* invites mistaking an import for an update;
|
||||
* - `author`: whoever imports the file owns the result;
|
||||
* - `published` and `finalized`: visibility and lifecycle are not part of the content, so an
|
||||
* imported flow starts as an editable draft;
|
||||
* - `projectId`: projects belong to one user;
|
||||
* - `status` and `validationErrors`: the server derives both from the graph.
|
||||
*
|
||||
* Node ids *inside* the graph are kept as they are: each flow stores its own graph, so they never
|
||||
* collide, and rewriting them would mean remapping every connection and dependency.
|
||||
*/
|
||||
export function buildFlowExport(
|
||||
flow: Pick<Flow, 'name' | 'description' | 'data'>,
|
||||
now: Date = new Date()
|
||||
): FlowExportFile {
|
||||
return {
|
||||
formatVersion: FLOW_EXPORT_FORMAT_VERSION,
|
||||
exportedAt: now.toISOString(),
|
||||
name: flow.name,
|
||||
description: flow.description ?? '',
|
||||
flow: flow.data
|
||||
};
|
||||
}
|
||||
|
||||
/** `Bias rerun` becomes `bias-rerun.flow.json`, so a folder of exports stays readable. */
|
||||
export function flowExportFileName(name: string): string {
|
||||
const slug = (name ?? '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60);
|
||||
return `${slug || 'flow'}.flow.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a file back.
|
||||
*
|
||||
* Accepts a bare `FlowData` as well as an envelope: the backend hands whole graphs around in that
|
||||
* shape - the container import retriever does exactly this - so a JSON copied from there, or out
|
||||
* of the database, still imports. In that case the name comes from the file name, which is why the
|
||||
* caller has to supply one.
|
||||
*/
|
||||
export function parseFlowImport(text: string, fallbackName: string): FlowImportParse {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return { ok: false, message: 'That file is not valid JSON.' };
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { ok: false, message: 'That file does not contain a flow.' };
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const version = record['formatVersion'];
|
||||
|
||||
if (typeof version === 'number') {
|
||||
if (version > FLOW_EXPORT_FORMAT_VERSION) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `That file was exported by a newer version of HumAIn Flow (format ${version}).`
|
||||
};
|
||||
}
|
||||
const data = toFlowData(record['flow']);
|
||||
if (!data) return { ok: false, message: 'That file does not contain a flow.' };
|
||||
return {
|
||||
ok: true,
|
||||
name: readName(record['name']) ?? fallbackName,
|
||||
description: typeof record['description'] === 'string' ? record['description'] : '',
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
// No envelope: accept the graph on its own.
|
||||
const bare = toFlowData(record);
|
||||
if (!bare) return { ok: false, message: 'That file does not contain a flow.' };
|
||||
return { ok: true, name: fallbackName, description: '', data: bare };
|
||||
}
|
||||
|
||||
/**
|
||||
* The node type names the file refers to, so a caller can check them against its catalogs.
|
||||
*
|
||||
* Containers carry their own graph inside their configuration, and a type this server does not
|
||||
* know is just as fatal in there, so the walk goes all the way down - reusing the app's own
|
||||
* definition of where a subflow lives rather than growing a second one.
|
||||
*/
|
||||
export function collectNodeTypeNames(data: FlowData): { blocks: string[]; containers: string[] } {
|
||||
const blocks = new Set<string>();
|
||||
const containers = new Set<string>();
|
||||
|
||||
const collect = (graph: FlowData) => {
|
||||
for (const name of typeNamesOf(graph.blocks)) blocks.add(name);
|
||||
for (const name of typeNamesOf(graph.containers)) containers.add(name);
|
||||
};
|
||||
|
||||
collect(data);
|
||||
for (const entry of listFlowSubflows(data)) {
|
||||
const nested = resolveFlowSubflow(data, entry.locator);
|
||||
if (nested) collect(nested);
|
||||
}
|
||||
|
||||
return { blocks: [...blocks], containers: [...containers] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The server still answers to two type names it has renamed (BlockTypes.get). A file written
|
||||
* before those renames imports fine, so the client check has to know them too - otherwise it
|
||||
* would refuse a flow the server would have accepted.
|
||||
*/
|
||||
const LEGACY_TYPE_NAMES: Readonly<Record<string, string>> = {
|
||||
ChatHumanInteraction: 'ChatInteraction',
|
||||
ExclusiveMergeBlock: 'BranchRejoinBlock'
|
||||
};
|
||||
|
||||
export function resolveLegacyTypeName(typeName: string): string {
|
||||
return LEGACY_TYPE_NAMES[typeName] ?? typeName;
|
||||
}
|
||||
|
||||
function typeNamesOf(nodes: ReadonlyArray<{ typeName?: string }> | undefined): string[] {
|
||||
const found: string[] = [];
|
||||
for (const node of nodes ?? []) {
|
||||
const typeName = node?.typeName;
|
||||
if (typeof typeName === 'string' && typeName.trim().length) found.push(typeName);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* A flow is recognised by having a `blocks` array. The other collections are normalised to empty
|
||||
* rather than demanded: an older or hand-written file that omits `lanes` or `dependencies` is
|
||||
* still a flow, and the editor treats those as empty anyway.
|
||||
*/
|
||||
function toFlowData(value: unknown): FlowData | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (!Array.isArray(record['blocks'])) return null;
|
||||
|
||||
return {
|
||||
blocks: record['blocks'] as FlowData['blocks'],
|
||||
containers: asArray(record['containers']) as FlowData['containers'],
|
||||
connections: asArray(record['connections']) as FlowData['connections'],
|
||||
dependencies: asArray(record['dependencies']) as FlowData['dependencies'],
|
||||
globalInputs: asArray(record['globalInputs']) as FlowData['globalInputs'],
|
||||
lanes: asArray(record['lanes']) as FlowData['lanes']
|
||||
};
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function readName(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length ? value.trim() : null;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Injectable, signal } from '@angular/core';
|
||||
import { environment } from '@environment';
|
||||
import { Flow } from '@models/flow';
|
||||
import { Flow, FlowData } from '@models/flow';
|
||||
import { FlowsCallServiceBase } from './flows-call.base';
|
||||
import { catchError, firstValueFrom, Observable, of, switchMap, tap, throwError } from 'rxjs';
|
||||
|
||||
|
|
@ -210,6 +210,28 @@ export class FlowsService {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a flow from a file the user picked.
|
||||
*
|
||||
* The name is de-duplicated the same way "Empty flow" does it, so importing the same file twice
|
||||
* gives two flows you can tell apart. `status` is always DRAFT: the server derives the real one
|
||||
* from the graph, so sending back an exported status would be inventing a fact.
|
||||
*/
|
||||
importFlow(imported: { name: string; description?: string; data: FlowData }): Observable<Flow> {
|
||||
return this.createFlow({
|
||||
name: this.nextFileName(imported.name),
|
||||
description: imported.description,
|
||||
data: imported.data,
|
||||
status: 'DRAFT'
|
||||
}).pipe(
|
||||
tap(() => this.refresh()),
|
||||
catchError(err => {
|
||||
console.error('Importing flow failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
createNewFlow(name? : string) {
|
||||
return this.createFlow({
|
||||
name: name || this.nextFileName('New Flow'),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,14 @@
|
|||
<span>Clone flow</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
(click)="exportFlow()">
|
||||
<mat-icon fontIcon="download"></mat-icon>
|
||||
<span>Export flow</span>
|
||||
</button>
|
||||
|
||||
@if (projectsEnabled) {
|
||||
<button
|
||||
mat-menu-item
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|||
import { provideRouter } from '@angular/router';
|
||||
import { Authorization } from '@services/authorization/authorization';
|
||||
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
|
||||
import { FileDownload } from '@services/files/file-download';
|
||||
import { FlowsService } from '@services/flows/flows';
|
||||
import { NotificationService } from '@services/notifications/notification';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { of } from 'rxjs';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { FlowItem } from './flow-item';
|
||||
|
|
@ -13,8 +15,31 @@ import { FlowItem } from './flow-item';
|
|||
describe('FlowItem', () => {
|
||||
let component: FlowItem;
|
||||
let fixture: ComponentFixture<FlowItem>;
|
||||
let saveJson: ReturnType<typeof vi.fn>;
|
||||
let getFlowById: ReturnType<typeof vi.fn>;
|
||||
let notify: ReturnType<typeof vi.fn>;
|
||||
|
||||
/** What the server holds - deliberately richer than the list row the card was given. */
|
||||
const storedFlow = {
|
||||
id: 'flow-1',
|
||||
name: 'Test flow',
|
||||
visibility: 'PRIVATE',
|
||||
description: 'A flow',
|
||||
data: {
|
||||
blocks: [{ id: 'b1', typeName: 'LLMBlock' }],
|
||||
containers: [], connections: [], dependencies: [], globalInputs: [], lanes: []
|
||||
},
|
||||
author: 'author',
|
||||
createdAt: new Date(),
|
||||
status: 'DRAFT',
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
saveJson = vi.fn();
|
||||
notify = vi.fn();
|
||||
getFlowById = vi.fn().mockReturnValue(of(storedFlow));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [FlowItem],
|
||||
providers: [
|
||||
|
|
@ -35,9 +60,12 @@ describe('FlowItem', () => {
|
|||
provide: FlowsService,
|
||||
useValue: {
|
||||
cloneFlow: vi.fn().mockReturnValue(of(null)),
|
||||
deleteFlow: vi.fn().mockReturnValue(of(null))
|
||||
deleteFlow: vi.fn().mockReturnValue(of(null)),
|
||||
getFlowById
|
||||
}
|
||||
},
|
||||
{ provide: FileDownload, useValue: { saveJson } },
|
||||
{ provide: NotificationService, useValue: { show: notify } },
|
||||
{
|
||||
provide: EditorStateHolder,
|
||||
useValue: {
|
||||
|
|
@ -83,4 +111,33 @@ describe('FlowItem', () => {
|
|||
expect(indicator).not.toBeNull();
|
||||
expect(indicator.tagName.toLowerCase()).toBe('mat-icon');
|
||||
});
|
||||
|
||||
describe('exporting a flow', () => {
|
||||
it('writes what the server holds, not the row the card was handed', () => {
|
||||
// The card's own input carries an empty graph here. Exporting that would produce a file that
|
||||
// looks complete and is not, which is exactly the failure worth a test.
|
||||
expect(component.flow().data.blocks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('re-reads the flow and hands the envelope to the download', async () => {
|
||||
await component.exportFlow();
|
||||
|
||||
expect(getFlowById).toHaveBeenCalledWith('flow-1', true);
|
||||
const [fileName, payload] = saveJson.mock.calls.at(-1)!;
|
||||
expect(fileName).toBe('test-flow.flow.json');
|
||||
expect(payload.formatVersion).toBe(1);
|
||||
expect(payload.name).toBe('Test flow');
|
||||
expect(payload.flow.blocks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports a failure instead of writing a partial file', async () => {
|
||||
getFlowById.mockReturnValue(throwError(() => new Error('offline')));
|
||||
|
||||
await component.exportFlow();
|
||||
|
||||
expect(saveJson).not.toHaveBeenCalled();
|
||||
expect(notify).toHaveBeenCalledWith('Could not export this flow.', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { Flow } from '@models/flow';
|
|||
import { Project } from '@models/project';
|
||||
import { Authorization } from '@services/authorization/authorization';
|
||||
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
|
||||
import { FileDownload } from '@services/files/file-download';
|
||||
import { buildFlowExport, flowExportFileName } from '@services/flows/flow-transfer';
|
||||
import { FlowsService } from '@services/flows/flows';
|
||||
import { NotificationService } from '@services/notifications/notification';
|
||||
import { ProjectsService } from '@services/projects/projects';
|
||||
|
|
@ -32,6 +34,7 @@ export class FlowItem {
|
|||
private flowsService = inject(FlowsService);
|
||||
private projectsService = inject(ProjectsService);
|
||||
private notifications = inject(NotificationService);
|
||||
private fileDownload = inject(FileDownload);
|
||||
|
||||
readonly projectsEnabled = PROJECTS_ENABLED;
|
||||
readonly assignableProjects = this.projectsService.projects;
|
||||
|
|
@ -100,9 +103,17 @@ export class FlowItem {
|
|||
await this.editorState.openDocument(await this.loadCompleteFlow());
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws. Opening a flow can fall back to the cached row, but exporting one cannot: a failure
|
||||
* that quietly returns the row would write a file that looks complete and is not.
|
||||
*/
|
||||
private fetchCompleteFlow(): Promise<Flow> {
|
||||
return firstValueFrom(this.flowsService.getFlowById(this.flow().id, true));
|
||||
}
|
||||
|
||||
private async loadCompleteFlow(): Promise<Flow> {
|
||||
try {
|
||||
return await firstValueFrom(this.flowsService.getFlowById(this.flow().id, true));
|
||||
return await this.fetchCompleteFlow();
|
||||
} catch (error) {
|
||||
console.error('Error loading complete flow', error);
|
||||
return this.flow();
|
||||
|
|
@ -115,6 +126,24 @@ export class FlowItem {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports what the server has, not what the cache holds - the same reason `open()` re-reads the
|
||||
* flow. If the flow is open with unsaved changes that means the file is the last saved version,
|
||||
* which is worth saying out loud rather than leaving to be discovered.
|
||||
*/
|
||||
async exportFlow() {
|
||||
try {
|
||||
const complete = await this.fetchCompleteFlow();
|
||||
this.fileDownload.saveJson(flowExportFileName(complete.name), buildFlowExport(complete));
|
||||
if (this.isRootOpen() && this.editorState.isDirty()) {
|
||||
this.notifications.show('Exported the last saved version of this flow.', 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error exporting flow', error);
|
||||
this.notifications.show('Could not export this flow.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async remove() {
|
||||
if (!this.canDelete()) return;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue