This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-8376-2a21b6610c6e51a6bf956f4101571609e5e7d796
in repository https://gitbox.apache.org/repos/asf/texera.git

commit ebc65b54bc0cbbf6a74b4fc1f8c811488fe0f554
Author: yangzhang75 <[email protected]>
AuthorDate: Fri Sep 4 19:35:25 2026 +0000

    feat(workflow-form): Form View page shell, load, and the shared spec 
harness (#8376)
    
    ### What changes were proposed in this PR?
    
    The base of the Form View page (parent issue #8011): a new
    `/user/workflow/:id/form` page, of which this PR lays down the read-only
    shell. Frontend only.
    
    This PR is **independent of the property-editor "choose exposed" PR
    (#8318)** and builds directly on the merged foundation (feature flag
    #8013, `default_view` #8014, shared types #8015, form-binding service
    #8016). The Form View is two parallel tracks -- the authoring affordance
    (#8318) and the reader page -- and this is the first slice of the
    reader-page track; the later page slices (title bar/save, read-only
    preview, rendering the inputs, running, results, authoring) all build on
    this component.
    
    * Behind the `formViewEnabled` flag it loads the workflow the URL names;
    with the flag off it hands straight to the operator canvas. With the
    flag on the form renders for any workflow -- `default_view` only picks
    which view a workflow lands on by default, not whether the form is
    reachable (per #8011) -- and a failed load shows a neutral message.
    * Shows the workflow read-only (the graph and its properties are not
    edited here) and offers a switch back to the operator canvas via a full
    page load.
    * Releases the shared singleton services (graph, shared model,
    computing-unit connection, console, results) on destroy, so nothing left
    bound here follows the user to the next page.
    
    * Registers the `workflow/:id/form` route (above the canvas
    `workflow/:id` route, so the trailing segment is not swallowed), making
    the page reachable at its real URL. The page still guards itself, so
    nothing is exposed while the flag is off.
    
    It also adds `workflow-form.spec-harness.ts`, the shared mock/factory
    the later page slices build their specs from; each slice extends it with
    the dependencies it introduces.
    
    #### Screenshot
    <img width="1511" height="787" alt="Screenshot 2026-09-03 at 7 28 08 PM"
    
src="https://github.com/user-attachments/assets/99c1b692-1f5f-46ea-b294-c5db470efb30";
    />
    
    
    #### Screenshot
    
    The read-only page shell at `/user/workflow/:id/form` (behind the flag,
    a form-default workflow). Since the spec deliberately skips template
    rendering, this is the rendered-page evidence for the view.
    
    <!-- drag the screenshot here; GitHub replaces this line with the
    uploaded image -->
    
    ### Any related issues, documentation, discussions?
    
    Closes #8018.
    
    Part of the Form View feature (parent issue #8011); builds on the merged
    #8013/#8014/#8015/#8016. Independent of #8318.
    
    ### How was this PR tested?
    
    Unit tests (vitest): `workflow-form.component.spec.ts` (7 tests, direct
    construction) plus `workflow-form.rendered.spec.ts` (4 TestBed-rendered
    tests that stand the real template up -- only the two child icons are
    blanked, so the page's own template renders as shipped). The page
    component injects ~14 services and heavy child components, so the direct
    spec constructs it with the shared harness's mocks; the rendered spec
    covers the template through TestBed. The component (61/61) and the
    template (31/31) are both at 100% locally. The template compiles cleanly
    under `ng build`.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Yes. Co-authored with Claude (Anthropic), reviewed line by line by the
    author before submission.
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    Co-authored-by: Meng Wang <[email protected]>
---
 frontend/src/app/app-routing.module.ts             |   8 +
 .../workflow-form/workflow-form.component.html     |  65 ++++++++
 .../workflow-form/workflow-form.component.scss     | 166 +++++++++++++++++++++
 .../workflow-form/workflow-form.component.spec.ts  | 148 ++++++++++++++++++
 .../workflow-form/workflow-form.component.ts       | 161 ++++++++++++++++++++
 .../workflow-form/workflow-form.rendered.spec.ts   | 144 ++++++++++++++++++
 .../workflow-form/workflow-form.spec-harness.ts    |  84 +++++++++++
 7 files changed, 776 insertions(+)

diff --git a/frontend/src/app/app-routing.module.ts 
b/frontend/src/app/app-routing.module.ts
index f9fd43cac1..d076c7e88c 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 0000000000..618e8c0e1c
--- /dev/null
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
@@ -0,0 +1,65 @@
+<!--
+ 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.
+-->
+
+<!-- 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. -->
+<div class="pc-topbar">
+  <nz-avatar
+    *ngIf="wid"
+    class="wid"
+    [nzText]="wid.toString()"></nz-avatar>
+  <span class="wf-name">{{ workflowName || "Untitled workflow" }}</span>
+  <span class="topbar-spacer"></span>
+  <div class="view-switch">
+    <button
+      type="button"
+      aria-pressed="false"
+      (click)="openRegularCanvas()">
+      Canvas
+    </button>
+    <button
+      type="button"
+      class="on"
+      aria-pressed="true">
+      Form View
+    </button>
+  </div>
+  <!-- Live collaborators, the same avatars the operator canvas shows -- Form 
view is just
+       another view of the same shared workflow, so co-editors belong here 
too. -->
+  <ng-container *ngFor="let user of coeditorPresenceService.coeditors">
+    <texera-coeditor-user-icon [coeditor]="user"></texera-coeditor-user-icon>
+  </ng-container>
+  <texera-user-icon></texera-user-icon>
+</div>
+
+<div class="pc-page">
+  <div class="pc-rule"></div>
+
+  <div
+    *ngIf="loading"
+    class="pc-loading">
+    Loading…
+  </div>
+
+  <!-- Body is filled in by the following PRs: the read-only workflow preview, 
the inputs,
+       running and the results. This PR is the page shell -- load, show, hand 
back. -->
+  <div [hidden]="loading"></div>
+</div>
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 0000000000..29960a1972
--- /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 0000000000..76732c814d
--- /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<typeof setupHarness>;
+  let router: { navigate: ReturnType<typeof vi.fn> };
+  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 0000000000..3d8f2b8f8c
--- /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 0000000000..90285492d4
--- /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<WorkflowFormComponent>;
+  let workflow$: Subject<any>;
+  const navigate = vi.fn();
+
+  const configure = async () => {
+    workflow$ = new Subject<any>();
+    // 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 0000000000..b047361535
--- /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,
+  };
+}

Reply via email to