Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8846756
feat: read the result of any output port of an operator
kz930 Oct 20, 2026
13c1294
fix(frontend): keep a result table to the output port it shows
kz930 Oct 20, 2026
a64630b
feat(frontend): preview an operator's result when hovering it on the …
kz930 Oct 20, 2026
896a32a
feat(frontend): pin an operator's result beside it, with a tab per ou…
kz930 Oct 20, 2026
e7fa6eb
Merge upstream/main into feat/9053-read-any-output-port
kz930 Oct 26, 2026
bc9c706
Merge feat/9053-read-any-output-port into feat/9057-pinned-result-win…
kz930 Oct 26, 2026
8f43951
Merge upstream/main into feat/9057-pinned-result-windows
kz930 Oct 26, 2026
ac7682d
Merge feat/9057-pinned-result-windows into feat/9062-pinned-result-wi…
kz930 Oct 26, 2026
7e4e75f
feat(frontend): close the result preview on every press and delete, a…
kz930 Nov 1, 2026
18159ea
feat(frontend): leave selectPage's default column limit to its own PR
kz930 Nov 1, 2026
032fe0c
Merge upstream/main into feat/9057-pinned-result-windows
kz930 Nov 1, 2026
bc0afea
Merge feat/9057-pinned-result-windows into feat/9062-pinned-result-wi…
kz930 Nov 1, 2026
abf80e8
feat(frontend): refresh a pinned window's port past the first
kz930 Nov 2, 2026
f67e171
feat(frontend): show the last output when a pinned window's chosen on…
kz930 Nov 2, 2026
34c6d18
feat(frontend): cover pinned result windows in the editor's spec
kz930 Nov 2, 2026
b0018e9
feat(frontend): hide a cell download on an output past the first
kz930 Nov 4, 2026
828fbb8
ci: re-run CI after a flaky macOS frontend run
kz930 Nov 4, 2026
261c8eb
Merge apache/main into feat/9062-pinned-result-windows
kz930 Nov 5, 2026
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 @@ -86,7 +86,7 @@ <h4>Empty result set</h4>
</th>
</tr>
<tr
*ngIf="tableStats && prevTableStats"
*ngIf="hasTableStats"
#statsRow
class="custom-stats-row">
<th *ngFor="let column of currentColumns">
Expand Down Expand Up @@ -195,9 +195,10 @@ <h5 class="rightAlign"><span [innerHTML]="compare(column.header, 'other')"></spa
</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. -->
nothing. The top menu and the context menu already honour the same switch.
Nor on a port past the first: a cell export reads the operator's first port. -->
<button
*ngIf="guiConfigService.env.exportExecutionResultEnabled"
*ngIf="guiConfigService.env.exportExecutionResultEnabled && portIndex === 0"
(click)="downloadData(currentResult[i][column.columnDef], i, columnIndex, column.columnDef); $event.stopPropagation()"
nz-button
nzType="link"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@

import { ComponentFixture, TestBed } from "@angular/core/testing";
import { SimpleChange } from "@angular/core";
import type { Mock } from "vitest";

import { ResultTableFrameComponent } from "./result-table-frame.component";
import { OTHER_PORT_REFRESH_MS, ResultTableFrameComponent } from "./result-table-frame.component";
import { OperatorMetadataService } from "../../../service/operator-metadata/operator-metadata.service";
import { StubOperatorMetadataService } from "../../../service/operator-metadata/stub-operator-metadata.service";
import { HttpClientTestingModule } from "@angular/common/http/testing";
Expand Down Expand Up @@ -410,6 +411,38 @@ describe("ResultTableFrameComponent", () => {
stateStream.next({ otherOp: OperatorState.Completed });
expect(component.isOperatorFinished).toBe(false);
});

it("asks once more for a port past the first when the operator completes, as no update may follow", () => {
const stateStream = new Subject<Record<string, OperatorState>>();
vi.spyOn(workflowStatusService, "getStateUpdateStream").mockReturnValue(stateStream.asObservable());
const secondPort = {
getCurrentTotalNumTuples: vi.fn().mockReturnValue(6),
selectPage: vi.fn().mockReturnValue(of(makePageEvent(1))),
};
vi.spyOn(workflowResultService, "getPortPaginatedResultService").mockReturnValue(
secondPort as unknown as OperatorPaginationResultService
);
recreateComponent("op1");
component.portIndex = 1;

stateStream.next({ op1: OperatorState.Running });
stateStream.next({ op1: OperatorState.Completed });
stateStream.next({ op1: OperatorState.Completed });

expect(secondPort.selectPage).toHaveBeenCalledTimes(1);
expect(component.totalNumTuples).toBe(6);
});

