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, + }; +}