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