it("leaves the first port to its updates when the operator completes", () => {
const stateStream = new Subject<Record<string, OperatorState>>();
vi.spyOn(workflowStatusService, "getStateUpdateStream").mockReturnValue(stateStream.asObservable());
const paginated = makePaginatedResultService();
recreateComponent("op1");

stateStream.next({ op1: OperatorState.Completed });

expect(paginated.selectPage).not.toHaveBeenCalled();
});
});

describe("result update stream", () => {
Expand Down Expand Up @@ -462,16 +495,53 @@ describe("ResultTableFrameComponent", () => {
expect(component.totalNumTuples).toBe(0);
});

it("leaves a port past the first alone, as the updates are about the first port", () => {
const paginated = makePaginatedResultService();
recreateComponent("op1");
component.portIndex = 1;
component.totalNumTuples = 5;
describe("on a port past the first", () => {
let secondPort: { getCurrentTotalNumTuples: Mock; selectPage: Mock };

resultUpdates.next({ op1: paginationUpdate(63, [1]) });
beforeEach(() => {
vi.useFakeTimers();
secondPort = {
getCurrentTotalNumTuples: vi.fn().mockReturnValue(5),
selectPage: vi.fn().mockReturnValue(of(makePageEvent(1))),
};
vi.spyOn(workflowResultService, "getPortPaginatedResultService").mockReturnValue(
secondPort as unknown as OperatorPaginationResultService
);
recreateComponent("op1");
component.portIndex = 1;
});

expect(component.totalNumTuples).toBe(5);
expect(paginated.selectPage).not.toHaveBeenCalled();
afterEach(() => vi.useRealTimers());

it("asks for its page again on its operator's update, as the update counts the first port only", () => {
component.totalNumTuples = 3;

resultUpdates.next({ op1: paginationUpdate(63, [4]) });

expect(secondPort.selectPage).toHaveBeenCalledTimes(1);
// the count is the port's own, from the reply, not the first port's
expect(component.totalNumTuples).toBe(5);
});

it("ignores another operator's update", () => {
resultUpdates.next({ otherOp: paginationUpdate(9, [1]) });
vi.advanceTimersByTime(OTHER_PORT_REFRESH_MS);

expect(secondPort.selectPage).not.toHaveBeenCalled();
});

it("asks at most once a second while a run updates, and always for the last update", () => {
resultUpdates.next({ op1: paginationUpdate(1, [1]) });
resultUpdates.next({ op1: paginationUpdate(2, [1]) });
resultUpdates.next({ op1: paginationUpdate(3, [1]) });
expect(secondPort.selectPage).toHaveBeenCalledTimes(1);

vi.advanceTimersByTime(OTHER_PORT_REFRESH_MS);
expect(secondPort.selectPage).toHaveBeenCalledTimes(2);

vi.advanceTimersByTime(OTHER_PORT_REFRESH_MS);
expect(secondPort.selectPage).toHaveBeenCalledTimes(2);
});
});

it("ignores updates when the frame has no operator", () => {
Expand Down Expand Up @@ -664,6 +734,48 @@ describe("ResultTableFrameComponent", () => {
expect(component.pageSize).toBe(1);
expect(component.currentPageIndex).toBe(3);
});

// A table in a pinned window fills that window instead of the result panel.
it("falls back to the panel's rule for the height it is given while no row is drawn", () => {
component.totalNumTuples = 30;
const paginated = makePaginatedResultService();
component.operatorId = "op1";
component.containerHeight = 700;

component.ngOnChanges({ containerHeight: new SimpleChange(undefined, 700, false) });

// the same 700 the result panel was given above, so the same 11 rows
expect(component.pageSize).toBe(11);
// a new height alone resizes the page; the table asks for the rows itself, as on a panel resize
expect(paginated.selectPage).not.toHaveBeenCalled();

resizeService.changePanelSize(800, 300);
expect(component.pageSize).toBe(11);
});

it("fits as many rows as the height leaves under what it measures around the drawn rows", () => {
component.totalNumTuples = 30;
component.operatorId = "op1";
// the server pages the rows, so both rows given are drawn
component.isFrontPagination = false;
component.setupResultTable([SAMPLE_ROW, { _id: 1, name: "bob", score: 75 }], 30);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
const rows = Array.from(host.querySelectorAll<HTMLElement>("tbody tr.ant-table-row"));
expect(rows.length).toBe(2);
// the drawn table is 200px tall, its two rows 40px each, so 120px sit around the rows
const setHeight = (element: HTMLElement, height: number) =>
Object.defineProperty(element, "offsetHeight", { configurable: true, get: () => height });
setHeight(host.querySelector<HTMLElement>(".result-table")!, 200);
rows.forEach(row => setHeight(row, 40));
component.containerHeight = 450;

component.ngOnChanges({ containerHeight: new SimpleChange(undefined, 450, false) });

// (450 - 120) / 40 rounds down to 8 rows, where the panel's rule would give 4
expect(component.pageSize).toBe(8);
expect(resizeService.pageSize).toBe(8);
});
});

describe("onTableQueryParamsChange", () => {
Expand Down Expand Up @@ -866,6 +978,20 @@ describe("ResultTableFrameComponent", () => {
expect(fixture.nativeElement.querySelector(".result-table").hidden).toBe(true);
});

// Statistics come only with the first output port, and only once they arrive; an empty row of
// them would be a grey bar with nothing in it.
it("renders no statistics row while no column has statistics", () => {
component.operatorId = "op1";
component.setupResultTable([SAMPLE_ROW], 1);
component.isFrontPagination = false;
component.tableStats = {};
component.prevTableStats = {};
fixture.detectChanges();

expect(fixture.nativeElement.querySelector(".result-table").hidden).toBe(false);
expect(fixture.nativeElement.querySelector(".custom-stats-row")).toBeNull();
});

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);
Expand Down Expand Up @@ -939,6 +1065,12 @@ describe("ResultTableFrameComponent", () => {
renderOneRow(false);
expect(fixture.debugElement.query(By.css("button.download-button"))).toBeNull();
});

it("is not rendered on a port past the first, as a cell export reads the first port", () => {
component.portIndex = 1;
renderOneRow(true);
expect(fixture.debugElement.query(By.css("button.download-button"))).toBeNull();
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* under the License.
*/

import { ChangeDetectorRef, Component, Input, OnChanges, OnInit, SimpleChanges } from "@angular/core";
import { ChangeDetectorRef, Component, ElementRef, Input, OnChanges, OnInit, SimpleChanges } from "@angular/core";
import { NzModalRef, NzModalService } from "ng-zorro-antd/modal";
import {
NzTableQueryParams,
Expand All @@ -42,6 +42,7 @@ import { RowModalComponent } from "../result-panel-modal.component";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { DomSanitizer, SafeHtml } from "@angular/platform-browser";
import { isAudioUrl, isVideoUrl, isImageUrl } from "../../../../common/util/media-type.util";
import { isDefined } from "../../../../common/util/predicate";
import { ResultExportationComponent } from "../../result-exportation/result-exportation.component";
import { WorkflowStatusService } from "../../../service/workflow-status/workflow-status.service";
import { GuiConfigService } from "../../../../common/service/gui-config.service";
Expand All @@ -52,6 +53,10 @@ import { NzButtonComponent } from "ng-zorro-antd/button";
import { NzWaveDirective } from "ng-zorro-antd/core/wave";
import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
import { NzIconDirective } from "ng-zorro-antd/icon";
import { asyncScheduler, filter, throttleTime } from "rxjs";

// how often, at most, a table on a port past the first asks for its page again while a run updates
export const OTHER_PORT_REFRESH_MS = 1000;

export type MediaCellType = "video" | "audio" | "image" | "text";

Expand Down Expand Up @@ -95,6 +100,9 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
// which of the operator's output ports to show. The running execution reports only on the first,
// so another port is read when shown, and its counts and statistics are not followed live.
@Input() portIndex = 0;
// The height the table has to fill in a window of its own. Left out, the table fills the result
// panel and follows its height.
@Input() containerHeight?: number;

// display result table
currentColumns?: TableColumn[];
Expand All @@ -119,6 +127,14 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
panelHeight = 0;
tableStats: Record<string, Record<string, number>> = {};
prevTableStats: Record<string, Record<string, number>> = {};

/**
* Whether any column has statistics to show. Until they arrive there are none, and an output
* port past the first never has any, as the statistics are the first port's.
*/
get hasTableStats(): boolean {
return Object.keys(this.tableStats).length > 0;
}
widthPercent: string = "";
isOperatorFinished: boolean = false;

Expand All @@ -135,14 +151,18 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
private workflowResultService: WorkflowResultService,
private resizeService: PanelResizeService,
private changeDetectorRef: ChangeDetectorRef,
private elementRef: ElementRef,
private sanitizer: DomSanitizer,
private workflowStatusService: WorkflowStatusService,
// Read by the template only (the export button's flag); the template can see a protected member.
protected guiConfigService: GuiConfigService
) {}

ngOnChanges(changes: SimpleChanges): void {
if (isDefined(this.containerHeight)) this.fitPageSizeToContainer(this.containerHeight);
if (changes.operatorId) this.operatorId = changes.operatorId.currentValue;
// A new height alone only resizes the page, as dragging the result panel's edge does.
if (Object.keys(changes).every(key => key === "containerHeight")) return;
// another port's rows are not this port's, so a new port starts from an empty table
if (changes.portIndex && !changes.portIndex.firstChange) {
this.currentResult = [];
Expand Down Expand Up @@ -174,6 +194,9 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
.pipe(untilDestroyed(this))
.subscribe(stateMap => {
if (this.operatorId && stateMap[this.operatorId] === OperatorState.Completed) {
// Another port can still be filling after the first port's last update, so its table
// asks once more when the operator completes.
if (!this.isOperatorFinished && this.portIndex !== 0) this.changePaginatedResultData();
this.isOperatorFinished = true;
this.changeDetectorRef.detectChanges();
} else {
Expand Down Expand Up @@ -205,6 +228,17 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
this.changeDetectorRef.detectChanges();
});

// Another port has no update of its own, so its table asks for its page again on the
// operator's updates, at most once a second; the last one is always asked for.
this.workflowResultService
.getResultUpdateStream()
.pipe(
filter(update => isDefined(this.operatorId) && this.portIndex !== 0 && isDefined(update[this.operatorId])),
throttleTime(OTHER_PORT_REFRESH_MS, asyncScheduler, { leading: true, trailing: true }),
untilDestroyed(this)
)
.subscribe(() => this.changePaginatedResultData());

this.workflowResultService
.getResultTableStats()
.pipe(untilDestroyed(this))
Expand All @@ -225,12 +259,9 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
});

this.resizeService.currentSize.pipe(untilDestroyed(this)).subscribe(size => {
this.panelHeight = size.height;
this.adjustPageSizeBasedOnPanelSize(size.height);
let currentPageNum: number = Math.ceil(this.totalNumTuples / this.pageSize);
while (this.currentPageIndex > currentPageNum && this.currentPageIndex > 1) {
this.currentPageIndex -= 1;
}
// a table given its own height to fill does not follow the result panel's
if (isDefined(this.containerHeight)) return;
this.fitPageSize(size.height);
});

if (this.operatorId) {
Expand Down Expand Up @@ -326,8 +357,10 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
PAGINATION_HEIGHT +
SEARCH_BAR_HEIGHT_WITH_MARGIN;

const newPageSize = Math.max(1, Math.floor((panelHeight - usedHeight) / ROW_HEIGHT));
this.setPageSize(Math.max(1, Math.floor((panelHeight - usedHeight) / ROW_HEIGHT)));
}

private setPageSize(newPageSize: number): void {
const oldOffset = (this.currentPageIndex - 1) * this.pageSize;

this.pageSize = newPageSize;
Expand All @@ -336,6 +369,40 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
this.currentPageIndex = Math.floor(oldOffset / newPageSize) + 1;
}

/** As many rows to a page as fill the result panel, keeping the page within the result. */
private fitPageSize(height: number): void {
this.panelHeight = height;
this.adjustPageSizeBasedOnPanelSize(height);
this.keepPageWithinResult();
}

/**
* In a window of its own, the table measures what it draws around its rows, the search bar,
* header, statistics and pager, so its rows take up the rest. Until a row is drawn there is
* nothing to measure, and the panel's rule stands in.
*/
private fitPageSizeToContainer(height: number): void {
const table = (this.elementRef.nativeElement as HTMLElement).querySelector<HTMLElement>(".result-table");
const rows = Array.from(table?.querySelectorAll<HTMLElement>("tbody tr.ant-table-row") ?? []);
const rowsHeight = rows.reduce((sum, row) => sum + row.offsetHeight, 0);
if (!table || rowsHeight === 0) {
this.fitPageSize(height);
return;
}
const aroundRows = table.offsetHeight - rowsHeight;
const pageSize = Math.max(1, Math.floor((height - aroundRows) / (rowsHeight / rows.length)));
if (pageSize === this.pageSize) return;
this.setPageSize(pageSize);
this.keepPageWithinResult();
}

private keepPageWithinResult(): void {
let currentPageNum: number = Math.ceil(this.totalNumTuples / this.pageSize);
while (this.currentPageIndex > currentPageNum && this.currentPageIndex > 1) {
this.currentPageIndex -= 1;
}
}

/**
* Callback function for table query params changed event
* params containing new page index, new page size, and more
Expand Down Expand Up @@ -437,6 +504,8 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
}
this.setupResultTable(pageData.table, paginatedResultService.getCurrentTotalNumTuples(), pageData.schema);
this.changeDetectorRef.detectChanges();
// the rows are drawn now, so a table in a window can measure what fits
if (isDefined(this.containerHeight)) this.fitPageSizeToContainer(this.containerHeight);
});
}

Expand Down
Loading
Loading