diff --git a/bin/k8s/templates/base/gateway/gateway-routes.yaml b/bin/k8s/templates/base/gateway/gateway-routes.yaml index 5c6ccd79be1..7fc5d1f6351 100644 --- a/bin/k8s/templates/base/gateway/gateway-routes.yaml +++ b/bin/k8s/templates/base/gateway/gateway-routes.yaml @@ -48,6 +48,12 @@ spec: - path: type: PathPrefix value: /api/compile + # The Python export reads the same compiled workflow the compile endpoint does, so it + # is served by the same service. Without a rule of its own it falls to the /api + # catch-all below and reaches the webserver, which has no such resource. + - path: + type: PathPrefix + value: /api/workflow-to-python backendRefs: - name: workflow-compiling-service-svc port: 9090 diff --git a/bin/single-node/nginx.conf b/bin/single-node/nginx.conf index 688e256d46d..f0d6970f11b 100644 --- a/bin/single-node/nginx.conf +++ b/bin/single-node/nginx.conf @@ -33,6 +33,12 @@ http { proxy_set_header X-Real-IP $remote_addr; } + location /api/workflow-to-python { + proxy_pass http://workflow-compiling-service:9090; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location /api/dataset { proxy_pass http://file-service:9092; proxy_set_header Host $host; diff --git a/frontend/proxy.config.json b/frontend/proxy.config.json index 483b7a308a5..0e3a0b66dcf 100755 --- a/frontend/proxy.config.json +++ b/frontend/proxy.config.json @@ -30,6 +30,11 @@ "secure": false, "changeOrigin": true }, + "/api/workflow-to-python": { + "target": "http://localhost:9090", + "secure": false, + "changeOrigin": true + }, "/api/dataset": { "target": "http://localhost:9092", "secure": false, diff --git a/frontend/src/app/dashboard/service/user/workflow-to-python/workflow-to-python.service.ts b/frontend/src/app/dashboard/service/user/workflow-to-python/workflow-to-python.service.ts new file mode 100644 index 00000000000..301515d9568 --- /dev/null +++ b/frontend/src/app/dashboard/service/user/workflow-to-python/workflow-to-python.service.ts @@ -0,0 +1,58 @@ +/** + * 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 { HttpClient, HttpHeaders } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { AppSettings } from "../../../../common/app-setting"; +import { LogicalPlan } from "../../../../workspace/types/execute-workflow.interface"; + +export const WORKFLOW_TO_PYTHON_ENDPOINT = "workflow-to-python"; + +export interface WorkflowToPythonResponse { + type: "success" | "failure"; + pythonCode?: string; + errorMessage?: string; +} + +@Injectable({ + providedIn: "root", +}) +export class WorkflowToPythonService { + constructor(private httpClient: HttpClient) {} + + public convertToPython(logicalPlan: LogicalPlan): Observable { + const body = { + operators: logicalPlan.operators, + links: logicalPlan.links, + opsToReuseResult: [], + opsToViewResult: [], + }; + + return this.httpClient.post( + `${AppSettings.getApiEndpoint()}/${WORKFLOW_TO_PYTHON_ENDPOINT}`, + body, + { + headers: new HttpHeaders({ + "Content-Type": "application/json", + }), + } + ); + } +} diff --git a/frontend/src/app/workspace/component/menu/menu.component.html b/frontend/src/app/workspace/component/menu/menu.component.html index 982a37e1917..c5bde9d41dc 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.html +++ b/frontend/src/app/workspace/component/menu/menu.component.html @@ -153,6 +153,15 @@ nz-icon nzType="download"> + + +
{{ pythonCodeForModal }}
+ + diff --git a/frontend/src/app/workspace/component/menu/menu.component.scss b/frontend/src/app/workspace/component/menu/menu.component.scss index a2ee69762b9..8b2bec4d2b7 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.scss +++ b/frontend/src/app/workspace/component/menu/menu.component.scss @@ -211,6 +211,37 @@ texera-coeditor-user-icon { } } +.workflow-python-modal { + display: flex; + flex-direction: column; + gap: 8px; +} + +.workflow-python-modal-toolbar { + display: flex; + justify-content: flex-end; +} + +.workflow-python-code { + max-height: 70vh; + overflow: auto; + margin: 0; + padding: 12px; + white-space: pre-wrap; + word-break: break-word; + background: #f6f8fa; + border: 1px solid #d9d9d9; + border-radius: 4px; + font-size: 12px; + line-height: 1.5; +} + +.jupyter-notebook-icon { + height: 1.1em; + width: auto; + vertical-align: -0.2em; +} + /* 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 diff --git a/frontend/src/app/workspace/component/menu/menu.component.spec.ts b/frontend/src/app/workspace/component/menu/menu.component.spec.ts index e9ab4f7e905..76856a79ff1 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.spec.ts +++ b/frontend/src/app/workspace/component/menu/menu.component.spec.ts @@ -57,6 +57,8 @@ import { USER_WORKFLOW, workspaceFormUrl } from "../../../app-routing.constant"; import { GuiConfigService } from "../../../common/service/gui-config.service"; import { MockGuiConfigService } from "../../../common/service/gui-config.service.mock"; import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service"; +import { WorkflowCompilingService } from "../../service/compile-workflow/workflow-compiling.service"; +import { CompilationState } from "../../types/workflow-compiling.interface"; import type { Mocked } from "vitest"; import { WarehouseService } from "../../../common/service/warehouse/warehouse.service"; import { Privilege } from "../../../dashboard/type/share-access.interface"; @@ -75,8 +77,10 @@ describe("MenuComponent", () => { let notificationService: NotificationService; let location: Location; let validationStream$: BehaviorSubject; + let compilationStream$: Subject; beforeEach(async () => { + compilationStream$ = new Subject(); await TestBed.configureTestingModule({ imports: [MenuComponent, HttpClientTestingModule, RouterTestingModule.withRoutes([]), NzModalModule], providers: [ @@ -93,6 +97,11 @@ describe("MenuComponent", () => { }, }, { provide: UserService, useClass: StubUserService }, + { + // stubbed so the debounced compile request of the real service does not outlive the test injector + provide: WorkflowCompilingService, + useValue: { getCompilationStateInfoChangedStream: () => compilationStream$.asObservable() }, + }, ...commonTestProviders, ], }).compileComponents(); @@ -321,6 +330,18 @@ describe("MenuComponent", () => { expect(behavior.disable).toBe(true); }); + it("returns 'Invalid Workflow' when the workflow does not compile", () => { + component.isWorkflowValid = true; + component.isWorkflowEmpty = false; + component.isWorkflowCompilable = false; + + const behavior = component.getRunButtonBehavior(); + + expect(behavior.text).toBe("Invalid Workflow"); + expect(behavior.icon).toBe("warning"); + expect(behavior.disable).toBe(true); + }); + it("returns 'Empty Workflow' when the workflow has no operators", () => { component.isWorkflowValid = true; component.isWorkflowEmpty = true; @@ -552,6 +573,53 @@ describe("MenuComponent", () => { expect(component.runDisable).toBe(true); }); + describe("export as Python", () => { + // Exporting the valid part of the graph answers a workflow with a required + // value missing by leaving that operator and its links out, and handing back + // a script that runs and is not the workflow. + it("refuses a workflow the canvas reports errors on", () => { + const errorSpy = vi.spyOn(notificationService, "error").mockImplementation(() => {}); + const convert = vi.spyOn(component["workflowToPythonService"], "convertToPython"); + validationStream$.next({ + errors: { "operator-1": { isValid: false, messages: { attribute: "required" } } } as any, + workflowEmpty: false, + }); + + component.onClickExportAsPython(); + + expect(convert).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + expect(component.isTranslatingToPython).toBe(false); + }); + + it("refuses an empty workflow", () => { + vi.spyOn(notificationService, "error").mockImplementation(() => {}); + const convert = vi.spyOn(component["workflowToPythonService"], "convertToPython"); + validationStream$.next({ errors: {}, workflowEmpty: true }); + + component.onClickExportAsPython(); + + expect(convert).not.toHaveBeenCalled(); + }); + + // The whole graph, not the valid part of it: the two agree once the workflow + // is valid, and asking for the valid part is what dropped operators. + it("sends the whole graph once the workflow is valid", () => { + const convert = vi + .spyOn(component["workflowToPythonService"], "convertToPython") + .mockReturnValue(of({ type: "success", pythonCode: "print(1)" } as any)); + const validSubgraph = vi.spyOn(validationWorkflowService, "getValidTexeraGraph"); + workflowActionService.addOperator(mockScanPredicate, mockPoint); + validationStream$.next({ errors: {}, workflowEmpty: false }); + + component.onClickExportAsPython(); + + expect(validSubgraph).not.toHaveBeenCalled(); + expect(convert).toHaveBeenCalled(); + expect(convert.mock.calls[0][0].operators).toHaveLength(1); + }); + }); + describe("hasOperators", () => { it("returns false on an empty graph", () => { expect(component.hasOperators()).toBe(false); @@ -642,6 +710,19 @@ describe("MenuComponent", () => { expect(component.computingUnitSelectionComponent.showAddComputeUnitModalVisible).not.toHaveBeenCalled(); }); + it("does nothing when the workflow does not compile", () => { + component.isWorkflowValid = true; + component.isWorkflowEmpty = false; + component.isWorkflowCompilable = false; + component.computingUnitStatus = ComputingUnitState.Running; + const executeSpy = vi.spyOn(executeWorkflowService, "executeWorkflowWithEmailNotification"); + + component.runWorkflow(); + + expect(executeSpy).not.toHaveBeenCalled(); + expect(component.computingUnitSelectionComponent.showAddComputeUnitModalVisible).not.toHaveBeenCalled(); + }); + it("does nothing when the workflow is empty", () => { component.isWorkflowValid = true; component.isWorkflowEmpty = true; @@ -856,6 +937,19 @@ describe("MenuComponent", () => { }); }); + it("copyPythonCodeToClipboard writes the generated Python script to the clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + const successSpy = vi.spyOn(notificationService, "success").mockImplementation(() => {}); + component.pythonCodeForModal = "print('hello')"; + + await component.copyPythonCodeToClipboard(); + + expect(writeText).toHaveBeenCalledWith("print('hello')"); + expect(successSpy).toHaveBeenCalledWith("Python script copied to clipboard"); + vi.unstubAllGlobals(); + }); + // The export flags are reset when a menu is destroyed -- right on leaving the workspace, wrong on // a hand-over between a workflow's two views, where the results are kept. A menu mounting on // retained results asks for them to be recomputed rather than offering a dead button. @@ -1809,6 +1903,53 @@ describe("MenuComponent", () => { } }); + it("re-applies the run button behavior on every compilation state event", () => { + component.isWorkflowValid = true; + component.isWorkflowEmpty = false; + component.computingUnitStatus = ComputingUnitState.Running; + component.executionState = ExecutionState.Uninitialized; + Object.defineProperty(component.workflowWebsocketService, "isConnected", { + get: () => true, + configurable: true, + }); + + compilationStream$.next(CompilationState.Failed); + expect(component.isWorkflowCompilable).toBe(false); + expect(component.runButtonText).toBe("Invalid Workflow"); + expect(component.runDisable).toBe(true); + + compilationStream$.next(CompilationState.Succeeded); + expect(component.isWorkflowCompilable).toBe(true); + expect(component.runButtonText).toBe("Run"); + expect(component.runDisable).toBe(false); + }); + + it("keeps Pause and Resume when a compile fails after the run started", () => { + component.isWorkflowValid = true; + component.isWorkflowEmpty = false; + component.computingUnitStatus = ComputingUnitState.Running; + Object.defineProperty(component.workflowWebsocketService, "isConnected", { + get: () => true, + configurable: true, + }); + + component.executionState = ExecutionState.Running; + compilationStream$.next(CompilationState.Failed); + expect(component.runButtonText).toBe("Pause"); + expect(component.runDisable).toBe(false); + + component.executionState = ExecutionState.Paused; + compilationStream$.next(CompilationState.Failed); + expect(component.runButtonText).toBe("Resume"); + expect(component.runDisable).toBe(false); + + // the failure still blocks the next run once this one ends + component.executionState = ExecutionState.Completed; + compilationStream$.next(CompilationState.Failed); + expect(component.runButtonText).toBe("Invalid Workflow"); + expect(component.runDisable).toBe(true); + }); + it("deactivates the export button unless the feature is on and results exist", () => { const guiConfig = TestBed.inject(GuiConfigService); const results$ = component.workflowResultExportService.hasResultToExportOnAllOperators; diff --git a/frontend/src/app/workspace/component/menu/menu.component.ts b/frontend/src/app/workspace/component/menu/menu.component.ts index e320fc484e2..189b0359562 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.ts +++ b/frontend/src/app/workspace/component/menu/menu.component.ts @@ -18,7 +18,7 @@ */ import { DatePipe, Location, NgIf, NgFor, NgTemplateOutlet, AsyncPipe } from "@angular/common"; -import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from "@angular/core"; +import { Component, ElementRef, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from "@angular/core"; import { Router, RouterLink } from "@angular/router"; import { UserService } from "../../../common/service/user/user.service"; import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; @@ -44,6 +44,7 @@ import { firstValueFrom, of } from "rxjs"; import { NzModalService } from "ng-zorro-antd/modal"; import { ResultExportationComponent } from "../result-exportation/result-exportation.component"; import { ReportGenerationService } from "../../service/report-generation/report-generation.service"; +import { WorkflowToPythonService } from "../../../dashboard/service/user/workflow-to-python/workflow-to-python.service"; import { ShareAccessComponent } from "src/app/dashboard/component/user/share-access/share-access.component"; import { PanelService } from "../../service/panel/panel.service"; import { USER_WORKFLOW, workspaceFormUrl } from "../../../app-routing.constant"; @@ -73,6 +74,8 @@ import { NzSwitchComponent } from "ng-zorro-antd/switch"; import { NzBadgeComponent } from "ng-zorro-antd/badge"; import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service"; +import { WorkflowCompilingService } from "../../service/compile-workflow/workflow-compiling.service"; +import { CompilationState } from "../../types/workflow-compiling.interface"; /** * MenuComponent is the top level menu bar that shows @@ -131,6 +134,9 @@ export class MenuComponent implements OnInit, OnDestroy { public ComputingUnitState = ComputingUnitState; // make Angular HTML access enum definition public isWorkflowValid: boolean = true; // this will check whether the workflow error or not public isWorkflowEmpty: boolean = false; + // whether the last compilation of the workflow failed. A workflow that cannot compile cannot be executed, + // so it is treated the same way as one that fails the schema / port validation. + public isWorkflowCompilable: boolean = true; public isSaving: boolean = false; /** A Form View hand-over is in progress (saving, then the route); a second click is a no-op. */ private handingOverToFormView = false; @@ -154,6 +160,7 @@ export class MenuComponent implements OnInit, OnDestroy { @Input() public currentExecutionName: string = ""; // reset executionName @Input() public particularVersionDate: string = ""; // placeholder for the metadata information of a particular workflow version @ViewChild("workflowNameInput") workflowNameInput: ElementRef | undefined; + @ViewChild("workflowPythonScriptModal", { static: true }) workflowPythonScriptModal?: TemplateRef; // variable bound with HTML to decide if the running spinner should show public runButtonText = "Run"; @@ -179,6 +186,7 @@ export class MenuComponent implements OnInit, OnDestroy { private location: Location, public undoRedoService: UndoRedoService, public validationWorkflowService: ValidationWorkflowService, + private workflowCompilingService: WorkflowCompilingService, public workflowPersistService: WorkflowPersistService, public workflowVersionService: WorkflowVersionService, public userService: UserService, @@ -195,6 +203,7 @@ export class MenuComponent implements OnInit, OnDestroy { private warehouseService: WarehouseService, protected config: GuiConfigService, private router: Router, + private workflowToPythonService: WorkflowToPythonService, private jupyterPanelService: JupyterPanelService, private fileSaverService: FileSaverService ) { @@ -253,6 +262,16 @@ export class MenuComponent implements OnInit, OnDestroy { this.applyRunButtonBehavior(this.getRunButtonBehavior()); }); + // the compilation errors are reported per operator on the canvas and in the result panel, but a workflow + // that cannot compile cannot be executed either, so the run button has to reflect the compilation state too + this.workflowCompilingService + .getCompilationStateInfoChangedStream() + .pipe(untilDestroyed(this)) + .subscribe(state => { + this.isWorkflowCompilable = state !== CompilationState.Failed; + this.applyRunButtonBehavior(this.getRunButtonBehavior()); + }); + // Subscribe to WorkflowResultExportService observable this.workflowResultExportService .getExportOnAllOperatorsStatusStream() @@ -387,14 +406,27 @@ export class MenuComponent implements OnInit, OnDestroy { this.onClickRunHandler = behavior.onClick; } + // the execution states in which the run button starts a new run rather than controlling one + private buttonStartsRun(): boolean { + return [ + ExecutionState.Uninitialized, + ExecutionState.Completed, + ExecutionState.Terminated, + ExecutionState.Killed, + ExecutionState.Failed, + ].includes(this.executionState); + } + public getRunButtonBehavior(): { text: string; icon: string; disable: boolean; onClick: () => void; } { - // If workflow is invalid, always disable and show "Invalid Workflow" - if (!this.isWorkflowValid) { + // If workflow is invalid, always disable and show "Invalid Workflow". A failed compile + // does the same, but only where the button would start a run: a compile can answer + // after the run started, and it must not take Pause/Resume away from that run. + if (!this.isWorkflowValid || (!this.isWorkflowCompilable && this.buttonStartsRun())) { return { text: "Invalid Workflow", icon: "warning", @@ -440,16 +472,7 @@ export class MenuComponent implements OnInit, OnDestroy { // the click into the create-warehouse modal. Only in the states whose // button would start a run: mid-execution the button is Pause/Resume/Kill, // and losing the last warehouse must not take that control away. - if ( - this.computingUnitSelectionComponent?.warehouseRequiredButMissing && - [ - ExecutionState.Uninitialized, - ExecutionState.Completed, - ExecutionState.Terminated, - ExecutionState.Killed, - ExecutionState.Failed, - ].includes(this.executionState) - ) { + if (this.computingUnitSelectionComponent?.warehouseRequiredButMissing && this.buttonStartsRun()) { return { text: "Warehouse", icon: "plus-circle", @@ -695,6 +718,61 @@ export class MenuComponent implements OnInit, OnDestroy { this.jupyterPanelService.openJupyterNotebookPanel(); } + public isTranslatingToPython = false; + public pythonCodeForModal = ""; + + public onClickExportAsPython(): void { + // Exporting the valid part of the graph answers a workflow with a required + // value missing by leaving that operator and its links out, and handing back + // a script that runs and is not the workflow. The button is disabled in that + // state; this says the same thing for a click that arrives another way. + if (this.isWorkflowEmpty || !this.isWorkflowValid) { + this.notificationService.error("This workflow cannot be exported yet: fix the errors the canvas reports first."); + return; + } + + // The whole graph, now that it is known to be whole. + const logicalPlan = ExecuteWorkflowService.getLogicalPlanRequest(this.workflowActionService.getTexeraGraph()); + + this.isTranslatingToPython = true; + this.workflowToPythonService + .convertToPython(logicalPlan) + .pipe(untilDestroyed(this)) + .subscribe({ + next: response => { + this.isTranslatingToPython = false; + if (response.type === "success") { + this.pythonCodeForModal = response.pythonCode ?? ""; + this.modalService.create({ + nzTitle: "Workflow as Python Script", + nzContent: this.workflowPythonScriptModal ?? "", + nzFooter: null, + nzWidth: 800, + }); + } else { + this.notificationService.error(response.errorMessage ?? "Failed to translate workflow to Python."); + } + }, + error: () => { + this.isTranslatingToPython = false; + this.notificationService.error("Request failed while translating workflow to Python."); + }, + }); + } + + public async copyPythonCodeToClipboard(): Promise { + if (!this.pythonCodeForModal) { + return; + } + + try { + await navigator.clipboard.writeText(this.pythonCodeForModal); + this.notificationService.success("Python script copied to clipboard"); + } catch (error) { + this.notificationService.error("Failed to copy Python script"); + } + } + public onClickExportWorkflow(): void { // The same shape the dashboard download produces (see exportedWorkflow): the content plus the // landing view as a sibling key, so a file exported here uploads as a form-default workflow too. @@ -976,7 +1054,7 @@ export class MenuComponent implements OnInit, OnDestroy { */ runWorkflow(): void { // Use the existing flags that were already updated via subscriptions - if (!this.isWorkflowValid || this.isWorkflowEmpty) { + if (!this.isWorkflowValid || !this.isWorkflowCompilable || this.isWorkflowEmpty) { return; } diff --git a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts index f88ef9e5cfc..19d8a83f352 100644 --- a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts +++ b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts @@ -33,6 +33,7 @@ import { ReactiveFormsModule } from "@angular/forms"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { NzDropDownModule } from "ng-zorro-antd/dropdown"; import { ValidationWorkflowService } from "src/app/workspace/service/validation/validation-workflow.service"; +import { WorkflowCompilingService } from "src/app/workspace/service/compile-workflow/workflow-compiling.service"; import { NzModalModule, NzModalService } from "ng-zorro-antd/modal"; import { commonTestProviders } from "../../../../../common/testing/test-utils"; // Import NzModalModule and NzModalService import type { Mocked } from "vitest"; @@ -56,6 +57,7 @@ describe("ContextMenuComponent", () => { let operatorMenuService: Mocked; let jointGraphWrapperSpy: Mocked; let validationWorkflowService: Mocked; + let workflowCompilingService: Mocked; let highlightedOperatorsSubject: BehaviorSubject; let highlightedCommentBoxesSubject: BehaviorSubject; @@ -71,7 +73,12 @@ describe("ContextMenuComponent", () => { jointGraphWrapperSpy.getCurrentHighlightedCommentBoxIDs.mockReturnValue([]); jointGraphWrapperSpy.getCurrentHighlightedLinkIDs.mockReturnValue([]); - const texeraGraphSpy = { isOperatorDisabled: vi.fn(), hasLinkWithID: vi.fn(), bundleActions: vi.fn() }; + const texeraGraphSpy = { + isOperatorDisabled: vi.fn(), + hasLinkWithID: vi.fn(), + bundleActions: vi.fn(), + getSubDAG: vi.fn(), + }; const workflowActionServiceSpy = { getJointGraphWrapper: vi.fn(), @@ -92,6 +99,7 @@ describe("ContextMenuComponent", () => { // Set up TexeraGraph spy return values texeraGraphSpy.hasLinkWithID.mockReturnValue(false); + texeraGraphSpy.getSubDAG.mockReturnValue({ operators: [], links: [] }); texeraGraphSpy.bundleActions.mockImplementation((callback: Function) => callback()); const workflowResultServiceSpy = { getResultService: vi.fn(), hasAnyResult: vi.fn() }; @@ -121,6 +129,9 @@ describe("ContextMenuComponent", () => { const validationWorkflowServiceSpy = { validateOperator: vi.fn() }; + const workflowCompilingServiceSpy = { getWorkflowCompilationErrors: vi.fn() }; + workflowCompilingServiceSpy.getWorkflowCompilationErrors.mockReturnValue({}); + await TestBed.configureTestingModule({ providers: [ { provide: OperatorMetadataService, useClass: StubOperatorMetadataService }, @@ -129,6 +140,7 @@ describe("ContextMenuComponent", () => { { provide: WorkflowResultExportService, useValue: workflowResultExportServiceSpy }, { provide: OperatorMenuService, useValue: operatorMenuService }, { provide: ValidationWorkflowService, useValue: validationWorkflowServiceSpy }, + { provide: WorkflowCompilingService, useValue: workflowCompilingServiceSpy }, NzModalService, // Provide NzModalService ...commonTestProviders, ], @@ -151,6 +163,7 @@ describe("ContextMenuComponent", () => { validationWorkflowService = TestBed.inject( ValidationWorkflowService ) as unknown as Mocked; + workflowCompilingService = TestBed.inject(WorkflowCompilingService) as unknown as Mocked; fixture = TestBed.createComponent(ContextMenuComponent); component = fixture.componentInstance; @@ -263,6 +276,31 @@ describe("ContextMenuComponent", () => { expect(texeraGraphSpy.isOperatorDisabled).toHaveBeenCalledWith("op1"); }); + it("should return false when the target operator failed to compile", () => { + texeraGraphSpy.getSubDAG.mockReturnValue({ operators: [{ operatorID: "op1" }], links: [] } as any); + workflowCompilingService.getWorkflowCompilationErrors.mockReturnValue({ op1: {} as any }); + + expect(component.canExecuteOperator()).toBe(false); + expect(texeraGraphSpy.getSubDAG).toHaveBeenCalledWith("op1"); + }); + + it("should return false when an upstream operator failed to compile", () => { + texeraGraphSpy.getSubDAG.mockReturnValue({ + operators: [{ operatorID: "op1" }, { operatorID: "upstream" }], + links: [], + } as any); + workflowCompilingService.getWorkflowCompilationErrors.mockReturnValue({ upstream: {} as any }); + + expect(component.canExecuteOperator()).toBe(false); + }); + + it("should return true when the compilation error is outside the target's sub-DAG", () => { + texeraGraphSpy.getSubDAG.mockReturnValue({ operators: [{ operatorID: "op1" }], links: [] } as any); + workflowCompilingService.getWorkflowCompilationErrors.mockReturnValue({ unrelated: {} as any }); + + expect(component.canExecuteOperator()).toBe(true); + }); + it("should check disabled status only for valid operators", () => { // First test with invalid operator validationWorkflowService.validateOperator.mockReturnValue({ isValid: false, messages: {} }); diff --git a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts index 911172b6ba3..f7a0888e00c 100644 --- a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts +++ b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts @@ -26,6 +26,7 @@ import { WorkflowResultExportService } from "src/app/workspace/service/workflow- import { NzModalService } from "ng-zorro-antd/modal"; import { ResultExportationComponent } from "../../../result-exportation/result-exportation.component"; import { ValidationWorkflowService } from "src/app/workspace/service/validation/validation-workflow.service"; +import { WorkflowCompilingService } from "src/app/workspace/service/compile-workflow/workflow-compiling.service"; import { GuiConfigService } from "../../../../../common/service/gui-config.service"; import { NzMenuDirective, NzMenuItemComponent } from "ng-zorro-antd/menu"; import { NgIf } from "@angular/common"; @@ -65,7 +66,8 @@ export class ContextMenuComponent { protected config: GuiConfigService, private workflowResultService: WorkflowResultService, private modalService: NzModalService, - private validationWorkflowService: ValidationWorkflowService + private validationWorkflowService: ValidationWorkflowService, + private workflowCompilingService: WorkflowCompilingService ) { this.registerWorkflowModifiableChangedHandler(); this.operatorMenuService.highlightedOperators$ @@ -96,10 +98,23 @@ export class ContextMenuComponent { private isOperatorExecutable(operatorID: string): boolean { return ( this.validationWorkflowService.validateOperator(operatorID).isValid && - !this.workflowActionService.getTexeraGraph().isOperatorDisabled(operatorID) + !this.workflowActionService.getTexeraGraph().isOperatorDisabled(operatorID) && + this.isSubDAGCompilable(operatorID) ); } + /** + * Executing to an operator runs the operator together with everything upstream of it, so the entry has to be + * disabled when any operator in that sub-DAG failed to compile, not only when the target operator itself did. + */ + private isSubDAGCompilable(operatorID: string): boolean { + const compilationErrors = this.workflowCompilingService.getWorkflowCompilationErrors(); + return this.workflowActionService + .getTexeraGraph() + .getSubDAG(operatorID) + .operators.every(operator => !(operator.operatorID in compilationErrors)); + } + public hasHighlightedLinks(): boolean { return this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedLinkIDs().length > 0; }