Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,24 @@

<!-- The operator canvas's title row, rebuilt here to the same measurements: same
height, same rule underneath, same full-bleed width, and the view switch pinned to
the same right inset ahead of the same user icon. The name is shown here; making it
editable (with the same rename/save the canvas gives) is the next PR. -->
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. -->
<div class="pc-topbar">
<nz-avatar
*ngIf="wid"
class="wid"
[nzText]="wid.toString()"></nz-avatar>
<span class="wf-name">{{ workflowName || "Untitled workflow" }}</span>
<!-- An input, not a label: the workflow can be renamed from either view, with the same
control in the same place. -->
<input
class="wf-name"
[(ngModel)]="workflowName"
(change)="onRenameWorkflow()"
placeholder="Untitled workflow" />
<!-- Editing here writes to the same workflow, so it earns the same reassurance the
operator canvas gives: the moment it was last saved. -->
<span class="saved-state">{{ autoSaveState }}</span>
<span class="topbar-spacer"></span>
<div class="view-switch">
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ $blue: #1890ff;
$text: rgba(0, 0, 0, 0.85);
$text-2: rgba(0, 0, 0, 0.45);
$divider: #f0f0f0;
$shell: #fafafa;

:host {
display: block;
Expand Down Expand Up @@ -64,16 +65,32 @@ $divider: #f0f0f0;
padding; matching that is what lines the two titles up. */
gap: 0;

/* Every value here is the operator canvas's .workflow-name, so the two title rows read
as the same row: the same size, weight and padding. Shown here; the editable input is
the next PR. */
/* An input, not a label: the workflow can be renamed from either view. Every value here is
the operator canvas's .workflow-name, so the two title rows read as the same row --
content-box sizing with the same padding, no border, chromeless until you reach for it,
and a width set from the text by the same measure-in-a-span trick. */
.wf-name {
min-width: 100px;
max-width: 800px;
font-size: 18px;
font-weight: 400;
padding: 2px 8px;
border: none;
box-sizing: content-box;
background: none;
color: $text;
outline: none;

&:hover {
background: $shell;
}
}

/* Immediately after the name, in the body colour and size of ordinary text, exactly as on
the operator canvas; the name field's own padding makes the gap. */
.saved-state {
color: $text;
white-space: nowrap;
}

.topbar-spacer {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,19 @@
*/

import { Router } from "@angular/router";
import { throwError } from "rxjs";
import { of, throwError } from "rxjs";

import { WorkflowFormComponent } from "./workflow-form.component";
import { setupHarness, formViewWorkflow } from "./workflow-form.spec-harness";
import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant";
import { DefaultView } from "../../../dashboard/type/workflow-metadata.interface";

/**
* These exercise the shell's own decisions -- what a reader is shown and where an ordinary
* workflow is sent -- without standing up the JointJS canvas. The component is built directly
* (not through TestBed) with the shared spec harness's mocks; the title bar, save and preview
* are added, with their own tests, by later PRs.
* These exercise the page's own decisions -- what a reader is shown, where an ordinary
* workflow is sent, and how the title bar renames and saves -- without standing up the JointJS
* canvas. The component is built directly (not through TestBed) with the shared spec harness's
* mocks; the read-only preview, inputs, running and results are added, with their own tests, by
* later PRs.
*/
describe("WorkflowFormComponent", () => {
let component: WorkflowFormComponent;
Expand All @@ -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;
Expand Down Expand Up @@ -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();
});
});
});
Loading
Loading