diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
index 618e8c0e1c6..a1402b04f26 100644
--- a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
+++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
@@ -19,14 +19,24 @@
+ the same right inset ahead of the same user icon. The content below is padded and
+ centred, which is why the switch cannot live down there -- it would stop at the
+ column edge and never line up with the other view. -->
-
{{ workflowName || "Untitled workflow" }}
+
+
+
+
{{ autoSaveState }}
{
let component: WorkflowFormComponent;
@@ -54,6 +55,8 @@ describe("WorkflowFormComponent", () => {
h.cdr as any,
h.computingUnitStatusService as any,
h.workflowConsoleService as any,
+ h.host as any,
+ h.datePipe as any,
h.config as any
);
return component;
@@ -145,4 +148,197 @@ describe("WorkflowFormComponent", () => {
expect(h.workflowResultService.clearResults).toHaveBeenCalled();
});
});
+
+ describe("title bar and saving", () => {
+ const enableSave = () => {
+ h.userService.isLogin.mockReturnValue(true);
+ h.workflowPersistService.isWorkflowPersistEnabled.mockReturnValue(true);
+ };
+
+ it("shows the last-saved time from the workflow's metadata", () => {
+ build(formViewWorkflow).ngOnInit();
+
+ expect(component.autoSaveState).toBe("Saved at 01/01/2026 00:00:00");
+ });
+
+ it("shows no saved state when the workflow has never been saved", () => {
+ workflowActionService.getWorkflowMetadata = () => ({ name: "x", lastModifiedTime: undefined });
+
+ build(formViewWorkflow).ngOnInit();
+
+ expect(component.autoSaveState).toBe("");
+ });
+
+ it("renames through the workflow action service and saves", () => {
+ enableSave();
+ build(formViewWorkflow).ngOnInit();
+ component.workflowName = "New name";
+
+ component.onRenameWorkflow();
+
+ expect(workflowActionService.setWorkflowName).toHaveBeenCalledWith("New name");
+ expect(workflowPersistService.persistWorkflow).toHaveBeenCalled();
+ });
+
+ // The title bar is refreshed from one place: a rename or save -- here or by a co-editor --
+ // updates the shown name and the saved-at state, so the two views never drift apart. This
+ // is also where onRenameWorkflow's normalised name is read back.
+ it("follows the workflow metadata: refreshes the name and saved state when it changes", () => {
+ vi.useFakeTimers();
+ build(formViewWorkflow).ngOnInit();
+ component.workflowName = "stale";
+ workflowActionService.getWorkflowMetadata = () => ({ name: "Renamed", lastModifiedTime: 1767225600000 });
+
+ h.workflowMetaDataChangedStream.next(undefined);
+ vi.runAllTimers();
+
+ expect(component.workflowName).toBe("Renamed");
+ expect(component.autoSaveState).toBe("Saved at 01/01/2026 00:00:00");
+ vi.useRealTimers();
+ });
+
+ it("persists the workflow, filling in a position for every operator", () => {
+ enableSave();
+ workflowActionService.getWorkflow.mockReturnValue({
+ wid: 7,
+ content: {
+ operators: [{ operatorID: "op-1" }, { operatorID: "op-2" }],
+ operatorPositions: { "op-1": { x: 5, y: 6 } },
+ },
+ });
+ build(formViewWorkflow).ngOnInit();
+
+ (component as any).save();
+
+ const saved = workflowPersistService.persistWorkflow.mock.calls.at(-1)[0];
+ expect(saved.content.operatorPositions).toEqual({ "op-1": { x: 5, y: 6 }, "op-2": { x: 0, y: 0 } });
+ });
+
+ // The graph is read-only here, but a co-editor can still move operators on the canvas; a save
+ // must carry those live positions, not revert them to where they sat when this page opened.
+ it("saves the live positions, not the load-time snapshot", () => {
+ enableSave();
+ build({ ...formViewWorkflow, content: { operatorPositions: { "op-1": { x: 1, y: 1 } } } }).ngOnInit();
+ // a co-editor has since dragged op-1; the shared graph reflects the new spot
+ workflowActionService.getWorkflow.mockReturnValue({
+ wid: 7,
+ content: { operators: [{ operatorID: "op-1" }], operatorPositions: { "op-1": { x: 9, y: 9 } } },
+ });
+
+ (component as any).save();
+
+ const saved = workflowPersistService.persistWorkflow.mock.calls.at(-1)[0];
+ expect(saved.content.operatorPositions).toEqual({ "op-1": { x: 9, y: 9 } });
+ });
+
+ // The canvas advances "Saved at ..." by feeding the persist response back into the metadata;
+ // the form must do the same, or the saved-at state never moves past the moment it opened.
+ it("feeds the persist response back into the workflow metadata", () => {
+ enableSave();
+ build(formViewWorkflow).ngOnInit();
+ const updated = { wid: 7, name: "scGPT", lastModifiedTime: 999, content: {} };
+ workflowPersistService.persistWorkflow.mockReturnValue(of(updated));
+
+ (component as any).save();
+
+ expect(workflowActionService.setWorkflowMetadata).toHaveBeenCalledWith(updated);
+ });
+
+ it("does not save when the user is not logged in", () => {
+ build(formViewWorkflow).ngOnInit();
+ workflowPersistService.persistWorkflow.mockClear();
+
+ (component as any).save();
+
+ expect(workflowPersistService.persistWorkflow).not.toHaveBeenCalled();
+ });
+
+ it("does not save when persistence is disabled", () => {
+ h.userService.isLogin.mockReturnValue(true);
+ build(formViewWorkflow).ngOnInit();
+ workflowPersistService.persistWorkflow.mockClear();
+
+ (component as any).save();
+
+ expect(workflowPersistService.persistWorkflow).not.toHaveBeenCalled();
+ });
+
+ it("does not save a workflow that is not the one this page opened", () => {
+ enableSave();
+ workflowActionService.getWorkflow.mockReturnValue({ wid: 99, content: { operators: [], operatorPositions: {} } });
+ build(formViewWorkflow).ngOnInit();
+ workflowPersistService.persistWorkflow.mockClear();
+
+ (component as any).save();
+
+ expect(workflowPersistService.persistWorkflow).not.toHaveBeenCalled();
+ });
+
+ it("reports a failed save so a lost edit is not silent", () => {
+ enableSave();
+ build(formViewWorkflow).ngOnInit();
+ // set after build(): build()'s useWorkflow() resets the persist mock
+ workflowPersistService.persistWorkflow.mockReturnValue(throwError(() => new Error("no")));
+
+ (component as any).save();
+
+ expect(h.notificationService.error).toHaveBeenCalled();
+ });
+
+ it("saves on any workflow change, debounced", () => {
+ vi.useFakeTimers();
+ enableSave();
+ build(formViewWorkflow).ngOnInit();
+ workflowPersistService.persistWorkflow.mockClear();
+
+ h.workflowChangedStream.next(undefined);
+ vi.runAllTimers();
+
+ expect(workflowPersistService.persistWorkflow).toHaveBeenCalled();
+ vi.useRealTimers();
+ });
+
+ it("saves before handing over to the operator canvas", () => {
+ enableSave();
+ build(formViewWorkflow).ngOnInit();
+ workflowPersistService.persistWorkflow.mockClear();
+
+ component.openRegularCanvas();
+
+ expect(workflowPersistService.persistWorkflow).toHaveBeenCalled();
+ });
+
+ it("saves once more on the way out", () => {
+ enableSave();
+ build(formViewWorkflow).ngOnInit();
+ workflowPersistService.persistWorkflow.mockClear();
+
+ component.ngOnDestroy();
+
+ expect(workflowPersistService.persistWorkflow).toHaveBeenCalled();
+ });
+
+ it("measures the name field after load, and no-ops when it is not in the DOM", () => {
+ vi.useFakeTimers();
+ const query = vi.spyOn(h.host.nativeElement, "querySelector");
+ build(formViewWorkflow).ngOnInit();
+
+ vi.runAllTimers();
+
+ expect(query).toHaveBeenCalledWith("input.wf-name");
+ vi.useRealTimers();
+ });
+
+ it("stops a deferred name measurement once the page is gone", () => {
+ vi.useFakeTimers();
+ build(formViewWorkflow).ngOnInit();
+ const query = vi.spyOn(h.host.nativeElement, "querySelector");
+ component.ngOnDestroy();
+
+ vi.runAllTimers();
+
+ expect(query).not.toHaveBeenCalled();
+ vi.useRealTimers();
+ });
+ });
});
diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
index 3d8f2b8f8c7..c1db653f032 100644
--- a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
+++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
@@ -17,15 +17,18 @@
* under the License.
*/
-import { ChangeDetectorRef, Component, HostListener, OnDestroy, OnInit } from "@angular/core";
-import { CommonModule } from "@angular/common";
+import { ChangeDetectorRef, Component, ElementRef, HostListener, OnDestroy, OnInit } from "@angular/core";
+import { CommonModule, DatePipe } from "@angular/common";
+import { FormsModule } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { NzAvatarModule } from "ng-zorro-antd/avatar";
import { UserIconComponent } from "../../../dashboard/component/user/user-icon/user-icon.component";
import { forkJoin } from "rxjs";
+import { debounceTime } from "rxjs/operators";
import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant";
+import { Workflow, WorkflowContent } from "../../../common/type/workflow";
import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service";
import { NotificationService } from "../../../common/service/notification/notification.service";
@@ -36,27 +39,42 @@ import { WorkflowActionService } from "../../service/workflow-graph/model/workfl
import { GuiConfigService } from "../../../common/service/gui-config.service";
import { WorkflowConsoleService } from "../../service/workflow-console/workflow-console.service";
import { WorkflowResultService } from "../../service/workflow-result/workflow-result.service";
+import { Point } from "../../types/workflow-common.interface";
import { CoeditorUserIconComponent } from "../menu/coeditor-user-icon/coeditor-user-icon.component";
import { CoeditorPresenceService } from "../../service/workflow-graph/model/coeditor-presence.service";
+import { SAVE_DEBOUNCE_TIME_IN_MS } from "../workspace.component";
/**
- * The Form View: a second way to use a workflow. This PR lays down the page shell -- behind
- * the feature flag it loads the workflow the URL names, shows it read-only, and hands back to
- * the operator canvas. The title bar's rename/save, the read-only preview, the inputs, running
- * and results are added on top by later PRs. A view, not a new object: it opens the same
- * workflow the canvas does.
+ * The Form View: a second way to use a workflow. Building on the page shell, this PR adds the
+ * title bar -- the workflow name (renamable, exactly as on the operator canvas), its "Saved
+ * at ..." state, and the debounced save that both views share so an edit in one view is not
+ * lost in the other. The read-only preview, the inputs, running and results are added by later
+ * PRs. A view, not a new object: it opens the same workflow the canvas does.
*/
@UntilDestroy()
@Component({
selector: "texera-workflow-form",
templateUrl: "./workflow-form.component.html",
styleUrls: ["./workflow-form.component.scss"],
- imports: [CommonModule, NzAvatarModule, UserIconComponent, CoeditorUserIconComponent],
+ imports: [CommonModule, FormsModule, NzAvatarModule, UserIconComponent, CoeditorUserIconComponent],
})
export class WorkflowFormComponent implements OnInit, OnDestroy {
public wid?: number;
public workflowName = "";
public loading = true;
+ /** "Saved at …", worded and formatted exactly as on the operator canvas. */
+ public autoSaveState = "";
+
+ /** Set on teardown so deferred callbacks stop touching a view that is gone. */
+ private destroyed = false;
+
+ /**
+ * Operator positions as loaded, kept only as a fallback: a save writes the live positions
+ * from the shared model (a co-editor's drags included), and falls back to this snapshot, then
+ * origin, if the live map is ever missing an operator -- so a save never drops an operator's
+ * position, and never overwrites a co-editor's move with a stale one.
+ */
+ private storedPositions: { [operatorID: string]: Point } = {};
constructor(
// Public for the template: shows the same live collaborator avatars as the canvas.
@@ -73,6 +91,8 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
private cdr: ChangeDetectorRef,
private computingUnitStatusService: ComputingUnitStatusService,
private workflowConsoleService: WorkflowConsoleService,
+ private host: ElementRef,
+ private datePipe: DatePipe,
private config: GuiConfigService
) {}
@@ -108,11 +128,16 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
// a per-workflow switch -- and bounce a later PR's canvas-to-form switch straight
// back for any canvas-default workflow.
this.workflowName = workflow.name;
+ this.storedPositions = { ...(workflow.content?.operatorPositions ?? {}) };
this.workflowActionService.setNewSharedModel(wid, this.userService.getCurrentUser());
this.workflowActionService.reloadWorkflow(workflow);
// The workflow is shown, not edited, from here: dragging operators around or
// deleting them belongs to the operator canvas.
this.applyEditability();
+ this.refreshSavedState();
+ this.later(() => this.adjustWorkflowNameWidth(), 0);
+ this.registerMetadataRefresh();
+ this.registerAutoPersist();
this.loading = false;
this.cdr.detectChanges();
},
@@ -133,6 +158,70 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
this.workflowActionService.disableWorkflowModification();
}
+ /**
+ * Size the name field to its text, the way the operator canvas does, so what follows
+ * it starts at the same place in both views instead of after a fixed-width box.
+ */
+ private adjustWorkflowNameWidth(): void {
+ const input = this.host.nativeElement.querySelector("input.wf-name");
+ if (!input) {
+ return;
+ }
+ /* v8 ignore start -- font-metrics DOM measuring; jsdom has no layout */
+ const probe = document.createElement("span");
+ probe.style.visibility = "hidden";
+ probe.style.position = "absolute";
+ probe.style.whiteSpace = "pre";
+ probe.style.font = getComputedStyle(input).font;
+ probe.textContent = input.value || input.placeholder;
+ document.body.appendChild(probe);
+ input.style.width = `${Math.min(probe.offsetWidth + 20, 800)}px`;
+ document.body.removeChild(probe);
+ /* v8 ignore stop */
+ }
+
+ private refreshSavedState(): void {
+ const lastModified = this.workflowActionService.getWorkflowMetadata()?.lastModifiedTime;
+ this.autoSaveState =
+ lastModified === undefined
+ ? ""
+ : "Saved at " +
+ (this.datePipe.transform(
+ lastModified,
+ "MM/dd/yyyy HH:mm:ss",
+ Intl.DateTimeFormat().resolvedOptions().timeZone,
+ "en"
+ ) ?? "");
+ }
+
+ /**
+ * Renaming here is the same edit as renaming on the operator canvas: commit the name and
+ * save. The title bar itself -- the read-back (normalised) name and its width -- is
+ * refreshed from the metadata subscription below, the single place this page's own rename
+ * and a co-editor's both flow through.
+ */
+ public onRenameWorkflow(): void {
+ this.workflowActionService.setWorkflowName(this.workflowName);
+ this.save();
+ }
+
+ /**
+ * Keep the title bar in step with the workflow's metadata, exactly as the operator canvas
+ * does: a rename or a save -- this page's own or a co-editor's -- refreshes the name, its
+ * width, and the "Saved at ..." state from one place, so the two views never drift apart.
+ */
+ private registerMetadataRefresh(): void {
+ this.workflowActionService
+ .workflowMetaDataChanged()
+ // The same 100ms the operator canvas debounces its title-bar refresh by.
+ .pipe(debounceTime(100), untilDestroyed(this))
+ .subscribe(() => {
+ this.workflowName = this.workflowActionService.getWorkflowMetadata()?.name ?? "";
+ this.later(() => this.adjustWorkflowNameWidth(), 0);
+ this.refreshSavedState();
+ });
+ }
+
/**
* Switch to the operator canvas with a full page load, not a route. The two views share
* root-level singletons (the graph, the Yjs shared model, the CU connection); handing
@@ -140,18 +229,97 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
* of yourself, broken runs. A fresh document is the reliable handover.
*/
public openRegularCanvas(): void {
+ this.save();
/* v8 ignore start -- full-document navigation; jsdom cannot navigate */
window.location.href = `${USER_WORKSPACE}/${this.wid}`;
/* v8 ignore stop */
}
+ /**
+ * Save the same way the operator canvas does. Both views edit one workflow, so the
+ * form has to write through the same debounced persist -- otherwise an author's
+ * setup, or a value someone filled in, would be gone on the next visit.
+ */
+ private registerAutoPersist(): void {
+ this.workflowActionService
+ .workflowChanged()
+ .pipe(debounceTime(SAVE_DEBOUNCE_TIME_IN_MS), untilDestroyed(this))
+ .subscribe(() => this.save());
+ }
+
+ /**
+ * Save the workflow this page opened, and only that one. The persist endpoint creates a
+ * workflow when the payload has no id, so saving whatever the graph holds would spawn
+ * stray "Untitled workflow" rows when the page is left before its workflow loaded.
+ */
+ private save(): void {
+ if (!this.userService.isLogin() || !this.workflowPersistService.isWorkflowPersistEnabled()) {
+ return;
+ }
+ const workflow = this.workflowActionService.getWorkflow();
+ if (workflow.wid === undefined || workflow.wid !== this.wid) {
+ return;
+ }
+ const preserved: Workflow = {
+ ...workflow,
+ content: { ...workflow.content, operatorPositions: this.positionsToSave(workflow.content) },
+ };
+ // On the way out the subscription must NOT be tied to this component: ngOnDestroy
+ // calls save(), and untilDestroyed would tear the subscription down as part of the
+ // very same destroy sequence, aborting the request that was the point of the call.
+ const persist = this.workflowPersistService.persistWorkflow(preserved);
+ // The `destroyed` branch deliberately omits untilDestroyed (see above); the persist call
+ // is a one-shot HTTP request that completes on its own, so it needs no teardown operator.
+ // eslint-disable-next-line rxjs-angular/prefer-takeuntil
+ (this.destroyed ? persist : persist.pipe(untilDestroyed(this))).subscribe({
+ // Feed the saved workflow back, exactly as the operator canvas does: this advances
+ // lastModifiedTime (and the normalised name), and the metadata subscription then repaints
+ // the title bar -- so "Saved at ..." moves past the moment the page opened.
+ next: updatedWorkflow => this.workflowActionService.setWorkflowMetadata(updatedWorkflow),
+ // A save that fails silently is the worst thing this page can do: the author walks
+ // away believing the form they just built is stored.
+ error: () => this.notificationService.error("Could not save. Your latest changes are not stored yet."),
+ });
+ }
+
+ /**
+ * A position for every operator: the live one from the shared model (what a co-editor's drag
+ * has set), else the load-time snapshot, else origin. Preferring live saves what is current
+ * rather than overwriting moves with a stale copy; the fallbacks keep the guarantee that every
+ * operator has a position, since loading throws on one that does not.
+ */
+ private positionsToSave(content: WorkflowContent): { [operatorID: string]: Point } {
+ const positions: { [operatorID: string]: Point } = {};
+ for (const operator of content.operators) {
+ positions[operator.operatorID] = content.operatorPositions?.[operator.operatorID] ??
+ this.storedPositions[operator.operatorID] ?? { x: 0, y: 0 };
+ }
+ return positions;
+ }
+
+ /**
+ * Run after a short delay, unless the page is gone by then: the callback touches the view,
+ * and detectChanges on a destroyed view throws -- reachable by navigating away while the
+ * name field is still waiting to be measured.
+ */
+ private later(fn: () => void, delayMs = 0): void {
+ setTimeout(() => {
+ if (!this.destroyed) {
+ fn();
+ }
+ }, delayMs);
+ }
+
/**
* Tear down exactly what the operator canvas tears down: both views drive the same
* singleton services, so anything left bound here follows the user to the next page
* (the symptom was a frozen canvas after a visit -- the old shared model still attached).
+ * On the way out, save once more so a last edit is not lost.
*/
@HostListener("window:beforeunload")
ngOnDestroy(): void {
+ this.destroyed = true;
+ this.save();
this.workflowActionService.clearWorkflow();
this.computingUnitStatusService.disconnect();
this.executeWorkflowService.resetExecutionAndWorkers();
diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
index 90285492d4f..af7ba64f078 100644
--- a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
+++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
@@ -17,9 +17,10 @@
* under the License.
*/
+import { DatePipe } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, Router } from "@angular/router";
-import { of, Subject } from "rxjs";
+import { EMPTY, of, Subject } from "rxjs";
import { WorkflowFormComponent } from "./workflow-form.component";
import { UserIconComponent } from "../../../dashboard/component/user/user-icon/user-icon.component";
@@ -78,17 +79,29 @@ describe("WorkflowFormComponent (rendered template)", () => {
disableWorkflowModification: vi.fn(),
clearWorkflow: vi.fn(),
getWorkflowMetadata: () => ({ name: "scGPT", lastModifiedTime: undefined }),
+ getWorkflow: () => ({ wid: 7, content: { operators: [], operatorPositions: {} } }),
+ setWorkflowName: vi.fn(),
+ workflowChanged: () => EMPTY,
+ workflowMetaDataChanged: () => EMPTY,
+ },
+ },
+ {
+ provide: WorkflowPersistService,
+ useValue: {
+ retrieveWorkflow: () => workflow$,
+ isWorkflowPersistEnabled: () => false,
+ persistWorkflow: () => of({}),
},
},
- { provide: WorkflowPersistService, useValue: { retrieveWorkflow: () => workflow$ } },
{ provide: OperatorMetadataService, useValue: { getOperatorMetadata: () => of({}) } },
{ provide: ExecuteWorkflowService, useValue: { resetExecutionAndWorkers: vi.fn() } },
{ provide: WorkflowResultService, useValue: { clearResults: vi.fn() } },
{ provide: NotificationService, useValue: { error: vi.fn() } },
- { provide: UserService, useValue: { getCurrentUser: () => undefined } },
+ { provide: UserService, useValue: { getCurrentUser: () => undefined, isLogin: () => false } },
{ provide: ComputingUnitStatusService, useValue: { disconnect: vi.fn() } },
{ provide: WorkflowConsoleService, useValue: { clearConsoleMessages: vi.fn() } },
{ provide: GuiConfigService, useValue: { env: { formViewEnabled: true } } },
+ DatePipe,
],
}).compileComponents();
fixture = TestBed.createComponent(WorkflowFormComponent);
@@ -103,13 +116,30 @@ describe("WorkflowFormComponent (rendered template)", () => {
beforeEach(configure);
- it("renders the workflow's avatar and name in the title row", () => {
+ it("renders the workflow's avatar and name in the title row", async () => {
fixture.detectChanges(); // ngOnInit -> load()
finishLoad();
+ // ngModel writes the name into the input on a microtask; let it flush before reading.
+ await fixture.whenStable();
+ fixture.detectChanges();
expect(el(".pc-topbar")).not.toBeNull();
expect(el("nz-avatar.wid")).not.toBeNull();
- expect(el(".wf-name")?.textContent?.trim()).toBe("scGPT");
+ // The name is an editable input on this slice; its value is the workflow name.
+ expect((el("input.wf-name") as HTMLInputElement | null)?.value).toBe("scGPT");
+ });
+
+ it("renames the workflow when the name input fires a change", async () => {
+ fixture.detectChanges();
+ finishLoad();
+ await fixture.whenStable();
+ const spy = vi.spyOn(fixture.componentInstance, "onRenameWorkflow");
+ const input = el("input.wf-name") as HTMLInputElement;
+
+ input.value = "Renamed";
+ input.dispatchEvent(new Event("change"));
+
+ expect(spy).toHaveBeenCalled();
});
it("switches to the operator canvas when the Canvas control is clicked", () => {
diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
index b047361535c..2fc33a6a192 100644
--- a/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
+++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { of } from "rxjs";
+import { of, Subject } from "rxjs";
import { vi } from "vitest";
import { DefaultView } from "../../../dashboard/type/workflow-metadata.interface";
@@ -34,6 +34,8 @@ export const formViewWorkflow = { name: "scGPT", defaultView: DefaultView.FORM,
*/
export function setupHarness() {
const router = { navigate: vi.fn() };
+ const workflowChangedStream = new Subject();
+ const workflowMetaDataChangedStream = new Subject();
const workflowActionService = {
resetAsNewWorkflow: vi.fn(),
@@ -42,9 +44,18 @@ export function setupHarness() {
enableWorkflowModification: vi.fn(),
disableWorkflowModification: vi.fn(),
clearWorkflow: vi.fn(),
+ workflowChanged: () => workflowChangedStream.asObservable(),
+ workflowMetaDataChanged: () => workflowMetaDataChangedStream.asObservable(),
+ getWorkflow: vi.fn().mockReturnValue({ wid: 7, content: { operators: [], operatorPositions: {} } }),
+ getWorkflowMetadata: () => ({ name: "scGPT", lastModifiedTime: 1767225600000 }),
+ setWorkflowName: vi.fn(),
+ setWorkflowMetadata: vi.fn(),
};
const workflowPersistService = {
retrieveWorkflow: vi.fn().mockReturnValue(of(formViewWorkflow)),
+ // Off by default so opening a workflow does not save; the save tests turn it on.
+ isWorkflowPersistEnabled: vi.fn().mockReturnValue(false),
+ persistWorkflow: vi.fn().mockReturnValue(of(formViewWorkflow)),
};
const coeditorPresenceService = { coeditors: [] };
const route = { snapshot: { params: { id: "7" } } };
@@ -52,16 +63,22 @@ export function setupHarness() {
const executeWorkflowService = { resetExecutionAndWorkers: vi.fn() };
const workflowResultService = { clearResults: vi.fn() };
const notificationService = { error: vi.fn() };
- const userService = { getCurrentUser: () => undefined };
+ // Not logged in by default so opening a workflow does not save; the save tests log in.
+ const userService = { getCurrentUser: () => undefined, isLogin: vi.fn().mockReturnValue(false) };
const cdr = { detectChanges: vi.fn() };
const computingUnitStatusService = { disconnect: vi.fn() };
const workflowConsoleService = { clearConsoleMessages: vi.fn() };
+ // The name field is measured off the host; querySelector returns null so the measuring
+ // (DOM-layout, jsdom has none) short-circuits.
+ const host = { nativeElement: { querySelector: () => null } };
+ const datePipe = { transform: () => "01/01/2026 00:00:00" };
const config = { env: { formViewEnabled: true } };
// Point the persist mock at `workflow`; each spec supplies the remaining constructor
// arguments in its own order via the named mocks above.
const useWorkflow = (workflow: any) => {
workflowPersistService.retrieveWorkflow.mockReturnValue(of(workflow));
+ workflowPersistService.persistWorkflow.mockReturnValue(of(workflow));
};
return {
@@ -79,6 +96,10 @@ export function setupHarness() {
cdr,
computingUnitStatusService,
workflowConsoleService,
+ host,
+ datePipe,
config,
+ workflowChangedStream,
+ workflowMetaDataChangedStream,
};
}