diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts
index f9fd43cac15..d076c7e88c4 100644
--- a/frontend/src/app/app-routing.module.ts
+++ b/frontend/src/app/app-routing.module.ts
@@ -25,6 +25,7 @@ import { UserQuotaComponent } from "./dashboard/component/user/user-quota/user-q
import { UserComputingUnitComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit.component";
import { UserVenvComponent } from "./dashboard/component/user/user-venv/user-venv.component";
import { WorkspaceComponent } from "./workspace/component/workspace.component";
+import { WorkflowFormComponent } from "./workspace/component/workflow-form/workflow-form.component";
import { AboutComponent } from "./hub/component/about/about.component";
import { TexeraLoginComponent } from "./hub/component/login/texera-login.component";
import { AuthGuardService } from "./common/service/user/auth-guard.service";
@@ -134,6 +135,13 @@ routes.push({
path: "workflow",
component: UserWorkflowComponent,
},
+ {
+ // Must precede "workflow/:id" so the trailing "form" segment is not swallowed by
+ // the canvas route. The page guards itself (the feature flag off, or a workflow
+ // that does not open in the form, hands back to the canvas).
+ path: "workflow/:id/form",
+ component: WorkflowFormComponent,
+ },
{
path: "workflow/:id",
component: WorkspaceComponent,
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
new file mode 100644
index 00000000000..618e8c0e1c6
--- /dev/null
+++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
@@ -0,0 +1,65 @@
+
+
+
+
+
+ {{ workflowName || "Untitled workflow" }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading…
+
+
+
+
+
diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss
new file mode 100644
index 00000000000..29960a1972c
--- /dev/null
+++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss
@@ -0,0 +1,166 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/* Colours are Texera's own: ng-zorro blue and the #d9d9d9 / #f0f0f0 neutrals. */
+$blue: #1890ff;
+$text: rgba(0, 0, 0, 0.85);
+$text-2: rgba(0, 0, 0, 0.45);
+$divider: #f0f0f0;
+
+:host {
+ display: block;
+ background: #fff;
+ color: $text;
+ overflow-y: auto;
+ height: 100%;
+}
+
+/* Measured against the operator canvas's #menu-user so the two rows are the same row:
+ 36px tall, a 1px rule underneath, full bleed across the content area. Anything the
+ view switch needs to line up with lives here, not in the padded column below. */
+.pc-topbar {
+ height: 36px;
+ margin: 1px;
+ border-bottom: 1px solid #cacaca;
+ display: flex;
+ align-items: center;
+ // Pinned to the top so the Canvas / Form view switch (and the workflow name) stay
+ // reachable while scrolling the preview/results below.
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ background: #fff;
+
+ /* Trims the difference between this row's right edge and the operator canvas's, so
+ the switch and the user icon land on the same x in both views. */
+ padding-right: 10px;
+
+ /* The operator canvas pins its avatar to the top of the row with a 1px nudge rather
+ than centring it; copying that is what puts the two on the same pixel. */
+ .wid {
+ flex: none;
+ margin-left: 3px;
+ align-self: flex-start;
+ margin-top: 1px;
+ }
+
+ /* The operator canvas puts 0 gap between its avatar and name, which carries its own
+ 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. */
+ .wf-name {
+ min-width: 100px;
+ max-width: 800px;
+ font-size: 18px;
+ font-weight: 400;
+ padding: 2px 8px;
+ color: $text;
+ }
+
+ .topbar-spacer {
+ flex: 1;
+ }
+
+ /* Sized by its own content, exactly as on the operator canvas -- reserving a fixed
+ width here pushed the switch 74px further left than its counterpart. */
+ texera-user-icon {
+ flex: none;
+ }
+}
+
+/* One workflow, two ways of working on it. Rendered identically in the operator canvas
+ and the Form View, in the same slot of the same title row, so the control never moves
+ when the view does -- that stillness is what makes the two read as two views of one
+ thing rather than two pages.
+
+ Deliberately quiet: the indicator is a rule sitting on the row's own bottom border,
+ not a filled button. This is secondary navigation and it shares a screen with Run,
+ which is the one thing here that should be solid blue. The current view is inert --
+ clicking the view you are already in should do nothing. */
+.view-switch {
+ display: inline-flex;
+ align-self: stretch;
+ align-items: stretch;
+ flex: none;
+ gap: 20px;
+ margin-right: 20px;
+
+ button {
+ appearance: none;
+ border: 0;
+ background: none;
+ cursor: pointer;
+ font: inherit;
+ font-size: 13px;
+ color: rgba(0, 0, 0, 0.45);
+ padding: 0;
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ white-space: nowrap;
+ transition: color 0.15s;
+
+ /* Sits on the row's bottom rule, so the two read as tabs of the row rather than
+ as a widget dropped into it. */
+ &::after {
+ content: "";
+ position: absolute;
+ left: -2px;
+ right: -2px;
+ bottom: -1px;
+ height: 2px;
+ background: transparent;
+ transition: background 0.15s;
+ }
+
+ &:hover:not(.on) {
+ color: rgba(0, 0, 0, 0.85);
+ }
+ }
+
+ button.on {
+ color: rgba(0, 0, 0, 0.85);
+ font-weight: 500;
+ cursor: default;
+
+ &::after {
+ background: $blue;
+ }
+ }
+}
+
+.pc-page {
+ max-width: 1320px;
+ margin: 0 auto;
+ padding: 22px 24px 80px;
+}
+
+.pc-rule {
+ height: 1px;
+ background: $divider;
+ margin: 20px 0 24px;
+}
+
+.pc-loading {
+ color: $text-2;
+ padding: 40px 0;
+}
diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
new file mode 100644
index 00000000000..76732c814d8
--- /dev/null
+++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
@@ -0,0 +1,148 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Router } from "@angular/router";
+import { 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.
+ */
+describe("WorkflowFormComponent", () => {
+ let component: WorkflowFormComponent;
+ let h: ReturnType;
+ let router: { navigate: ReturnType };
+ let workflowActionService: any;
+ let workflowPersistService: any;
+
+ const build = (workflow: any) => {
+ h.useWorkflow(workflow);
+ component = new WorkflowFormComponent(
+ h.coeditorPresenceService as any,
+ h.route as any,
+ h.router as unknown as Router,
+ h.workflowActionService as any,
+ h.workflowPersistService as any,
+ h.operatorMetadataService as any,
+ h.executeWorkflowService as any,
+ h.workflowResultService as any,
+ h.notificationService as any,
+ h.userService as any,
+ h.cdr as any,
+ h.computingUnitStatusService as any,
+ h.workflowConsoleService as any,
+ h.config as any
+ );
+ return component;
+ };
+
+ beforeEach(() => {
+ h = setupHarness();
+ router = h.router;
+ workflowActionService = h.workflowActionService;
+ workflowPersistService = h.workflowPersistService;
+ });
+
+ describe("who this page is for", () => {
+ it("opens the form for a workflow that opens in it", () => {
+ build(formViewWorkflow).ngOnInit();
+
+ expect(component.wid).toBe(7);
+ expect(component.workflowName).toBe("scGPT");
+ expect(component.loading).toBe(false);
+ expect(router.navigate).not.toHaveBeenCalled();
+ });
+
+ // A bad URL id should not try to load anything.
+ it("goes back to the workflow list when the URL carries no valid id", () => {
+ h.route.snapshot.params.id = "not-a-number";
+
+ build(formViewWorkflow).ngOnInit();
+
+ expect(router.navigate).toHaveBeenCalledWith([USER_WORKFLOW]);
+ expect(workflowActionService.reloadWorkflow).not.toHaveBeenCalled();
+ });
+
+ // The flag, not the workflow, gates the form: with it on, the form renders for any
+ // workflow -- default_view only picks the landing view (settled on #8011), so a
+ // canvas-default workflow opens here too rather than being bounced to the canvas.
+ it("renders the form for any workflow while the flag is on, whatever its default view", () => {
+ build({ ...formViewWorkflow, defaultView: DefaultView.CANVAS }).ngOnInit();
+
+ expect(router.navigate).not.toHaveBeenCalled();
+ expect(workflowActionService.reloadWorkflow).toHaveBeenCalled();
+ expect(component.loading).toBe(false);
+ });
+
+ // With the feature turned off, the form does not exist at all -- even for a form-default
+ // workflow, the page hands over to the canvas without loading anything, so a failing
+ // request cannot strand the visitor on an error instead.
+ it("hands over to the canvas when the feature flag is off, without loading", () => {
+ h.config.env.formViewEnabled = false;
+
+ build(formViewWorkflow).ngOnInit();
+
+ expect(router.navigate).toHaveBeenCalledWith([USER_WORKSPACE, "7"], { replaceUrl: true });
+ expect(workflowPersistService.retrieveWorkflow).not.toHaveBeenCalled();
+ expect(workflowActionService.resetAsNewWorkflow).not.toHaveBeenCalled();
+ });
+
+ it("shows the workflow read-only, since editing belongs to the other view", () => {
+ build(formViewWorkflow).ngOnInit();
+
+ expect(workflowActionService.disableWorkflowModification).toHaveBeenCalled();
+ expect(workflowActionService.enableWorkflowModification).not.toHaveBeenCalled();
+ expect(workflowActionService.setNewSharedModel).toHaveBeenCalled();
+ expect(workflowActionService.reloadWorkflow).toHaveBeenCalled();
+ });
+
+ it("goes back to the list when the workflow cannot be opened", () => {
+ build(formViewWorkflow);
+ workflowPersistService.retrieveWorkflow.mockReturnValue(throwError(() => new Error("denied")));
+
+ component.ngOnInit();
+
+ expect(h.notificationService.error).toHaveBeenCalled();
+ expect(router.navigate).toHaveBeenCalledWith([USER_WORKFLOW]);
+ });
+ });
+
+ describe("leaving the page", () => {
+ // Both views drive the same singleton services, so the page must release them on the way
+ // out or they follow the user to the next page.
+ it("releases the shared services on destroy", () => {
+ build(formViewWorkflow).ngOnInit();
+
+ component.ngOnDestroy();
+
+ expect(workflowActionService.clearWorkflow).toHaveBeenCalled();
+ expect(h.computingUnitStatusService.disconnect).toHaveBeenCalled();
+ expect(h.executeWorkflowService.resetExecutionAndWorkers).toHaveBeenCalled();
+ expect(h.workflowConsoleService.clearConsoleMessages).toHaveBeenCalled();
+ expect(h.workflowResultService.clearResults).toHaveBeenCalled();
+ });
+ });
+});
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
new file mode 100644
index 00000000000..3d8f2b8f8c7
--- /dev/null
+++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
@@ -0,0 +1,161 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ChangeDetectorRef, Component, HostListener, OnDestroy, OnInit } from "@angular/core";
+import { CommonModule } from "@angular/common";
+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 { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant";
+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";
+import { UserService } from "../../../common/service/user/user.service";
+import { ExecuteWorkflowService } from "../../service/execute-workflow/execute-workflow.service";
+import { OperatorMetadataService } from "../../service/operator-metadata/operator-metadata.service";
+import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service";
+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 { CoeditorUserIconComponent } from "../menu/coeditor-user-icon/coeditor-user-icon.component";
+import { CoeditorPresenceService } from "../../service/workflow-graph/model/coeditor-presence.service";
+
+/**
+ * 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.
+ */
+@UntilDestroy()
+@Component({
+ selector: "texera-workflow-form",
+ templateUrl: "./workflow-form.component.html",
+ styleUrls: ["./workflow-form.component.scss"],
+ imports: [CommonModule, NzAvatarModule, UserIconComponent, CoeditorUserIconComponent],
+})
+export class WorkflowFormComponent implements OnInit, OnDestroy {
+ public wid?: number;
+ public workflowName = "";
+ public loading = true;
+
+ constructor(
+ // Public for the template: shows the same live collaborator avatars as the canvas.
+ public coeditorPresenceService: CoeditorPresenceService,
+ private route: ActivatedRoute,
+ private router: Router,
+ private workflowActionService: WorkflowActionService,
+ private workflowPersistService: WorkflowPersistService,
+ private operatorMetadataService: OperatorMetadataService,
+ private executeWorkflowService: ExecuteWorkflowService,
+ private workflowResultService: WorkflowResultService,
+ private notificationService: NotificationService,
+ private userService: UserService,
+ private cdr: ChangeDetectorRef,
+ private computingUnitStatusService: ComputingUnitStatusService,
+ private workflowConsoleService: WorkflowConsoleService,
+ private config: GuiConfigService
+ ) {}
+
+ ngOnInit(): void {
+ const wid = Number(this.route.snapshot.params.id);
+ if (!Number.isFinite(wid)) {
+ void this.router.navigate([USER_WORKFLOW]);
+ return;
+ }
+ this.wid = wid;
+ this.load(wid);
+ }
+
+ private load(wid: number): void {
+ // With the feature off the form does not exist: hand straight to the operator canvas
+ // without loading anything, so a request that then fails cannot strand the visitor on
+ // an error instead of the page they would have gotten.
+ if (!this.config.env.formViewEnabled) {
+ void this.router.navigate([USER_WORKSPACE, String(wid)], { replaceUrl: true });
+ return;
+ }
+ this.workflowActionService.resetAsNewWorkflow();
+ forkJoin({
+ metadata: this.operatorMetadataService.getOperatorMetadata(),
+ workflow: this.workflowPersistService.retrieveWorkflow(wid),
+ })
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: ({ workflow }) => {
+ // With the flag on, the form renders for any workflow: default_view only decides
+ // which view a workflow lands on by default, not whether the form is reachable
+ // (settled on #8011). Gating the form on default_view here would quietly reintroduce
+ // 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.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.loading = false;
+ this.cdr.detectChanges();
+ },
+ // The load can fail for many reasons (no access, a network or server error, the
+ // metadata call): a neutral message covers them without claiming it was permissions.
+ error: () => {
+ this.notificationService.error("Unable to open this workflow.");
+ void this.router.navigate([USER_WORKFLOW]);
+ },
+ });
+ }
+
+ /**
+ * Show the workflow rather than edit it: the graph shape and its properties are read-only
+ * on this page. A later PR's authoring mode makes properties editable with write access.
+ */
+ private applyEditability(): void {
+ this.workflowActionService.disableWorkflowModification();
+ }
+
+ /**
+ * 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
+ * over in-process left the old state attached -- undraggable operators, a ghost coeditor
+ * of yourself, broken runs. A fresh document is the reliable handover.
+ */
+ public openRegularCanvas(): void {
+ /* v8 ignore start -- full-document navigation; jsdom cannot navigate */
+ window.location.href = `${USER_WORKSPACE}/${this.wid}`;
+ /* v8 ignore stop */
+ }
+
+ /**
+ * 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).
+ */
+ @HostListener("window:beforeunload")
+ ngOnDestroy(): void {
+ this.workflowActionService.clearWorkflow();
+ this.computingUnitStatusService.disconnect();
+ this.executeWorkflowService.resetExecutionAndWorkers();
+ this.workflowConsoleService.clearConsoleMessages();
+ this.workflowResultService.clearResults();
+ }
+}
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
new file mode 100644
index 00000000000..90285492d4f
--- /dev/null
+++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
@@ -0,0 +1,144 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { ActivatedRoute, Router } from "@angular/router";
+import { of, Subject } from "rxjs";
+
+import { WorkflowFormComponent } from "./workflow-form.component";
+import { UserIconComponent } from "../../../dashboard/component/user/user-icon/user-icon.component";
+import { CoeditorUserIconComponent } from "../menu/coeditor-user-icon/coeditor-user-icon.component";
+import { CoeditorPresenceService } from "../../service/workflow-graph/model/coeditor-presence.service";
+import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service";
+import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service";
+import { OperatorMetadataService } from "../../service/operator-metadata/operator-metadata.service";
+import { ExecuteWorkflowService } from "../../service/execute-workflow/execute-workflow.service";
+import { WorkflowResultService } from "../../service/workflow-result/workflow-result.service";
+import { NotificationService } from "../../../common/service/notification/notification.service";
+import { UserService } from "../../../common/service/user/user.service";
+import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
+import { WorkflowConsoleService } from "../../service/workflow-console/workflow-console.service";
+import { GuiConfigService } from "../../../common/service/gui-config.service";
+
+/**
+ * The direct-construction spec exercises the component's logic without a DOM; this one stands
+ * the page's real template up through TestBed so the rendered shell is covered too -- the
+ * name/avatar row, the Canvas switch actually firing, the loading/body swap, and the co-editor
+ * row -- which is the review's evidence of the rendered page in place of a screenshot.
+ */
+describe("WorkflowFormComponent (rendered template)", () => {
+ let fixture: ComponentFixture;
+ let workflow$: Subject;
+ const navigate = vi.fn();
+
+ const configure = async () => {
+ workflow$ = new Subject();
+ // Blank out ONLY the two child icons: their ng-zorro dropdown/menu needs a host context
+ // this page does not set up. The override is on the children, not the page, so the page's
+ // own .component.html renders as shipped and stays covered -- which is the point of this
+ // spec, and why the no-restricted-syntax guard (aimed at blanking the component under test)
+ // does not apply here.
+ /* eslint-disable no-restricted-syntax */
+ TestBed.overrideComponent(UserIconComponent, { set: { template: "" } });
+ TestBed.overrideComponent(CoeditorUserIconComponent, { set: { template: "" } });
+ /* eslint-enable no-restricted-syntax */
+
+ await TestBed.configureTestingModule({
+ imports: [WorkflowFormComponent],
+ providers: [
+ // One co-editor so the collaborator row (the *ngFor) renders and is covered.
+ {
+ provide: CoeditorPresenceService,
+ useValue: { coeditors: [{ clientId: "c1", userName: "co", color: "#888" }] },
+ },
+ { provide: ActivatedRoute, useValue: { snapshot: { params: { id: "7" } } } },
+ { provide: Router, useValue: { navigate } },
+ {
+ provide: WorkflowActionService,
+ useValue: {
+ resetAsNewWorkflow: vi.fn(),
+ setNewSharedModel: vi.fn(),
+ reloadWorkflow: vi.fn(),
+ disableWorkflowModification: vi.fn(),
+ clearWorkflow: vi.fn(),
+ getWorkflowMetadata: () => ({ name: "scGPT", lastModifiedTime: undefined }),
+ },
+ },
+ { 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: ComputingUnitStatusService, useValue: { disconnect: vi.fn() } },
+ { provide: WorkflowConsoleService, useValue: { clearConsoleMessages: vi.fn() } },
+ { provide: GuiConfigService, useValue: { env: { formViewEnabled: true } } },
+ ],
+ }).compileComponents();
+ fixture = TestBed.createComponent(WorkflowFormComponent);
+ };
+
+ const el = (sel: string): HTMLElement | null => fixture.nativeElement.querySelector(sel);
+ const finishLoad = (workflow: any = { name: "scGPT", content: {} }) => {
+ workflow$.next(workflow);
+ workflow$.complete();
+ fixture.detectChanges();
+ };
+
+ beforeEach(configure);
+
+ it("renders the workflow's avatar and name in the title row", () => {
+ fixture.detectChanges(); // ngOnInit -> load()
+ finishLoad();
+
+ expect(el(".pc-topbar")).not.toBeNull();
+ expect(el("nz-avatar.wid")).not.toBeNull();
+ expect(el(".wf-name")?.textContent?.trim()).toBe("scGPT");
+ });
+
+ it("switches to the operator canvas when the Canvas control is clicked", () => {
+ fixture.detectChanges();
+ finishLoad();
+ const spy = vi.spyOn(fixture.componentInstance, "openRegularCanvas").mockImplementation(() => {});
+
+ el(".view-switch button")!.click(); // the first button is Canvas
+
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it("shows the loading state until the workflow arrives, then swaps to the body", () => {
+ fixture.detectChanges(); // load() started; workflow not yet emitted
+
+ expect(el(".pc-loading")?.textContent?.trim()).toBe("Loading…");
+
+ finishLoad();
+
+ expect(el(".pc-loading")).toBeNull();
+ });
+
+ it("tears the workflow down when the browser unloads (the beforeunload host binding)", () => {
+ fixture.detectChanges();
+ finishLoad();
+ const workflowActionService: any = TestBed.inject(WorkflowActionService);
+
+ window.dispatchEvent(new Event("beforeunload"));
+
+ expect(workflowActionService.clearWorkflow).toHaveBeenCalled();
+ });
+});
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
new file mode 100644
index 00000000000..b047361535c
--- /dev/null
+++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
@@ -0,0 +1,84 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { of } from "rxjs";
+import { vi } from "vitest";
+
+import { DefaultView } from "../../../dashboard/type/workflow-metadata.interface";
+
+/** The workflow every test opens by default: a form-default workflow, writable, empty content. */
+export const formViewWorkflow = { name: "scGPT", defaultView: DefaultView.FORM, readonly: false, content: {} };
+
+/**
+ * Mocks shared by every workflow-form spec, plus the component factory. Only what the current
+ * slices exercise is mocked; later slices add the dependencies (and streams) they introduce, so
+ * each PR's additions are covered by that PR's own spec. `setupHarness()` runs once per
+ * `beforeEach`; `build(workflow)` (in each spec) constructs the component with the subset its
+ * constructor takes.
+ */
+export function setupHarness() {
+ const router = { navigate: vi.fn() };
+
+ const workflowActionService = {
+ resetAsNewWorkflow: vi.fn(),
+ setNewSharedModel: vi.fn(),
+ reloadWorkflow: vi.fn(),
+ enableWorkflowModification: vi.fn(),
+ disableWorkflowModification: vi.fn(),
+ clearWorkflow: vi.fn(),
+ };
+ const workflowPersistService = {
+ retrieveWorkflow: vi.fn().mockReturnValue(of(formViewWorkflow)),
+ };
+ const coeditorPresenceService = { coeditors: [] };
+ const route = { snapshot: { params: { id: "7" } } };
+ const operatorMetadataService = { getOperatorMetadata: () => of({}) };
+ const executeWorkflowService = { resetExecutionAndWorkers: vi.fn() };
+ const workflowResultService = { clearResults: vi.fn() };
+ const notificationService = { error: vi.fn() };
+ const userService = { getCurrentUser: () => undefined };
+ const cdr = { detectChanges: vi.fn() };
+ const computingUnitStatusService = { disconnect: vi.fn() };
+ const workflowConsoleService = { clearConsoleMessages: vi.fn() };
+ 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));
+ };
+
+ return {
+ useWorkflow,
+ router,
+ coeditorPresenceService,
+ route,
+ workflowActionService,
+ workflowPersistService,
+ operatorMetadataService,
+ executeWorkflowService,
+ workflowResultService,
+ notificationService,
+ userService,
+ cdr,
+ computingUnitStatusService,
+ workflowConsoleService,
+ config,
+ };
+}