This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/texera.git
commit 9709525f9b14a6b2ea05f0fce0156b9c09cd5e62 Author: yangzhang75 <[email protected]> AuthorDate: Fri Sep 25 04:46:33 2026 +0000 fix(frontend): make a result cell's download work off the canvas (#8539) ### What changes were proposed in this PR? The download button on a result cell did nothing when pressed from the Form View: the dialog opened, Export closed it, and no file arrived — no request, no error, no message. An export scopes itself to the operators selected on the canvas unless the caller asked for all of them. That is the right scope for the two callers that read the canvas: the top menu asks for everything, the context menu exports the selection. The third caller is the download button on a result cell, and a cell belongs to one operator, whoever happens to be selected. The same cell is also mounted on the Form View, where the selection holds the step the user is configuring and holds nothing at all until they click one. There the export found an empty scope, so `performExport` returned at its `operatorIds.length === 0` check before sending anything. The dialog's own checks read the same empty selection, so a restricted operator went unreported there too. A caller that knows which operators it is exporting now says so, and both the dialog and the service prefer that over the selection: - `workflow-result-export.service.ts`: a trailing optional `requestedOperatorIds` on `exportWorkflowExecutionResult`, threaded to `performExport`. Precedence is `exportAll` (whole workflow) > caller-named > canvas selection. - `result-exportation.component.ts`: reads `operatorIds` off the modal data; `getOperatorIdsToCheck()` returns it when present, and `onClickExportResult` forwards it. - `result-table-frame.component.ts`: the cell's dialog names the operator whose results the frame is showing. Nothing changes for the menu or the context menu, which name no operators and keep the old scope. Ten lines of logic in three files. ### Any related issues, documentation, discussions? Closes #8538, which has a recording of the before state. One thing that PR does not change: `export-execution-result-enabled` still defaults to `false` in `gui.conf`, and `performExport` returns on that switch before it reaches any of this. A deployment with the switch off sees no change from this PR; every deployment that has it on gets a working cell download. Whether that default should flip is a separate question and is noted at the end of the issue. ### How was this PR tested? - Nine new unit tests across the three spec files: the service prefers a named scope over the selection and works with an empty selection, falls back to the selection when none is named, and still lets `exportAll` win; the dialog names none by default, checks the named operator, and hands it to the export; the frame names its operator, and names none when it has none. - Mutation check, each restored afterwards: dropping the named scope in the service, in the dialog, or in the frame each turns exactly one named test red. - Coverage on the three changed files: 100% lines, functions and branches, except one pre-existing unreachable branch elsewhere in `result-table-frame.component.ts` that this PR does not touch. - `ng test --watch=false`: 223 files, 6065 passed, 1 skipped (pre-existing), 0 failed. `ng build --configuration=production` (AOT): clean. `eslint` and `prettier --check` on all six files: clean. - Exercised in a running instance built from this branch on top of `main`: a Form View workflow whose result column holds binary files. With the fix, the cell download sends `POST /api/executions/result/export/local` and returns the bytes (4096 bytes, HDF5 magic `89 48 44 46`, byte-exact) while the canvas selection is empty. With the three production hunks reverted on the same stack, the same click sends no request and produces no file within two minutes. #### After the fix: downloading a result file from the Form View https://github.com/user-attachments/assets/ba8da56b-429d-43d9-9961-aa078268e2d0 ### Was this PR authored or co-authored using generative AI tooling? Yes. Generated-by: Claude Code (Claude Opus 5, Anthropic). Co-authored with Claude; the author reviewed the change before submission. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <[email protected]> --- .../result-exportation.component.spec.ts | 173 +++++++++++++++++++-- .../result-exportation.component.ts | 37 +++-- .../result-table-frame.component.html | 4 + .../result-table-frame.component.spec.ts | 74 +++++++++ .../result-table-frame.component.ts | 15 +- .../workflow-result-export.service.spec.ts | 111 ++++++++++--- .../workflow-result-export.service.ts | 39 +++-- 7 files changed, 389 insertions(+), 64 deletions(-) diff --git a/frontend/src/app/workspace/component/result-exportation/result-exportation.component.spec.ts b/frontend/src/app/workspace/component/result-exportation/result-exportation.component.spec.ts index ed3c62c8fc..9470b694dd 100644 --- a/frontend/src/app/workspace/component/result-exportation/result-exportation.component.spec.ts +++ b/frontend/src/app/workspace/component/result-exportation/result-exportation.component.spec.ts @@ -88,7 +88,10 @@ describe("ResultExportationComponent", () => { { provide: WorkflowActionService, useValue: { - getTexeraGraph: vi.fn().mockReturnValue({ getAllOperators: vi.fn().mockReturnValue([]) }), + getTexeraGraph: vi.fn().mockReturnValue({ + getAllOperators: vi.fn().mockReturnValue([]), + getAllOperatorIDs: vi.fn().mockReturnValue([]), + }), getJointGraphWrapper: vi .fn() .mockReturnValue({ getCurrentHighlightedOperatorIDs: vi.fn().mockReturnValue([]) }), @@ -141,8 +144,7 @@ describe("ResultExportationComponent", () => { expect(args[0]).toBe("csv"); // exportType, from modal data expect(args[1]).toBe("my-workflow"); // workflowName expect(args[2]).toEqual([1]); // datasetIds resolved from ds.dataset.did - expect(args[6]).toBe(true); // exportAll, because sourceTriggered === "menu" - expect(args[7]).toBe("dataset"); // destination + expect(args[6]).toBe("dataset"); // destination expect(modalClose).toHaveBeenCalledTimes(1); }); @@ -152,10 +154,45 @@ describe("ResultExportationComponent", () => { expect(exportWorkflowExecutionResult).toHaveBeenCalledTimes(1); const args = exportWorkflowExecutionResult.mock.calls[0]; expect(args[2]).toEqual([]); // local download carries no dataset ids - expect(args[7]).toBe("local"); + expect(args[6]).toBe("local"); expect(modalClose).toHaveBeenCalledTimes(1); }); + // The dialog reports on a scope (what a blocking dataset blocks, what kind of output is on + // offer) and then exports. Those have to be the same scope, so the export is handed the list + // the dialog resolved rather than a flag the service would resolve again, differently. + describe("the scope the dialog exports", () => { + const exportedOperatorIds = (): readonly string[] => { + component.onClickExportResult("local"); + return exportWorkflowExecutionResult.mock.calls[0][8]; + }; + const graph = () => TestBed.inject(WorkflowActionService).getTexeraGraph() as any; + const wrapper = () => TestBed.inject(WorkflowActionService).getJointGraphWrapper() as any; + + it("is the operators the caller named, whatever the canvas has selected", () => { + component.operatorIds = ["opNamed"]; + wrapper().getCurrentHighlightedOperatorIDs.mockReturnValue(["opHighlighted"]); + + expect(exportedOperatorIds()).toEqual(["opNamed"]); + }); + + it("is the whole workflow when the top menu opened the dialog", () => { + component.operatorIds = []; + component.sourceTriggered = "menu"; + graph().getAllOperatorIDs.mockReturnValue(["op1", "op2"]); + + expect(exportedOperatorIds()).toEqual(["op1", "op2"]); + }); + + it("is the canvas selection for anyone else who named nothing", () => { + component.operatorIds = []; + component.sourceTriggered = "context-menu"; + wrapper().getCurrentHighlightedOperatorIDs.mockReturnValue(["opHighlighted"]); + + expect(exportedOperatorIds()).toEqual(["opHighlighted"]); + }); + }); + it("onClickCreateNewDataset opens the dataset-creator modal and adopts the created dataset", () => { const created = { dataset: { did: 9, name: "brand-new" }, @@ -183,6 +220,7 @@ describe("ResultExportationComponent", () => { }; graph.getTexeraGraph.mockReturnValue({ getAllOperators: () => ids.map(id => ({ operatorID: id })), + getAllOperatorIDs: () => ids, }); } @@ -240,6 +278,7 @@ describe("ResultExportationComponent", () => { }; graph.getTexeraGraph.mockReturnValue({ getAllOperators: () => ids.map(id => ({ operatorID: id })), + getAllOperatorIDs: () => ids, }); } @@ -381,6 +420,7 @@ describe("ResultExportationComponent", () => { }; graph.getTexeraGraph.mockReturnValue({ getAllOperators: () => ids.map(id => ({ operatorID: id })), + getAllOperatorIDs: () => ids, }); } @@ -484,7 +524,7 @@ describe("ResultExportationComponent", () => { exportBtn!.triggerEventHandler("click", null); expect(exportWorkflowExecutionResult).toHaveBeenCalledTimes(1); const args = exportWorkflowExecutionResult.mock.calls[0]; - expect(args[7]).toBe("local"); + expect(args[6]).toBe("local"); }); it("renders the dataset destination with its list and create button", () => { @@ -601,7 +641,7 @@ describe("ResultExportationComponent", () => { expect(exportWorkflowExecutionResult).toHaveBeenCalledTimes(1); const args = exportWorkflowExecutionResult.mock.calls[0]; expect(args[2]).toEqual([1]); // the clicked dataset's did - expect(args[7]).toBe("dataset"); + expect(args[6]).toBe("dataset"); expect(modalClose).toHaveBeenCalledTimes(1); }); @@ -780,7 +820,10 @@ describe("ResultExportationComponent (context-menu source with default modal dat { provide: WorkflowActionService, useValue: { - getTexeraGraph: vi.fn().mockReturnValue({ getAllOperators: vi.fn().mockReturnValue([]) }), + getTexeraGraph: vi.fn().mockReturnValue({ + getAllOperators: vi.fn().mockReturnValue([]), + getAllOperatorIDs: vi.fn().mockReturnValue([]), + }), getJointGraphWrapper: vi.fn().mockReturnValue({ getCurrentHighlightedOperatorIDs: vi.fn().mockReturnValue(["hl-1", "hl-2"]), }), @@ -826,7 +869,7 @@ describe("ResultExportationComponent (context-menu source with default modal dat expect(component.blockedOperatorIds).toEqual([]); }); - it("exports highlighted operators only (exportAll === false) for a context-menu trigger", () => { + it("exports to the destination it was given for a context-menu trigger", () => { const exportService = TestBed.inject(WorkflowResultExportService) .exportWorkflowExecutionResult as unknown as ReturnType<typeof vi.fn>; @@ -834,7 +877,117 @@ describe("ResultExportationComponent (context-menu source with default modal dat expect(exportService).toHaveBeenCalledTimes(1); const args = exportService.mock.calls[0]; - expect(args[6]).toBe(false); // exportAll is false because sourceTriggered !== "menu" - expect(args[7]).toBe("local"); + expect(args[6]).toBe("local"); // destination + }); + + // The context menu names no operators of its own, so the dialog resolves the selection and + // hands that over. It does not pass nothing and leave the export to read the canvas a second + // time, which would let what is exported differ from what the dialog reported on. + it("hands over the selection it resolved, not an empty scope", () => { + const exportService = TestBed.inject(WorkflowResultExportService) + .exportWorkflowExecutionResult as unknown as ReturnType<typeof vi.fn>; + + component.onClickExportResult("local"); + + expect(exportService.mock.calls[0][8]).toEqual(["hl-1", "hl-2"]); + }); +}); + +// A result cell opens this dialog naming the one operator whose results it shows. Both the +// dialog's own checks and the export it triggers have to use that operator: the cell is also +// mounted on the Form View, where nothing is selected until the user clicks a step, so reading +// the selection there answered "no operators" and the export sent nothing. +describe("ResultExportationComponent (a caller that names its operators)", () => { + let component: ResultExportationComponent; + let fixture: ComponentFixture<ResultExportationComponent>; + + // Exactly what result-table-frame.component.ts puts in nzData, including the absence of + // sourceTriggered: a result cell names its operator instead of naming a trigger. + const CELL_DATA = { + exportType: "data", + workflowName: "cell-workflow", + defaultFileName: "content_3", + rowIndex: 3, + columnIndex: 1, + operatorIds: ["op-named"], + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResultExportationComponent], + providers: [ + { provide: NZ_MODAL_DATA, useValue: CELL_DATA }, + { provide: NzModalRef, useValue: { close: vi.fn(), getConfig: () => ({}) } }, + { provide: NzModalService, useValue: { create: vi.fn().mockReturnValue({ afterClose: of(null) }) } }, + { + provide: WorkflowResultExportService, + useValue: { + computeRestrictionAnalysis: vi.fn().mockReturnValue(of(new WorkflowResultDownloadability(new Map()))), + exportWorkflowExecutionResult: vi.fn(), + }, + }, + { + provide: DatasetService, + useValue: { retrieveAccessibleDatasets: vi.fn().mockReturnValue(of([])) }, + }, + { + provide: WorkflowActionService, + useValue: { + // Both fallbacks answer with something else, so a passing test can only be reading + // the operator the caller named. + getTexeraGraph: vi.fn().mockReturnValue({ + getAllOperators: vi.fn().mockReturnValue([{ operatorID: "op-all" }]), + getAllOperatorIDs: vi.fn().mockReturnValue(["op-all"]), + }), + getJointGraphWrapper: vi.fn().mockReturnValue({ + getCurrentHighlightedOperatorIDs: vi.fn().mockReturnValue(["op-highlighted"]), + }), + }, + }, + { + provide: WorkflowResultService, + useValue: { + determineOutputTypes: vi.fn().mockReturnValue({ + hasAnyResult: true, + isTableOutput: true, + isVisualizationOutput: false, + containsBinaryData: true, + }), + }, + }, + { + provide: ComputingUnitStatusService, + useValue: { getSelectedComputingUnit: vi.fn().mockReturnValue(of(null)) }, + }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(ResultExportationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + // A result cell names its operator and sends no trigger of its own, so this field arrives + // absent and must read as a string rather than as undefined wearing a string's type. + it("reads an absent source trigger as an empty string", () => { + expect(component.sourceTriggered).toBe(""); + }); + + it("checks the named operator rather than the canvas selection", () => { + expect(component.exportableOperatorIds).toEqual(["op-named"]); + expect(component.blockedOperatorIds).toEqual([]); + }); + + it("hands the named operator to the export", () => { + const exportService = TestBed.inject(WorkflowResultExportService) + .exportWorkflowExecutionResult as unknown as ReturnType<typeof vi.fn>; + + component.onClickExportResult("local"); + + expect(exportService).toHaveBeenCalledTimes(1); + expect(exportService.mock.calls[0][8]).toEqual(["op-named"]); }); }); diff --git a/frontend/src/app/workspace/component/result-exportation/result-exportation.component.ts b/frontend/src/app/workspace/component/result-exportation/result-exportation.component.ts index 33abb62524..96fafe7ca7 100644 --- a/frontend/src/app/workspace/component/result-exportation/result-exportation.component.ts +++ b/frontend/src/app/workspace/component/result-exportation/result-exportation.component.ts @@ -81,17 +81,20 @@ import { NzIconDirective } from "ng-zorro-antd/icon"; ], }) export class ResultExportationComponent implements OnInit { - /* Two sources can trigger this dialog, one from context-menu - which only export highlighted operators - and second is menu which wants to export all operators + /* Three sources can trigger this dialog: the context-menu, which exports the highlighted + operators; the menu, which wants to export all of them; and a result cell, which names the + one operator whose results it shows in operatorIds below and sends no trigger of its own. */ - sourceTriggered: string = inject(NZ_MODAL_DATA).sourceTriggered; + sourceTriggered: string = inject(NZ_MODAL_DATA).sourceTriggered ?? ""; workflowName: string = inject(NZ_MODAL_DATA).workflowName; inputFileName: string = inject(NZ_MODAL_DATA).defaultFileName ?? ""; rowIndex: number = inject(NZ_MODAL_DATA).rowIndex ?? -1; columnIndex: number = inject(NZ_MODAL_DATA).columnIndex ?? -1; destination: string = ""; exportType: string = inject(NZ_MODAL_DATA).exportType ?? ""; + // The operators this export covers, when the caller knows them. Empty leaves the scope to + // the rule below: the whole workflow for the menu, the canvas selection for the rest. + operatorIds: readonly string[] = inject(NZ_MODAL_DATA).operatorIds ?? []; isTableOutput: boolean = false; isVisualizationOutput: boolean = false; containsBinaryData: boolean = false; @@ -103,15 +106,21 @@ export class ResultExportationComponent implements OnInit { filteredUserAccessibleDatasets: DashboardDataset[] = []; /** - * Gets the operator IDs to check for restrictions based on the source trigger. - * Menu: all operators, Context menu: highlighted operators only + * The operators this export covers, which everything below reads: what may be exported, what + * a blocking dataset blocks, and what kind of output the dialog is offering. + * + * A caller that named them wins. The two fallbacks each belong to a caller reading the + * canvas -- the menu exports the whole workflow, the context menu exports the selection -- + * and a result cell is neither: it belongs to one operator, whoever is selected. On the Form + * View nothing is selected until the user clicks a step, so this answered "no operators", and + * with nothing in scope a blocked operator went unreported. */ private getOperatorIdsToCheck(): readonly string[] { + if (this.operatorIds.length > 0) { + return this.operatorIds; + } if (this.sourceTriggered === "menu") { - return this.workflowActionService - .getTexeraGraph() - .getAllOperators() - .map(op => op.operatorID); + return this.workflowActionService.getTexeraGraph().getAllOperatorIDs(); } else { return this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs(); } @@ -199,7 +208,7 @@ export class ResultExportationComponent implements OnInit { const operatorIds = this.getOperatorIdsToCheck(); if (operatorIds.length === 0) { - // No operators highlighted + // No operators in scope this.isTableOutput = false; this.isVisualizationOutput = false; this.containsBinaryData = false; @@ -261,9 +270,11 @@ export class ResultExportationComponent implements OnInit { this.rowIndex, this.columnIndex, this.inputFileName, - this.sourceTriggered === "menu", destination, - this.selectedComputingUnit + this.selectedComputingUnit, + // The same scope the dialog reported on, so what is exported is what the dialog said it + // would export. + this.getOperatorIdsToCheck() ); this.modalRef.close(); } diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html index e9c5f83ee4..be6b1c06b1 100644 --- a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html +++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html @@ -193,7 +193,11 @@ <ng-container *ngSwitchDefault>{{ column.getCell(row) }}</ng-container> </ng-container> </span> + <!-- Not rendered when result export is switched off for the deployment: every action + behind it returns without sending a request, so the button would be there and do + nothing. The top menu and the context menu already honour the same switch. --> <button + *ngIf="guiConfigService.env.exportExecutionResultEnabled" (click)="downloadData(currentResult[i][column.columnDef], i, columnIndex, column.columnDef); $event.stopPropagation()" nz-button nzType="link" diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts index 50e7c224a2..9afb07d04c 100644 --- a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts +++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts @@ -31,6 +31,8 @@ import { By, DomSanitizer } from "@angular/platform-browser"; import { of, Subject } from "rxjs"; import { commonTestProviders } from "../../../../common/testing/test-utils"; import { GuiConfigService } from "../../../../common/service/gui-config.service"; +import { MockGuiConfigService } from "../../../../common/service/gui-config.service.mock"; +import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service"; import { isAudioUrl, isImageUrl, isVideoUrl } from "../../../../common/util/media-type.util"; import { OperatorPaginationResultService, @@ -92,6 +94,13 @@ describe("ResultTableFrameComponent", () => { const queryParams = (pageIndex: number): NzTableQueryParams => ({ pageIndex, pageSize: 5, sort: [], filter: [] }); + // `commonTestProviders` supplies MockGuiConfigService, which is what the component receives, + // so the deployment's export switch is driven through it. + const setExport = (enabled: boolean): void => + (TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig({ + exportExecutionResultEnabled: enabled, + }); + // Re-creates the component so spies installed on service streams are picked up by ngOnInit. // Destroys the fixture created in beforeEach (or a prior recreate) first so its // untilDestroyed subscriptions are torn down and cannot leak across the test. @@ -612,6 +621,7 @@ describe("ResultTableFrameComponent", () => { describe("downloadData", () => { it("opens the export modal for the clicked cell's absolute row index", () => { const createSpy = vi.spyOn(modalService, "create").mockReturnValue({} as any); + component.operatorId = "op1"; component.currentPageIndex = 2; component.pageSize = 5; @@ -625,6 +635,45 @@ describe("ResultTableFrameComponent", () => { expect.objectContaining({ exportType: "data", defaultFileName: "name_6", rowIndex: 6, columnIndex: 3 }) ); }); + + // The export otherwise scopes itself to whatever the canvas has selected, which answers a + // different question: what the user picked, not what this frame shows. The frame also mounts + // on the Form View, where nothing is selected until the user clicks a step, so the export + // found an empty scope and did nothing. + it("names the operator whose results it is showing, so the export has a scope", () => { + const createSpy = vi.spyOn(modalService, "create").mockReturnValue({} as any); + component.operatorId = "op1"; + + component.downloadData("alice", 0, 0, "name"); + + expect((createSpy.mock.calls[0][0] as any).nzData).toEqual(expect.objectContaining({ operatorIds: ["op1"] })); + }); + + // `getWorkflowMetadata` is a method: read without calling it, `.name` is the function's own + // name, so every export carried the string "getWorkflowMetadata" as the workflow's name. + it("carries the workflow's real name, not the name of the accessor", () => { + const createSpy = vi.spyOn(modalService, "create").mockReturnValue({} as any); + vi.spyOn(TestBed.inject(WorkflowActionService), "getWorkflowMetadata").mockReturnValue({ + name: "scGPT", + } as any); + component.operatorId = "op1"; + + component.downloadData("alice", 0, 0, "name"); + + expect((createSpy.mock.calls[0][0] as any).nzData).toEqual(expect.objectContaining({ workflowName: "scGPT" })); + }); + + // A cell belongs to the operator whose results the frame shows. With no operator there is + // nothing to scope an export to, and opening the dialog would only let the reader press + // buttons that export nothing. + it("does not open the dialog at all when the frame has no operator", () => { + const createSpy = vi.spyOn(modalService, "create").mockReturnValue({} as any); + component.operatorId = undefined; + + component.downloadData("alice", 0, 0, "name"); + + expect(createSpy).not.toHaveBeenCalled(); + }); }); describe("column navigation and search", () => { @@ -688,6 +737,8 @@ describe("ResultTableFrameComponent", () => { }); it("renders headers, per-column stats, and clickable row cells once results arrive", () => { + // The download click below needs a rendered button, and the button is gated on the switch. + setExport(true); component.operatorId = "op1"; component.setupResultTable([SAMPLE_ROW], 1); component.isFrontPagination = false; @@ -736,6 +787,29 @@ describe("ResultTableFrameComponent", () => { download.triggerEventHandler("click", { stopPropagation: vi.fn() }); expect(downloadSpy).toHaveBeenCalledWith("alice", 0, 0, "name"); }); + + // Result export is a deployment switch. Every action behind this button returns without + // sending a request when it is off, so the button is not rendered rather than left there + // to do nothing. The top menu and the context menu already honour the same switch. + describe("the per-cell download button and the export switch", () => { + const renderOneRow = (exportEnabled: boolean) => { + setExport(exportEnabled); + component.operatorId = "op1"; + component.setupResultTable([SAMPLE_ROW], 1); + component.isFrontPagination = false; + fixture.detectChanges(); + }; + + it("is rendered when result export is on", () => { + renderOneRow(true); + expect(fixture.debugElement.query(By.css("button.download-button"))).not.toBeNull(); + }); + + it("is not rendered when result export is off", () => { + renderOneRow(false); + expect(fixture.debugElement.query(By.css("button.download-button"))).toBeNull(); + }); + }); }); it("should detect media URLs for result cells", () => { diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts index 7fcfd126a3..a329161369 100644 --- a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts +++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts @@ -130,7 +130,8 @@ export class ResultTableFrameComponent implements OnInit, OnChanges { private changeDetectorRef: ChangeDetectorRef, private sanitizer: DomSanitizer, private workflowStatusService: WorkflowStatusService, - private guiConfigService: GuiConfigService + // Read by the template only (the export button's flag); the template can see a protected member. + protected guiConfigService: GuiConfigService ) {} ngOnChanges(changes: SimpleChanges): void { @@ -464,6 +465,11 @@ export class ResultTableFrameComponent implements OnInit, OnChanges { } downloadData(data: any, rowIndex: number, columnIndex: number, columnName: string): void { + // A cell belongs to the operator whose results this frame is showing. Without one there is + // nothing to scope an export to, and the dialog would open only to export nothing. + if (!this.operatorId) { + return; + } const realRowNumber = (this.currentPageIndex - 1) * this.pageSize + rowIndex; const defaultFileName = `${columnName}_${realRowNumber}`; const modal = this.modalService.create({ @@ -471,10 +477,15 @@ export class ResultTableFrameComponent implements OnInit, OnChanges { nzContent: ResultExportationComponent, nzData: { exportType: "data", - workflowName: this.workflowActionService.getWorkflowMetadata.name, + workflowName: this.workflowActionService.getWorkflowMetadata()?.name, defaultFileName: defaultFileName, rowIndex: realRowNumber, columnIndex: columnIndex, + // Named rather than left to the canvas selection, which answers a different question: + // what the user has selected. This frame also mounts on the Form View, where nothing is + // selected until the user clicks a step, so the export found an empty scope and the + // button did nothing. + operatorIds: [this.operatorId], }, nzFooter: null, }); diff --git a/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts b/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts index b546e6a1a2..a1b13898d6 100644 --- a/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts +++ b/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts @@ -25,7 +25,7 @@ import { WorkflowActionService } from "../workflow-graph/model/workflow-action.s import { NotificationService } from "../../../common/service/notification/notification.service"; import { ExecuteWorkflowService } from "../execute-workflow/execute-workflow.service"; import { WorkflowResultService } from "../workflow-result/workflow-result.service"; -import { Observable, of, throwError } from "rxjs"; +import { Observable, of, Subject, throwError } from "rxjs"; import { ExecutionState } from "../../types/execute-workflow.interface"; import { DownloadService, ExportWorkflowJsonResponse } from "src/app/dashboard/service/user/download/download.service"; import { DatasetService } from "../../../dashboard/service/user/dataset/dataset.service"; @@ -255,7 +255,7 @@ describe("WorkflowResultExportService", () => { const download = stubDownloadService(); texeraGraphSpy.getAllOperators.mockReturnValue([{ operatorID: "op1" }] as any); - service.exportWorkflowExecutionResult("csv", "wf", [1], 0, 0, "file", true, "dataset", makeUnit()); + service.exportWorkflowExecutionResult("csv", "wf", [1], 0, 0, "file", "dataset", makeUnit(), ["op1"]); expect(notificationServiceSpy.loading).not.toHaveBeenCalled(); expect(notificationServiceSpy.error).not.toHaveBeenCalled(); @@ -267,7 +267,7 @@ describe("WorkflowResultExportService", () => { const download = stubDownloadService(); texeraGraphSpy.getAllOperators.mockReturnValue([{ operatorID: "op1" }] as any); - service.exportWorkflowExecutionResult("csv", "wf", [1], 0, 0, "file", true, "dataset", null); + service.exportWorkflowExecutionResult("csv", "wf", [1], 0, 0, "file", "dataset", null, ["op1"]); expect(notificationServiceSpy.error).toHaveBeenCalledWith( "Cannot export result: computing unit is not available" @@ -282,30 +282,103 @@ describe("WorkflowResultExportService", () => { const download = stubDownloadService(); texeraGraphSpy.getAllOperators.mockReturnValue([{ operatorID: "op1" }] as any); - service.exportWorkflowExecutionResult("csv", "wf", [1], 0, 0, "file", true, "dataset", makeUnit()); + service.exportWorkflowExecutionResult("csv", "wf", [1], 0, 0, "file", "dataset", makeUnit(), ["op1"]); expect(notificationServiceSpy.error).toHaveBeenCalledWith("Cannot export result: workflow ID is not available"); expect(download.exportWorkflowResultToDataset).not.toHaveBeenCalled(); }); - it("does nothing when no operators are selected (highlighted export with empty selection)", () => { + it("does nothing when the caller's scope is empty", () => { enableExport(); const download = stubDownloadService(); - jointGraphWrapperSpy.getCurrentHighlightedOperatorIDs.mockReturnValue([]); - service.exportWorkflowExecutionResult("csv", "wf", [1], 0, 0, "file", false, "dataset", makeUnit()); + service.exportWorkflowExecutionResult("csv", "wf", [1], 0, 0, "file", "dataset", makeUnit(), []); expect(notificationServiceSpy.loading).not.toHaveBeenCalled(); expect(notificationServiceSpy.error).not.toHaveBeenCalled(); expect(download.exportWorkflowResultToDataset).not.toHaveBeenCalled(); }); + // The scope is the caller's to decide and the service does not second-guess it: the dialog + // resolves what the export covers in order to report on it, and the same list is what gets + // exported. Which list that is, for each way of opening the dialog, is the dialog's own spec. + it("exports exactly the operators it was given, whatever the canvas has selected", () => { + enableExport(); + const download = stubDownloadService(); + jointGraphWrapperSpy.getCurrentHighlightedOperatorIDs.mockReturnValue(["opHighlighted"]); + texeraGraphSpy.getAllOperators.mockReturnValue([{ operatorID: "opA" }, { operatorID: "opB" }] as any); + + service.exportWorkflowExecutionResult("csv", "wf", [7], 1, 2, "file", "dataset", makeUnit(), ["opNamed"]); + + expect(download.exportWorkflowResultToDataset).toHaveBeenCalledWith( + "csv", + "workflow1", + "wf", + [{ id: "opNamed", outputType: "csv" }], + [7], + 1, + 2, + "file", + expect.anything() + ); + }); + + // The restriction analysis is asynchronous, and a caller may hand over the canvas selection, + // which is the live array rather than a copy of it. Without a snapshot the export would go + // out with whatever is selected when the analysis answers, not what was asked for. + it("exports the scope it was given, not what that array became while the analysis ran", () => { + enableExport(); + const download = stubDownloadService(); + const pending = new Subject<Record<string, string[]>>(); + downloadServiceSpy.getWorkflowResultDownloadability.mockReturnValue(pending as any); + const liveSelection = ["opA"]; + + service.exportWorkflowExecutionResult("csv", "wf", [7], 1, 2, "file", "dataset", makeUnit(), liveSelection); + liveSelection.push("opB"); + pending.next({}); + pending.complete(); + + expect(download.exportWorkflowResultToDataset).toHaveBeenCalledWith( + "csv", + "workflow1", + "wf", + [{ id: "opA", outputType: "csv" }], + [7], + 1, + 2, + "file", + expect.anything() + ); + }); + + it("exports every operator it was given, in the order it was given them", () => { + enableExport(); + const download = stubDownloadService(); + + service.exportWorkflowExecutionResult("csv", "wf", [7], 1, 2, "file", "dataset", makeUnit(), ["opA", "opB"]); + + expect(download.exportWorkflowResultToDataset).toHaveBeenCalledWith( + "csv", + "workflow1", + "wf", + [ + { id: "opA", outputType: "csv" }, + { id: "opB", outputType: "csv" }, + ], + [7], + 1, + 2, + "file", + expect.anything() + ); + }); + it("errors (no export) when every selected operator is blocked by a non-downloadable dataset", () => { enableExport(); const download = stubDownloadService({ downloadability: { op1: ["ds1 ([email protected])"] } }); texeraGraphSpy.getAllOperators.mockReturnValue([{ operatorID: "op1" }] as any); - service.exportWorkflowExecutionResult("csv", "wf", [1], 0, 0, "file", true, "dataset", makeUnit()); + service.exportWorkflowExecutionResult("csv", "wf", [1], 0, 0, "file", "dataset", makeUnit(), ["op1"]); expect(notificationServiceSpy.error).toHaveBeenCalledWith( "Cannot export result: selection depends on dataset(s) that are not downloadable: ds1 ([email protected])" @@ -320,7 +393,8 @@ describe("WorkflowResultExportService", () => { const download = stubDownloadService({ downloadability: { op2: ["ds2 ([email protected])"] } }); texeraGraphSpy.getAllOperators.mockReturnValue([{ operatorID: "op1" }, { operatorID: "op2" }] as any); - service.exportWorkflowExecutionResult("csv", "wf", [7], 1, 2, "file", true, "dataset", makeUnit()); + // Both are in scope; only op2 is blocked, which is what makes this the partial case. + service.exportWorkflowExecutionResult("csv", "wf", [7], 1, 2, "file", "dataset", makeUnit(), ["op1", "op2"]); expect(notificationServiceSpy.warning).toHaveBeenCalledWith( "Some operators were skipped because their results depend on dataset(s) that are not downloadable" + @@ -348,7 +422,7 @@ describe("WorkflowResultExportService", () => { const download = stubDownloadService({ downloadability: { op1: [] } }); texeraGraphSpy.getAllOperators.mockReturnValue([{ operatorID: "op1" }] as any); - service.exportWorkflowExecutionResult("csv", "wf", [1], 0, 0, "file", true, "dataset", makeUnit()); + service.exportWorkflowExecutionResult("csv", "wf", [1], 0, 0, "file", "dataset", makeUnit(), ["op1"]); expect(notificationServiceSpy.error).toHaveBeenCalledWith( "Cannot export result: selection depends on dataset(s) that are not downloadable" @@ -363,7 +437,8 @@ describe("WorkflowResultExportService", () => { const download = stubDownloadService({ downloadability: { op2: [] } }); texeraGraphSpy.getAllOperators.mockReturnValue([{ operatorID: "op1" }, { operatorID: "op2" }] as any); - service.exportWorkflowExecutionResult("csv", "wf", [7], 1, 2, "file", true, "dataset", makeUnit()); + // Both are in scope; only op2 is blocked, which is what makes this the partial case. + service.exportWorkflowExecutionResult("csv", "wf", [7], 1, 2, "file", "dataset", makeUnit(), ["op1", "op2"]); expect(notificationServiceSpy.warning).toHaveBeenCalledWith( "Some operators were skipped because their results depend on dataset(s) that are not downloadable" @@ -389,7 +464,7 @@ describe("WorkflowResultExportService", () => { const download = stubDownloadService(); texeraGraphSpy.getAllOperators.mockReturnValue([{ operatorID: "op1" }] as any); - service.exportWorkflowExecutionResult("csv", "wf", [5], 0, 0, "file", true, "dataset", makeUnit()); + service.exportWorkflowExecutionResult("csv", "wf", [5], 0, 0, "file", "dataset", makeUnit(), ["op1"]); expect(notificationServiceSpy.warning).not.toHaveBeenCalled(); expect(notificationServiceSpy.loading).toHaveBeenCalledWith("Exporting..."); @@ -404,7 +479,7 @@ describe("WorkflowResultExportService", () => { }); texeraGraphSpy.getAllOperators.mockReturnValue([{ operatorID: "op1" }] as any); - service.exportWorkflowExecutionResult("csv", "wf", [5], 0, 0, "file", true, "dataset", makeUnit()); + service.exportWorkflowExecutionResult("csv", "wf", [5], 0, 0, "file", "dataset", makeUnit(), ["op1"]); expect(notificationServiceSpy.error).toHaveBeenCalledWith("quota exceeded"); expect(notificationServiceSpy.success).not.toHaveBeenCalled(); @@ -415,7 +490,7 @@ describe("WorkflowResultExportService", () => { stubDownloadService({ datasetResponse: new HttpResponse({}) }); texeraGraphSpy.getAllOperators.mockReturnValue([{ operatorID: "op1" }] as any); - service.exportWorkflowExecutionResult("csv", "wf", [5], 0, 0, "file", true, "dataset", makeUnit()); + service.exportWorkflowExecutionResult("csv", "wf", [5], 0, 0, "file", "dataset", makeUnit(), ["op1"]); expect(notificationServiceSpy.error).toHaveBeenCalledWith("An error occurred during export"); }); @@ -425,7 +500,7 @@ describe("WorkflowResultExportService", () => { stubDownloadService({ datasetError: { error: { message: "server exploded" } } }); texeraGraphSpy.getAllOperators.mockReturnValue([{ operatorID: "op1" }] as any); - service.exportWorkflowExecutionResult("csv", "wf", [5], 0, 0, "file", true, "dataset", makeUnit()); + service.exportWorkflowExecutionResult("csv", "wf", [5], 0, 0, "file", "dataset", makeUnit(), ["op1"]); expect(notificationServiceSpy.error).toHaveBeenCalledWith( "An error happened in exporting operator results: server exploded" @@ -438,7 +513,7 @@ describe("WorkflowResultExportService", () => { stubDownloadService({ datasetError: { error: "dataset quota exceeded" } }); texeraGraphSpy.getAllOperators.mockReturnValue([{ operatorID: "op1" }] as any); - service.exportWorkflowExecutionResult("csv", "wf", [5], 0, 0, "file", true, "dataset", makeUnit()); + service.exportWorkflowExecutionResult("csv", "wf", [5], 0, 0, "file", "dataset", makeUnit(), ["op1"]); expect(notificationServiceSpy.error).toHaveBeenCalledWith( "An error happened in exporting operator results: dataset quota exceeded" @@ -451,7 +526,7 @@ describe("WorkflowResultExportService", () => { stubDownloadService({ datasetError: "connection reset by peer" }); texeraGraphSpy.getAllOperators.mockReturnValue([{ operatorID: "op1" }] as any); - service.exportWorkflowExecutionResult("csv", "wf", [5], 0, 0, "file", true, "dataset", makeUnit()); + service.exportWorkflowExecutionResult("csv", "wf", [5], 0, 0, "file", "dataset", makeUnit(), ["op1"]); expect(notificationServiceSpy.error).toHaveBeenCalledWith( "An error happened in exporting operator results: connection reset by peer" @@ -463,7 +538,7 @@ describe("WorkflowResultExportService", () => { const download = stubDownloadService(); texeraGraphSpy.getAllOperators.mockReturnValue([{ operatorID: "op1" }] as any); - service.exportWorkflowExecutionResult("json", "wf", [], 3, 4, "local-file", true, "local", makeUnit()); + service.exportWorkflowExecutionResult("json", "wf", [], 3, 4, "local-file", "local", makeUnit(), ["op1"]); expect(notificationServiceSpy.loading).toHaveBeenCalledWith("Exporting..."); expect(download.exportWorkflowResultToLocal).toHaveBeenCalledWith( diff --git a/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.ts b/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.ts index dad95c5d15..52a04387a6 100644 --- a/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.ts +++ b/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.ts @@ -197,12 +197,18 @@ export class WorkflowResultExportService { rowIndex: number, columnIndex: number, filename: string, - exportAll: boolean = false, // if the user click export button on the top bar (a.k.a menu), - // we should export all operators, otherwise, only highlighted ones - // which means export button is selected from context-menu - destination: "dataset" | "local" = "dataset", // default to dataset - unit: DashboardWorkflowComputingUnit | null // computing unit for cluster setting + destination: "dataset" | "local", + unit: DashboardWorkflowComputingUnit | null, // computing unit for cluster setting + // The operators this export covers. The caller resolves them: the dialog already works out + // its own scope in order to report what a blocking dataset blocks, so it says so here rather + // than leaving the scope to be worked out a second time, separately, from a flag and the + // canvas -- two answers to one question that agree only for as long as nobody edits one. + operatorIds: readonly string[] ): void { + // Copied now, not read later: the restriction analysis below is asynchronous, and the canvas + // selection a caller may have handed us is the live array, so the scope would otherwise be + // whatever is selected when the analysis answers rather than what was asked for. + const scope = [...operatorIds]; this.computeRestrictionAnalysis() .pipe(take(1)) .subscribe(restrictionResult => @@ -213,10 +219,10 @@ export class WorkflowResultExportService { rowIndex, columnIndex, filename, - exportAll, destination, unit, - restrictionResult + restrictionResult, + scope ) ); } @@ -226,10 +232,9 @@ export class WorkflowResultExportService { * * This method handles the core export logic: * 1. Validates configuration and computing unit availability - * 2. Determines operator scope (all vs highlighted) - * 3. Applies restriction filtering with user feedback - * 4. Makes the export API call - * 5. Handles response and shows appropriate notifications + * 2. Applies restriction filtering with user feedback + * 3. Makes the export API call + * 4. Handles response and shows appropriate notifications * * Shows error messages if all operators are blocked, warning messages if some are blocked. * @@ -242,10 +247,10 @@ export class WorkflowResultExportService { rowIndex: number, columnIndex: number, filename: string, - exportAll: boolean, destination: "dataset" | "local", unit: DashboardWorkflowComputingUnit | null, - downloadability: WorkflowResultDownloadability + downloadability: WorkflowResultDownloadability, + operatorIds: readonly string[] ): void { // Validates configuration and computing unit availability if (!this.config.env.exportExecutionResultEnabled) { @@ -262,14 +267,6 @@ export class WorkflowResultExportService { return; } - // Determines operator scope - const operatorIds = exportAll - ? this.workflowActionService - .getTexeraGraph() - .getAllOperators() - .map(operator => operator.operatorID) - : [...this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs()]; - if (operatorIds.length === 0) { return; }
