From a3ed02cb0c5b38e7c63f6237aab9d7aa883b7231 Mon Sep 17 00:00:00 2001 From: Yang Zhang Date: Wed, 2 Sep 2026 18:05:12 -0700 Subject: [PATCH] feat(workflow-form): Form View page shell, load, and the shared spec harness The read-only shell of the Form View page: behind the feature flag it loads the workflow the URL names, redirects to the operator canvas when the workflow does not open in the form (or the flag is off), shows it read-only, hands back to the canvas, and releases the shared singleton services on the way out. The title bar's rename/save, the read-only preview, the inputs, running and results are added on top by later PRs. Also adds the shared spec harness (workflow-form.spec-harness.ts) these specs build the component from; later slices extend it with the mocks they introduce. Co-Authored-By: Claude Opus 4.8 --- frontend/src/app/app-routing.module.ts | 8 + .../workflow-form.component.html | 65 +++++++ .../workflow-form.component.scss | 166 ++++++++++++++++++ .../workflow-form.component.spec.ts | 145 +++++++++++++++ .../workflow-form/workflow-form.component.ts | 163 +++++++++++++++++ .../workflow-form.spec-harness.ts | 84 +++++++++ 6 files changed, 631 insertions(+) create mode 100644 frontend/src/app/workspace/component/workflow-form/workflow-form.component.html create mode 100644 frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss create mode 100644 frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts create mode 100644 frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts create mode 100644 frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts 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..7b5e6faa33d --- /dev/null +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts @@ -0,0 +1,145 @@ +/** + * 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(); + }); + + // Nothing about this page should exist for a workflow that opens on the canvas. + it("sends a canvas-default workflow to the operator canvas instead of an empty form", () => { + build({ ...formViewWorkflow, defaultView: DefaultView.CANVAS }).ngOnInit(); + + expect(router.navigate).toHaveBeenCalledWith([USER_WORKSPACE, "7"], { replaceUrl: true }); + expect(workflowActionService.reloadWorkflow).not.toHaveBeenCalled(); + }); + + // 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..f8462b92987 --- /dev/null +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts @@ -0,0 +1,163 @@ +/** + * 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 { DefaultView } from "../../../dashboard/type/workflow-metadata.interface"; +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 }) => { + // Even with the flag on, a workflow that opens on the operator canvas is sent + // there: reaching this URL for it lands on the canvas, not an empty page. + if (workflow.defaultView !== DefaultView.FORM) { + void this.router.navigate([USER_WORKSPACE, String(wid)], { replaceUrl: true }); + return; + } + 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.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, + }; +}