From 48d89d3282e58b1caf027b455a179fe989ec1b8c Mon Sep 17 00:00:00 2001 From: Yang Zhang Date: Sun, 6 Sep 2026 12:36:50 -0700 Subject: [PATCH] feat(gui): show the chosen results on the Form View Render each chosen, currently-viewed step's output under the workflow that produced it -- a table, a visualization, or a compact "no result yet" -- with a per-result zoom and visualizations fitted to their cards after a run. The chosen set is a pure display filter that follows the canvas's view-result set (getOperatorsToViewResult) and never writes it, per the settled result-panel design. Add WorkflowResultService.hasNonEmptyResult so a view-result step that produced zero tuples (e.g. a download/publish UDF) reads as "no result" rather than an empty card. Opening a step to inspect it, and the authoring mode that picks what to show, follow in later PRs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FVvP3ttj22f9LB4p9u2anY --- .../workflow-form.component.html | 70 +++++ .../workflow-form.component.scss | 127 +++++++++ .../workflow-form.component.spec.ts | 228 ++++++++++++++++ .../workflow-form/workflow-form.component.ts | 258 +++++++++++++++++- .../workflow-form.rendered.spec.ts | 53 +++- .../workflow-form.spec-harness.ts | 52 +++- .../workflow-result.service.spec.ts | 22 ++ .../workflow-result.service.ts | 14 + 8 files changed, 818 insertions(+), 6 deletions(-) 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 index d0c0ce0f0ee..0b268628392 100644 --- a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html @@ -223,5 +223,75 @@

{{ instructionTitle || "How to use this" }}

class="box"> + + +
+
Results
+

+ {{ isRunning ? "Working…" : hasRunFinished ? "This run produced no results to show." : "Press Run and the + results appear here." }} +

+ + +
+
+ {{ resultLabel(id) }} + + + + + +
+ +
+ + + + + +

+ {{ isRunning ? "Computing…" : "No result yet." }} +

