Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -223,5 +223,75 @@ <h2>{{ instructionTitle || "How to use this" }}</h2>
class="box"></texera-mini-map>
</div>
</section>

<!-- Results, under the workflow that produced them. The section keeps its place before a run so
the answer has a visible destination. -->
<section class="results">
<div class="pc-section-head"><span class="label">Results</span></div>
<p
class="results-empty"
*ngIf="resultIdsToShow.length === 0">
{{ isRunning ? "Working…" : hasRunFinished ? "This run produced no results to show." : "Press Run and the
results appear here." }}
</p>
<!-- A card only for a chosen step that actually produced a result. Whether a Python UDF
yields one cannot be told from the graph, so a step earns its card at runtime rather than
sitting on a permanent "No result yet." (a download/publish step never would). -->
<ng-container *ngFor="let id of resultIdsToShow">
<div class="card result">
<div class="result-head">
<span>{{ resultLabel(id) }}</span>
<!-- Zooming only means something once there is a picture to zoom. -->
<span
class="result-zoom"
*ngIf="vizHasContent(id)">
<button
(click)="zoomResult(id, -1)"
[disabled]="resultZoom(id) <= 0"
nz-tooltip="Smaller"
aria-label="Smaller">
<i
nz-icon
nzType="minus"
aria-hidden="true"></i>
</button>
<button
(click)="zoomResult(id, 1)"
[disabled]="resultZoom(id) >= 2"
nz-tooltip="Bigger"
aria-label="Bigger">
<i
nz-icon
nzType="plus"
aria-hidden="true"></i>
</button>
</span>
</div>
<!-- Three cases, in order: a table (it shows its own "Empty result set" when it has no
rows), a visualisation that actually drew something, or nothing yet -- which
collapses to a compact line instead of a tall blank canvas. -->
<div
class="result-body"
[attr.data-zoom]="resultZoom(id)"
[ngSwitch]="true">
<texera-result-table-frame
*ngSwitchCase="isTabularResult(id)"
[operatorId]="id"></texera-result-table-frame>
<ng-container *ngSwitchCase="vizHasContent(id)">
<!-- Keyed on the result's version so a new result builds a new frame: the component
reads its content once, at creation. -->
<texera-visualization-panel-content
*ngFor="let key of [resultKey(id)]; trackBy: trackByKey"
[operatorId]="id"></texera-visualization-panel-content>
Comment thread
mengw15 marked this conversation as resolved.
</ng-container>
<p
*ngSwitchDefault
class="result-pending">
{{ isRunning ? "Computing…" : "No result yet." }}
</p>
</div>
</div>
</ng-container>
</section>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down Expand Up @@ -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 } });
Expand Down Expand Up @@ -1095,6 +1116,144 @@ 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("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();
Expand Down
Loading
Loading