+
+
+
+
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 index 93ef44ad831..77185232a69 100644 --- a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss @@ -568,3 +568,130 @@ $shell: #fafafa; } } } + +/* ---------- results ---------- */ + +.results { + margin-top: 28px; +} + +/* Quiet on purpose: it marks where the answer will land, and should not compete with the inputs + the reader is still filling in. */ +.results-empty { + margin: 0; + padding: 22px 0 6px; + color: $text-2; + font-size: 13.5px; +} + +.result { + margin-bottom: 14px; + overflow: hidden; + + .result-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 9px 13px; + border-bottom: 1px solid $divider; + font-size: 13px; + font-weight: 600; + background: $shell; + } + + .result-zoom { + display: inline-flex; + gap: 2px; + + button { + border: 1px solid $border; + background: #fff; + border-radius: 5px; + width: 24px; + height: 24px; + display: grid; + place-items: center; + color: $text-2; + cursor: pointer; + font-size: 11px; + + &:hover:not(:disabled) { + color: $blue; + border-color: $blue; + } + + &:disabled { + opacity: 0.35; + cursor: default; + } + } + } + + /* Centred in the card so an empty / computing step reads at the same height as a full one -- + every card is one fixed height, whatever its state. */ + .result-pending { + margin: 0; + height: 100%; + display: grid; + place-items: center; + text-align: center; + color: $text-2; + font-size: 13.5px; + } + + .result-body { + padding: 4px; + /* Every result card is the SAME height -- table, chart, empty, or "computing" -- so the list + never jumps between a tall box and a thin strip. Content scrolls inside; an empty state + centres. Tall enough for a screenful of rows, nowhere near a full-window box. */ + height: 380px; + overflow: auto; + + /* The reused result table's empty state ends with "Tip: enable the eye icon on the operator + ...". There is no such icon on this page and the step is already chosen, so that tip only + misleads here -- drop it, keep the neutral "Empty result set". */ + ::ng-deep texera-result-table-frame h4 + p { + display: none; + } + + /* Centre the table's own "Empty result set" in the card, where the other empty states sit. + Only the empty block carries this inline style, so a populated table is untouched. */ + ::ng-deep texera-result-table-frame > div[style*="text-align"] { + height: 340px; + display: grid; + place-items: center; + } + + /* An iframe has no intrinsic height; fill the card so a chart is the same height as a table. + The zoom control enlarges the picture inside without changing the card. */ + texera-visualization-panel-content { + display: block; + width: 100%; + height: 100%; + } + + /* Zoom scales the picture itself (the iframe's rendered content), not the card frame. The card + keeps its height and scrolls to reach the rest of an enlarged image. */ + &[data-zoom="0"] ::ng-deep iframe { + transform: scale(0.7); + transform-origin: top left; + } + + &[data-zoom="2"] ::ng-deep iframe { + transform: scale(1.6); + transform-origin: top left; + } + + /* A plot with dozens of categories needs the width most: squeezed narrow its axis labels + collapse into an unreadable smear. Let the iframe and whatever it renders take the card. */ + ::ng-deep iframe, + ::ng-deep .visualization-frame, + ::ng-deep .plotly-container, + ::ng-deep .js-plotly-plot { + width: 100% !important; + max-width: none !important; + height: 100% !important; + } + } +} 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 index d3ba936e211..9b711baca01 100644 --- 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 @@ -68,6 +68,7 @@ describe("WorkflowFormComponent", () => { h.workflowWebsocketService as any, h.host as any, h.datePipe as any, + h.panelResizeService as any, h.validationWorkflowService as any, h.config as any ); @@ -1055,6 +1056,26 @@ describe("WorkflowFormComponent", () => { expect(component.runError).toBe(""); }); + it("clears a stale failure banner when a new run starts, even a co-editor's", () => { + build(formViewWorkflow).ngOnInit(); + h.executionStateStream.next({ current: { state: ExecutionState.Failed, errorMessages: [{ message: "boom" }] } }); + expect(component.runError).not.toBe(""); + + // A co-editor starts the next run: the shared stream goes in-flight without this page's onRun(). + h.executionStateStream.next({ current: { state: ExecutionState.Running } }); + + expect(component.runError).toBe(""); + }); + + it("tells apart a never-run form from a completed run that produced nothing", () => { + build(formViewWorkflow).ngOnInit(); + expect(component.hasRunFinished).toBe(false); + + h.executionStateStream.next({ current: { state: ExecutionState.Completed } }); + + expect(component.hasRunFinished).toBe(true); + }); + it("stops a running workflow instead of starting another", () => { build(formViewWorkflow).ngOnInit(); h.executionStateStream.next({ current: { state: ExecutionState.Running } }); @@ -1095,6 +1116,213 @@ describe("WorkflowFormComponent", () => { }); }); + describe("showing the chosen results", () => { + // Chosen operators are only shown if they still have view-result on in the canvas; the form + // never writes that set (display filter, per the settled design). + const chosen = (resultOperatorIds: string[]) => + formBindingService.getConfig.mockReturnValue({ instruction: undefined, fields: [], resultOperatorIds }); + + it("shows a chosen result only while its operator still has view-result on the canvas", () => { + build(formViewWorkflow).ngOnInit(); + chosen(["a", "b"]); + h.viewResultIds.add("a"); // b's eye is off on the canvas + + (component as any).readConfig(); + + expect(component.shownResultIds).toEqual(["a"]); + }); + + it("shows a chosen terminal operator with no eye", () => { + // The engine materializes a terminal operator unconditionally, so its result is always available. + // The form must show it, or an author who picks the workflow's final operator -- the most natural + // choice -- would get a card that never appears. + build(formViewWorkflow).ngOnInit(); + chosen(["last"]); + h.graphOperators.push({ operatorID: "last", operatorType: "Limit" }); + h.terminalIds.add("last"); // no downstream link, and its eye is off + + (component as any).readConfig(); + + expect(component.shownResultIds).toEqual(["last"]); + }); + + it("shows the terminal result with nothing chosen, matching the canvas default", () => { + // A reader who never curates still sees the workflow's final result, because the canvas always + // materializes and surfaces the terminal operator. The form defaults to that same terminal. + build(formViewWorkflow).ngOnInit(); + chosen([]); + h.graphOperators.push({ operatorID: "last", operatorType: "Limit" }); + h.hasOperatorIds.add("last"); + h.terminalIds.add("last"); // terminal, no eye, not chosen + + (component as any).readConfig(); + + expect(component.shownResultIds).toEqual(["last"]); + }); + + it("does not show a chosen non-terminal operator whose eye is off", () => { + // A mid-graph step with an enabled downstream link is materialized only when its eye is on; + // without the eye it produces no result, so the form must not show a card that sits forever empty. + build(formViewWorkflow).ngOnInit(); + chosen(["mid"]); + h.graphOperators.push({ operatorID: "mid", operatorType: "Filter" }); // enabled downstream, no eye + + (component as any).readConfig(); + + expect(component.shownResultIds).toEqual([]); + }); + + it("treats an operator whose only downstream link is disabled as terminal", () => { + // The backend's storage rule is out-degree 0 on the ENABLED plan, so an operator whose downstream + // link is disabled is terminal and gets materialized. The form must match, reading enabled links. + build(formViewWorkflow).ngOnInit(); + chosen([]); + h.graphOperators.push({ operatorID: "a", operatorType: "Filter" }); + h.graphOperators.push({ operatorID: "b", operatorType: "Limit" }); + h.disabledDownstream.add("a"); // a -> b link disabled, so a has no enabled downstream + h.terminalIds.add("b"); // b is the true end + + (component as any).readConfig(); + + expect(component.shownResultIds).toContain("a"); + expect(component.shownResultIds).toContain("b"); + }); + + it("does not treat a disabled operator as terminal", () => { + // A disabled operator is not in the compiled plan, so the engine never materializes it; even with + // no downstream it must not be shown as a terminal result. + build(formViewWorkflow).ngOnInit(); + chosen([]); + h.graphOperators.push({ operatorID: "off", operatorType: "Limit", isDisabled: true }); + h.terminalIds.add("off"); // no downstream, but disabled + + (component as any).readConfig(); + + expect(component.shownResultIds).toEqual([]); + }); + + it("drops a card when the canvas view-result set changes, without a result update", () => { + build(formViewWorkflow).ngOnInit(); + chosen(["a", "b"]); + h.viewResultIds.add("a"); + h.viewResultIds.add("b"); + (component as any).readConfig(); + expect(component.shownResultIds).toEqual(["a", "b"]); + + // A co-editor turns b's eye off on the canvas. This emits no result-update event, so the + // filter must react to the view-result set changing directly, or b's card would go stale. + h.viewResultIds.delete("b"); + h.viewResultChanged.next({}); + + expect(component.shownResultIds).toEqual(["a"]); + }); + + it("cards only the chosen, viewed steps that actually produced a result", () => { + build(formViewWorkflow).ngOnInit(); + chosen(["produces", "produces-nothing"]); + h.viewResultIds.add("produces"); + h.viewResultIds.add("produces-nothing"); + h.anyResultIds.add("produces"); // the other ran but yielded nothing (e.g. a download UDF) + + (component as any).readConfig(); + + expect(component.resultIdsToShow).toEqual(["produces"]); + expect(component.hasResults).toBe(true); + }); + + it("has no results when nothing chosen has produced anything", () => { + build(formViewWorkflow).ngOnInit(); + chosen(["a"]); + h.viewResultIds.add("a"); + (component as any).readConfig(); + + expect(component.hasResults).toBe(false); + }); + + it("calls a paginated result a table, and gates visualisation content on a snapshot", () => { + build(formViewWorkflow).ngOnInit(); + (component as any).workflowResultService.hasPaginatedResult = (id: string) => id === "tab"; + + expect(component.isTabularResult("tab")).toBe(true); + expect(component.vizHasContent("tab")).toBe(false); // tables take the tabular branch + // A non-tabular op with a non-empty snapshot has viz content; an empty one does not. + h.snapshotById.set("viz", [{ a: 1 }]); + expect(component.vizHasContent("viz")).toBe(true); + expect(component.vizHasContent("blank")).toBe(false); + }); + + it("labels a result by the operator's friendly name, falling back to the id", () => { + build(formViewWorkflow).ngOnInit(); + h.graphOperators.push({ operatorID: "op-1", operatorType: "CSVFileScan" }); + + expect(component.resultLabel("op-1")).toBe("CSVFileScan"); + expect(component.resultLabel("gone")).toBe("gone"); + }); + + it("keeps a result's frame identity stable until its version moves", () => { + build(formViewWorkflow).ngOnInit(); + const before = component.resultKey("op-1"); + expect(component.resultKey("op-1")).toBe(before); + + (component as any).resultVersion.set("op-1", 1); + + expect(component.resultKey("op-1")).not.toBe(before); + expect(component.trackByKey(0, "k")).toBe("k"); + }); + + it("resizes a result within bounds, per result, and re-fits after", () => { + vi.useFakeTimers(); + build(formViewWorkflow).ngOnInit(); + const fit = vi.spyOn(component as any, "fitVisualisations").mockImplementation(() => {}); + expect(component.resultZoom("op-1")).toBe(1); + + component.zoomResult("op-1", 1); + component.zoomResult("op-1", 1); + expect(component.resultZoom("op-1")).toBe(2); // clamped at 2 + + component.zoomResult("op-1", -1); + component.zoomResult("op-1", -1); + component.zoomResult("op-1", -1); + expect(component.resultZoom("op-1")).toBe(0); // clamped at 0 + expect(component.resultZoom("op-2")).toBe(1); // untouched + + // The deferred re-fit runs after the card height lands. + vi.advanceTimersByTime(60); + expect(fit).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("bumps the result version and re-fits on a result update", () => { + vi.useFakeTimers(); + build(formViewWorkflow).ngOnInit(); + const fit = vi.spyOn(component as any, "fitVisualisations").mockImplementation(() => {}); + + h.resultUpdateStream.next({ "op-1": {} }); + expect(component.resultKey("op-1")).toBe("op-1#1"); + vi.advanceTimersByTime(300); + expect(fit).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("re-fits the charts once a finished run has results", () => { + vi.useFakeTimers(); + build(formViewWorkflow).ngOnInit(); + vi.spyOn(component, "hasResults", "get").mockReturnValue(true); + const fit = vi.spyOn(component as any, "fitVisualisations").mockImplementation(() => {}); + + h.executionStateStream.next({ current: { state: ExecutionState.Completed } }); + vi.advanceTimersByTime(400); + + expect(fit).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("gives the result tables a realistic page height on init", () => { + build(formViewWorkflow).ngOnInit(); + expect(h.panelResizeService.changePanelSize).toHaveBeenCalled(); + }); + }); + describe("reporting a failed run", () => { it("blames empty required inputs when a required field is left empty", () => { build(formViewWorkflow).ngOnInit(); 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 index f1070a7ccf2..16df57570b4 100644 --- a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts @@ -26,6 +26,8 @@ import { ActivatedRoute, Router } from "@angular/router"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { NzAvatarModule } from "ng-zorro-antd/avatar"; import { NzIconModule } from "ng-zorro-antd/icon"; +import { NzButtonModule } from "ng-zorro-antd/button"; +import { NzTooltipModule } from "ng-zorro-antd/tooltip"; import { UserIconComponent } from "../../../dashboard/component/user/user-icon/user-icon.component"; import { cloneDeep } from "lodash-es"; import { MarkdownService } from "ngx-markdown"; @@ -51,10 +53,13 @@ import { ValidationWorkflowService } from "../../service/validation/validation-w 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 { PanelResizeService } from "../../service/workflow-result/panel-resize/panel-resize.service"; import { WorkflowWebsocketService } from "../../service/workflow-websocket/workflow-websocket.service"; import { ExecutionState } from "../../types/execute-workflow.interface"; import { Point } from "../../types/workflow-common.interface"; import { ComputingUnitSelectionComponent } from "../power-button/computing-unit-selection.component"; +import { ResultTableFrameComponent } from "../result-panel/result-table-frame/result-table-frame.component"; +import { VisualizationFrameContentComponent } from "../visualization-panel-content/visualization-frame-content.component"; import { WorkflowEditorComponent } from "../workflow-editor/workflow-editor.component"; import { MiniMapComponent } from "../workflow-editor/mini-map/mini-map.component"; import { CoeditorUserIconComponent } from "../menu/coeditor-user-icon/coeditor-user-icon.component"; @@ -81,8 +86,11 @@ interface RenderedField { * makes, with each sub-field of a nested or repeated property renamed and hidden as the author set * it up. It also shows the author's instruction above the inputs, and runs the workflow: a Run * button (a reader's simplified Run/Stop, sharing the canvas's disable conditions), the - * computing-unit selector, a run clock and plain-language failure messages. Showing the results is - * added by a later PR. A view, not a new object: it opens the same workflow the canvas does. + * computing-unit selector, a run clock and plain-language failure messages. It then shows the + * chosen results underneath -- a table, a visualisation, or a compact "no result yet" -- as a + * display filter that follows the canvas's view-result set and never writes it. Opening a step to + * inspect it read-only, and the authoring mode that picks what to show, are later PRs. A view, not + * a new object: it opens the same workflow the canvas does. */ @UntilDestroy() @Component({ @@ -96,8 +104,12 @@ interface RenderedField { FormlyModule, NzAvatarModule, NzIconModule, + NzButtonModule, + NzTooltipModule, UserIconComponent, ComputingUnitSelectionComponent, + ResultTableFrameComponent, + VisualizationFrameContentComponent, WorkflowEditorComponent, MiniMapComponent, CoeditorUserIconComponent, @@ -143,6 +155,20 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { public isWorkflowValid = true; public isWorkflowEmpty = false; + /** + * Which steps' results to show, as a display filter over the canvas's view-result set: the + * author's chosen `resultOperatorIds`, kept to only those an operator still has view-result + * ("the eye") on. The form NEVER writes the canvas's view-result flags -- it only limits what it + * itself shows, so a canvas user's result-viewing is unaffected. + */ + public shownResultIds: string[] = []; + /** Chart height per result (0 compact / 1 default / 2 tall). Per operator so one does not resize + * the others, and in memory only -- a viewing preference, not part of the workflow. */ + private zoomByResult = new Map(); + /** Bumped when a result changes, used as the chart's *ngFor identity so the frame is rebuilt, not + * reused: the chart reads its content once at creation, so a stale frame showed "undefined". */ + private resultVersion = new Map(); + /** The collapsible workflow preview: closed until the reader opens it. */ public workflowOpen = false; /** The embedded canvas is built the first time the strip opens, never while collapsed. */ @@ -189,6 +215,10 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { private workflowWebsocketService: WorkflowWebsocketService, private host: ElementRef, private datePipe: DatePipe, + // The result table sizes its rows-per-page from this shared panel height. On the operator + // canvas the docked panel drives it; this page has no such panel, so left at the tiny default + // every table showed a single row per page. Given a realistic height in ngOnInit instead. + private panelResizeService: PanelResizeService, // Same source the operator canvas reads its "Invalid" / "Empty" states from, so Run is // disabled here exactly when it is disabled there. private validationWorkflowService: ValidationWorkflowService, @@ -202,8 +232,39 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { return; } this.wid = wid; + // Give the result tables a realistic height to page against, so they show a screenful of rows + // instead of one. (~7 rows; the card scrolls for the rest.) + this.panelResizeService.changePanelSize(900, 560); this.load(wid); + // A result changing bumps that operator's version (so its chart frame is rebuilt, not reused), + // re-limits what the form shows to the currently-viewed set, and re-fits the visualisations. + this.workflowResultService + .getResultUpdateStream() + .pipe(untilDestroyed(this)) + .subscribe(update => { + for (const operatorID of Object.keys(update ?? {})) { + this.resultVersion.set(operatorID, (this.resultVersion.get(operatorID) ?? 0) + 1); + } + this.refreshShownResults(); + // markForCheck, not detectChanges: this fires often during a run, and a synchronous pass + // can be thrown out of by an unrelated component's NG0100, killing the subscription. + this.cdr.markForCheck(); + this.later(() => this.fitVisualisations(), 300); + }); + + // Turning a step's view-result OFF on the canvas emits no result-update event, so the filter + // above would miss it and leave a stale card. React to the view-result set changing directly + // (a co-editor's toggle included), so a de-viewed step drops out here at once. + this.workflowActionService + .getTexeraGraph() + .getViewResultOperatorsChangedStream() + .pipe(untilDestroyed(this)) + .subscribe(() => { + this.refreshShownResults(); + this.cdr.markForCheck(); + }); + // The run clock, reusing the operator canvas's source outright rather than timing anything // here: the engine is the only thing that knows when the run really began, so a stopwatch // started at the click would drift and would be wrong after a reload. @@ -255,10 +316,17 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { .getExecutionStateStream() .pipe(untilDestroyed(this)) .subscribe(({ current }) => { + const wasRunning = this.isRunning; this.executionState = current.state; + // Clear a stale failure banner the moment any new run starts -- this session's or a + // co-editor's. onRun() clears it for a run started here, but a co-editor's run moves the + // shared execution stream to an in-flight state without going through onRun(), so without + // this the previous failure would linger over their running run. + if (!wasRunning && this.isRunning) { + this.runError = ""; + } // Surface a failed run. Without this the spinner just stops and the form gives zero - // feedback -- the opposite of what a reader needs. onRun() clears runError before the next - // run, so a stale error never lingers. + // feedback -- the opposite of what a reader needs. if (current.state === ExecutionState.Failed) { // A required input left empty is by far the commonest reason a run fails here, and the // engine reports it as an opaque "... is not contained in the schema". Answer with the @@ -268,6 +336,12 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { ? "Run failed: please fill in the required fields." : this.friendlyRunError(current.errorMessages?.[0]?.message?.trim() ?? ""); } + // Fit the charts to their cards once a run has results. Deliberately not on run START: the + // run repaints operators and a re-fit then zoomed the whole preview down. Deliberately does + // not open the workflow either -- someone using the form came for the inputs and results. + if (this.hasResults) { + this.later(() => this.fitVisualisations(), 400); + } // markForCheck, not detectChanges: this is the one subscription the page cannot afford to // lose. A synchronous detectChanges can be thrown out of by an unrelated component's NG0100, // which would kill this stream and freeze the Run button on a stale state with no error @@ -376,11 +450,48 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { this.parameters = this.formBindingService.resolveFields(); this.instructionTitle = config.instruction?.title ?? ""; this.instructionBody = config.instruction?.body ?? ""; + this.refreshShownResults(); // A reader always sees the instruction as rendered markdown. void this.renderInstruction(); this.buildForm(); } + /** + * Decide which operators' result cards to show. The engine materializes a result for every terminal + * operator (no enabled downstream) as well as every view-result operator, so a terminal's result is + * always available; the form shows terminals by default and layers the author's chosen view-result + * steps on top. This is a pure display filter that reads the graph and never writes it, so a normal + * canvas user's result-viewing is unaffected. A step that is neither viewed nor terminal (or was + * deleted) drops out rather than rendering a stale card. + */ + private refreshShownResults(): void { + const graph = this.workflowActionService.getTexeraGraph(); + const viewed = graph.getOperatorsToViewResult(); + // A result exists for an operator that is view-result (the eye) or terminal (no enabled + // downstream); the engine materializes both (WorkflowCompiler stores terminal operators plus + // opsToViewResult). A view-result id is always a live operator; a terminal id comes from + // terminalOperatorIds (live, enabled operators only), so both branches reference real steps. + const terminals = new Set(this.terminalOperatorIds()); + const availableOnCanvas = (id: string): boolean => viewed.has(id) || terminals.has(id); + const chosen = this.formBindingService.getConfig().resultOperatorIds; + // The terminal (final) operator's result always shows -- the engine always materializes it, so it + // cannot be turned off. resultOperatorIds adds extra intermediate (view-result) steps on top. The + // downstream hasNonEmptyResult filter drops steps that produced no data. + this.shownResultIds = [...new Set([...terminals, ...chosen])].filter(availableOnCanvas); + } + + /** The workflow's terminal operators: enabled operators with no enabled downstream link. Matches the + * backend's storage rule (WorkflowCompiler treats out-degree-0 operators of the enabled plan as + * terminal and always materializes them), so a disabled link or operator does not mislead this. */ + private terminalOperatorIds(): string[] { + const graph = this.workflowActionService.getTexeraGraph(); + const hasEnabledDownstream = new Set(graph.getAllEnabledLinks().map(link => link.source.operatorID)); + return graph + .getAllOperators() + .filter(op => !(op.isDisabled ?? false) && !hasEnabledDownstream.has(op.operatorID)) + .map(op => op.operatorID); + } + /** * Build the form from the operators' JSON schemas (FormlyJsonschema), keeping the one field per * exposed property. Each input gets its own form keyed by binding id. @@ -627,6 +738,131 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { return rendered.resolved.binding.id; } + // --------------------------------------------------------------------------- + // Results: the chosen steps' output, shown under the workflow that produced it + // --------------------------------------------------------------------------- + + public get hasResults(): boolean { + return this.shownResultIds.some(id => this.workflowResultService.hasNonEmptyResult(id)); + } + + /** + * The chosen steps that actually produced a result, so only those get a card. Whether a Python + * UDF yields a result cannot be known from the graph -- some (a download/publish step) never do -- + * so a chosen step earns its card at runtime rather than sitting on a permanent "No result yet.". + */ + public get resultIdsToShow(): string[] { + return this.shownResultIds.filter(id => this.workflowResultService.hasNonEmptyResult(id)); + } + + public isTabularResult(operatorID: string): boolean { + return this.workflowResultService.hasPaginatedResult(operatorID); + } + + /** + * Whether this step's visualisation drew something. A visualiser reserves a fixed canvas even + * when empty, so gating on real content lets an empty result collapse to the compact "No result + * yet" line instead of a tall blank box. Tables are excluded (they take the tabular branch). + */ + public vizHasContent(operatorID: string): boolean { + if (this.isTabularResult(operatorID)) { + return false; + } + const snapshot = this.workflowResultService.getResultService(operatorID)?.getCurrentResultSnapshot(); + return !!snapshot && snapshot.length > 0; + } + + /** The operator's friendly label for a result card, falling back to its raw id. */ + public resultLabel(operatorID: string): string { + const operator = this.workflowActionService.getTexeraGraph().getOperator(operatorID); + return operator ? this.formBindingService.operatorLabel(operator) : operatorID; + } + + public trackByKey(_: number, key: string): string { + return key; + } + + /** + * A per-result identity that changes on each result-update for that operator, used as the chart's + * *ngFor key so the frame is rebuilt (not reused) when the result changes: the chart reads its + * content once at creation, so a reused frame kept showing the old (or "undefined") picture. A + * visualisation's result arrives as a single snapshot, so in practice this bumps about once per + * result rather than per tuple. + */ + public resultKey(operatorID: string): string { + return operatorID + "#" + (this.resultVersion.get(operatorID) ?? 0); + } + + public resultZoom(operatorID: string): number { + return this.zoomByResult.get(operatorID) ?? 1; + } + + public zoomResult(operatorID: string, delta: number): void { + const next = Math.min(2, Math.max(0, this.resultZoom(operatorID) + delta)); + this.zoomByResult.set(operatorID, next); + // Let the new card height land, then have the chart redraw into it -- growing the frame alone + // leaves the picture at its old size until something asks it to re-measure. + this.cdr.detectChanges(); + this.later(() => this.fitVisualisations(), 60); + } + + /** + * Scale each visualisation to its card. They render in a same-origin srcdoc iframe at natural + * size, so we inject a stylesheet to fit the content to the card and fire a resize so chart + * libraries re-lay out. The operator's output is untouched. + */ + /* v8 ignore start -- iframe/Plotly DOM fitting; no coverage in jsdom */ + private fitVisualisations(): void { + const frames = this.host.nativeElement.querySelectorAll(".result-body iframe"); + frames.forEach(frame => { + const apply = () => { + try { + const doc = frame.contentDocument; + if (!doc?.body) { + return; + } + if (!doc.getElementById("pc-fit")) { + const style = doc.createElement("style"); + style.id = "pc-fit"; + style.textContent = ` + html, body { margin: 0; padding: 8px; overflow-x: hidden; } + .js-plotly-plot, .plot-container, .plotly, .svg-container { width: 100% !important; height: 100% !important; } + img, svg, canvas, video { max-width: 100% !important; height: auto !important; } + table { max-width: 100%; } + `; + doc.head?.appendChild(style); + } + const win = frame.contentWindow as (Window & { Plotly?: any }) | null; + const plots = doc.querySelectorAll(".js-plotly-plot"); + if (win?.Plotly?.Plots?.resize && plots.length) { + plots.forEach(plot => { + plot.style.width = "100%"; + plot.style.height = "100%"; + try { + win.Plotly.Plots.resize(plot); + } catch { + // A chart mid-render cannot be resized; the next call will catch it. + } + }); + } + win?.dispatchEvent(new Event("resize")); + } catch { + // A cross-origin document cannot be styled from here; leave it as it came. + } + }; + apply(); + // Re-apply after the iframe (re)loads. Bind once per frame element (guarded by a data flag): + // a `{ once: true }` listener added on every fit call never fires for an already-loaded frame, + // so repeated zoom/fit calls would pile up detached listeners. A single persistent listener + // per frame re-fits on each reload and is torn down with the frame. + if (!frame.dataset.pcFitBound) { + frame.dataset.pcFitBound = "1"; + frame.addEventListener("load", apply); + } + }); + } + /* v8 ignore stop */ + // --------------------------------------------------------------------------- // Instruction: the author's one piece of guidance, shown as rendered markdown // --------------------------------------------------------------------------- @@ -668,6 +904,20 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { ); } + /** + * A run has started and ended, as opposed to never having run. Lets the empty results section say + * "this run produced nothing" after a completed-but-empty run, instead of the "press Run" hint + * that wrongly implies nothing has run yet. + */ + public get hasRunFinished(): boolean { + return ( + this.executionState === ExecutionState.Completed || + this.executionState === ExecutionState.Failed || + this.executionState === ExecutionState.Killed || + this.executionState === ExecutionState.Terminated + ); + } + /** * A unit is picked but its socket is still coming up -- the same window the operator canvas shows * "Connecting" and disables its run button. Read from the exact condition the canvas uses diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts index bca2ee4e046..4ba67a7ab55 100644 --- a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts @@ -33,6 +33,8 @@ import { WarningOutline, LoadingOutline, LockOutline, + MinusOutline, + PlusOutline, } from "@ant-design/icons-angular/icons"; import { EMPTY, of, Subject } from "rxjs"; @@ -56,6 +58,9 @@ import { WorkflowConsoleService } from "../../service/workflow-console/workflow- import { WorkflowWebsocketService } from "../../service/workflow-websocket/workflow-websocket.service"; import { ValidationWorkflowService } from "../../service/validation/validation-workflow.service"; import { ComputingUnitSelectionComponent } from "../power-button/computing-unit-selection.component"; +import { ResultTableFrameComponent } from "../result-panel/result-table-frame/result-table-frame.component"; +import { VisualizationFrameContentComponent } from "../visualization-panel-content/visualization-frame-content.component"; +import { PanelResizeService } from "../../service/workflow-result/panel-resize/panel-resize.service"; import { WorkflowComputingUnitManagingService } from "../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service"; import { WorkflowExecutionsService } from "../../../dashboard/service/user/workflow-executions/workflow-executions.service"; import { ComputingUnitActionsService } from "../../../common/service/computing-unit/computing-unit-actions/computing-unit-actions.service"; @@ -97,6 +102,11 @@ describe("WorkflowFormComponent (rendered template)", () => { // than overriding the page's imports, which would JIT-recompile the page and drop its // host-binding coverage -- keeps the run bar around it rendered and the page fully covered. TestBed.overrideComponent(ComputingUnitSelectionComponent, { set: { template: "" } }); + // Blank the two result frame children: the real table/visualization need a live result service + // and (for the chart) an iframe jsdom cannot run. Blanking the children keeps the page's own + // results markup -- the section, the card, the head, the zoom controls -- rendered and covered. + TestBed.overrideComponent(ResultTableFrameComponent, { set: { template: "" } }); + TestBed.overrideComponent(VisualizationFrameContentComponent, { set: { template: "" } }); /* eslint-enable no-restricted-syntax */ await TestBed.configureTestingModule({ @@ -130,6 +140,10 @@ describe("WorkflowFormComponent (rendered template)", () => { triggerCenterEvent: vi.fn(), hasOperator: () => false, getOperator: () => undefined, + getAllOperators: () => [], + getAllEnabledLinks: () => [], + getOperatorsToViewResult: () => new Set(), + getViewResultOperatorsChangedStream: () => EMPTY, }), }, }, @@ -171,7 +185,18 @@ describe("WorkflowFormComponent (rendered template)", () => { resetExecutionAndWorkers: vi.fn(), }, }, - { provide: WorkflowResultService, useValue: { clearResults: vi.fn() } }, + { + provide: WorkflowResultService, + useValue: { + clearResults: vi.fn(), + getResultUpdateStream: () => EMPTY, + hasNonEmptyResult: () => false, + hasAnyResult: () => false, + hasPaginatedResult: () => false, + getResultService: () => undefined, + }, + }, + { provide: PanelResizeService, useValue: { changePanelSize: vi.fn() } }, { provide: NotificationService, useValue: { error: vi.fn() } }, { provide: UserService, useValue: { getCurrentUser: () => undefined, isLogin: () => false } }, { provide: MarkdownService, useValue: { parse: (s: string) => s } }, @@ -212,6 +237,8 @@ describe("WorkflowFormComponent (rendered template)", () => { WarningOutline, LoadingOutline, LockOutline, + MinusOutline, + PlusOutline, ], }, DatePipe, @@ -391,6 +418,30 @@ describe("WorkflowFormComponent (rendered template)", () => { expect(el(".run-note")?.getAttribute("role")).toBe("status"); }); + it("renders the results section: empty state, then a result card for a produced step", () => { + fixture.detectChanges(); + finishLoad(); + const c = fixture.componentInstance; + + // Before any result: the section shows its quiet empty line, no cards. + expect(el(".results .label")?.textContent?.trim()).toBe("Results"); + expect(el(".results-empty")).not.toBeNull(); + expect(el(".result")).toBeNull(); + + // A chosen step reports a non-empty result: a card appears. Kept in the neutral "no result + // yet" switch branch (not tabular, no snapshot) so the heavy table/visualization children -- + // which their own specs cover, and which drag in a websocket/status chain jsdom cannot run -- + // are not instantiated here; this test covers the page's own card + head markup. + const wrs: any = TestBed.inject(WorkflowResultService); + wrs.hasNonEmptyResult = () => true; + c.shownResultIds = ["op-1"]; + fixture.detectChanges(); + + expect(el(".results-empty")).toBeNull(); + expect(el(".result .result-head")).not.toBeNull(); + expect(el(".result .result-body")).not.toBeNull(); + }); + it("tears the workflow down when the browser unloads (the beforeunload host binding)", () => { fixture.detectChanges(); finishLoad(); 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 index 4a9b7fa6055..c4c0b25213c 100644 --- 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 @@ -65,10 +65,24 @@ export function setupHarness() { const selectedUnitStream = new Subject(); const validationStream = new Subject<{ errors: Record; workflowEmpty: boolean }>(); const connectionStream = new Subject(); + // A result changed; the form bumps versions and re-fits. Tests drive it directly. + const resultUpdateStream = new Subject>(); + // Fires when the canvas's view-result set changes (a co-editor's eye toggle included). + const viewResultChanged = new Subject(); // The operators the graph holds: `hasOperatorIds` gates operatorSchemaFor, `graphOperators` // supplies each operator's type (which picks the custom widget). Tests add to them as needed. const hasOperatorIds = new Set(); const graphOperators: any[] = []; + // Operators with view-result ("the eye") on in the canvas -- the ONLY ones the form may show. + const viewResultIds = new Set(); + // Operators that produced a non-empty result -- drives hasNonEmptyResult in the result mock. + const anyResultIds = new Set(); + // Operators the engine treats as terminal (out-degree 0 on the ENABLED plan): the engine materializes + // a terminal's result with no eye set, so the form shows it too. A test marks an id terminal by adding + // it here (no downstream at all) or to disabledDownstream (its only downstream link is disabled) -- + // both mean "no enabled downstream link", so getAllEnabledLinks emits no link from that operator. + const terminalIds = new Set(); + const disabledDownstream = new Set(); // The preview centres the embedded graph once it is built; tests assert this fired. const triggerCenterEvent = vi.fn(); @@ -89,6 +103,20 @@ export function setupHarness() { triggerCenterEvent, hasOperator: (id: string) => hasOperatorIds.has(id), getOperator: (id: string) => graphOperators.find(o => o.operatorID === id), + getAllOperators: () => graphOperators, + getOperatorsToViewResult: () => new Set(viewResultIds), + getViewResultOperatorsChangedStream: () => viewResultChanged.asObservable(), + // Enabled links only: a non-terminal operator emits one dummy outgoing link; a terminal operator + // (in terminalIds, or whose downstream is disabled via disabledDownstream) emits none. Drives the + // component's terminal detection (terminalOperatorIds / isTerminalOperator). + getAllEnabledLinks: () => + graphOperators + .filter(op => !terminalIds.has(op.operatorID) && !disabledDownstream.has(op.operatorID)) + .map(op => ({ + linkID: `${op.operatorID}-out`, + source: { operatorID: op.operatorID }, + target: { operatorID: "downstream" }, + })), }), // Exposing or un-exposing a property announces on this stream; the form re-reads its config. formBindingChanged$: new Subject(), @@ -102,6 +130,8 @@ export function setupHarness() { resolveFields: vi.fn().mockReturnValue([]), readValue: vi.fn().mockReturnValue(undefined), writeValue: vi.fn(), + // A result card's friendly label; the mock returns the operator's display name or its id. + operatorLabel: (op: any) => op?.customDisplayName ?? op?.operatorType ?? op?.operatorID, }; // A field per property the tests expose. Real formly json-schema conversion is exercised by the // property panel's own spec; here a deterministic map keeps these tests about the component's @@ -178,7 +208,19 @@ export function setupHarness() { killWorkflow: vi.fn(), resetExecutionAndWorkers: vi.fn(), }; - const workflowResultService = { clearResults: vi.fn() }; + // Results: `anyResultIds` marks which operators produced a non-empty result; `snapshotById` + // lets a test give an operator a snapshot (drives vizHasContent). `hasPaginatedResult` is off + // unless a test overrides it. `getResultUpdateStream` is the stream the form watches. + const snapshotById = new Map>(); + const workflowResultService = { + clearResults: vi.fn(), + getResultUpdateStream: () => resultUpdateStream.asObservable(), + hasNonEmptyResult: (id: string) => anyResultIds.has(id), + hasAnyResult: (id: string) => anyResultIds.has(id), + hasPaginatedResult: (_id: string) => false, + getResultService: (id: string) => ({ getCurrentResultSnapshot: () => snapshotById.get(id) }), + }; + const panelResizeService = { changePanelSize: vi.fn() }; const notificationService = { error: vi.fn() }; // Not logged in by default so opening a workflow does not save; the save tests log in. const userService = { getCurrentUser: () => undefined, isLogin: vi.fn().mockReturnValue(false) }; @@ -235,6 +277,7 @@ export function setupHarness() { computingUnitStatusService, workflowConsoleService, workflowWebsocketService, + panelResizeService, validationWorkflowService, host, datePipe, @@ -248,8 +291,15 @@ export function setupHarness() { selectedUnitStream, validationStream, connectionStream, + resultUpdateStream, + viewResultChanged, hasOperatorIds, graphOperators, + viewResultIds, + anyResultIds, + terminalIds, + disabledDownstream, + snapshotById, triggerCenterEvent, }; } diff --git a/frontend/src/app/workspace/service/workflow-result/workflow-result.service.spec.ts b/frontend/src/app/workspace/service/workflow-result/workflow-result.service.spec.ts index 31b83b5b109..e53de2c11fc 100644 --- a/frontend/src/app/workspace/service/workflow-result/workflow-result.service.spec.ts +++ b/frontend/src/app/workspace/service/workflow-result/workflow-result.service.spec.ts @@ -115,6 +115,28 @@ describe("WorkflowResultService", () => { expect(updateEvents).toEqual([updates]); }); + it("hasNonEmptyResult tells an empty result apart from one with rows", () => { + const ws = TestBed.inject(WorkflowWebsocketService); + pushWsEvent(ws, { + type: "WebResultUpdateEvent", + updates: { + emptyPag: paginationUpdate(0), + fullPag: paginationUpdate(5), + emptySnap: snapshotUpdate([]), + fullSnap: snapshotUpdate([{ a: 1 }]), + }, + tableStats: {}, + }); + + // A service exists for the empty step (hasAnyResult), but it holds nothing. + expect(service.hasAnyResult("emptyPag")).toBe(true); + expect(service.hasNonEmptyResult("emptyPag")).toBe(false); + expect(service.hasNonEmptyResult("fullPag")).toBe(true); + expect(service.hasNonEmptyResult("emptySnap")).toBe(false); + expect(service.hasNonEmptyResult("fullSnap")).toBe(true); + expect(service.hasNonEmptyResult("never-ran")).toBe(false); + }); + it("announces newly-created operators on the result-initiate stream", () => { const ws = TestBed.inject(WorkflowWebsocketService); const initiated: string[] = []; diff --git a/frontend/src/app/workspace/service/workflow-result/workflow-result.service.ts b/frontend/src/app/workspace/service/workflow-result/workflow-result.service.ts index 89d4e453fab..e7879ba15e8 100644 --- a/frontend/src/app/workspace/service/workflow-result/workflow-result.service.ts +++ b/frontend/src/app/workspace/service/workflow-result/workflow-result.service.ts @@ -64,6 +64,20 @@ export class WorkflowResultService { return this.hasResult(operatorID) || this.hasPaginatedResult(operatorID); } + /** + * Whether the operator produced an actual, non-empty result. A step marked view-result still + * registers a (paginated) result service with zero tuples -- e.g. a UDF that only writes a file + * or logs -- so hasAnyResult alone is true for those. This checks the tuple count / snapshot + * length so an empty result reads as "no result". + */ + public hasNonEmptyResult(operatorID: string): boolean { + const paginated = this.getPaginatedResultService(operatorID); + if (paginated) { + return paginated.getCurrentTotalNumTuples() > 0; + } + return (this.getResultService(operatorID)?.getCurrentResultSnapshot()?.length ?? 0) > 0; + } + public hasResult(operatorID: string): boolean { return isDefined(this.getResultService(operatorID)); }