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 aadb1775a7a..fc5dd05f090 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
@@ -86,7 +86,7 @@
Empty result set
@@ -195,9 +195,10 @@
+ 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. -->
{
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>();
+ 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>();
+ 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", () => {
@@ -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", () => {
@@ -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("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(".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", () => {
@@ -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);
@@ -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();
+ });
});
});
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 78d07d8dfdd..75ff2d6acfb 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
@@ -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,
@@ -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";
@@ -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";
@@ -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[];
@@ -119,6 +127,14 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
panelHeight = 0;
tableStats: Record> = {};
prevTableStats: Record> = {};
+
+ /**
+ * 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;
@@ -135,6 +151,7 @@ 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.
@@ -142,7 +159,10 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
) {}
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 = [];
@@ -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 {
@@ -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))
@@ -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) {
@@ -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;
@@ -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(".result-table");
+ const rows = Array.from(table?.querySelectorAll("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
@@ -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);
});
}
diff --git a/frontend/src/app/workspace/component/workflow-editor/operator-result-preview/operator-result-preview.component.html b/frontend/src/app/workspace/component/workflow-editor/operator-result-preview/operator-result-preview.component.html
index d6b6cd1d9bd..46e574c1d1f 100644
--- a/frontend/src/app/workspace/component/workflow-editor/operator-result-preview/operator-result-preview.component.html
+++ b/frontend/src/app/workspace/component/workflow-editor/operator-result-preview/operator-result-preview.component.html
@@ -27,6 +27,17 @@
[class.result-preview__summary--error]="preview.kind === 'error'"
>{{ preview.summary }}
+
+
+ Pin
+
0"
diff --git a/frontend/src/app/workspace/component/workflow-editor/operator-result-preview/operator-result-preview.component.scss b/frontend/src/app/workspace/component/workflow-editor/operator-result-preview/operator-result-preview.component.scss
index 442e9dc685a..04c99709320 100644
--- a/frontend/src/app/workspace/component/workflow-editor/operator-result-preview/operator-result-preview.component.scss
+++ b/frontend/src/app/workspace/component/workflow-editor/operator-result-preview/operator-result-preview.component.scss
@@ -44,6 +44,24 @@
gap: 8px;
}
+ &__pin {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 1px 8px;
+ font-size: 12px;
+ color: #1890ff;
+ background: white;
+ border: 1px solid #91d5ff;
+ border-radius: 4px;
+ cursor: pointer;
+
+ &:hover {
+ background: #e6f7ff;
+ }
+ }
+
&__title {
min-width: 0;
font-weight: 600;
diff --git a/frontend/src/app/workspace/component/workflow-editor/operator-result-preview/operator-result-preview.component.ts b/frontend/src/app/workspace/component/workflow-editor/operator-result-preview/operator-result-preview.component.ts
index 159725f1ead..6af43d58ed0 100644
--- a/frontend/src/app/workspace/component/workflow-editor/operator-result-preview/operator-result-preview.component.ts
+++ b/frontend/src/app/workspace/component/workflow-editor/operator-result-preview/operator-result-preview.component.ts
@@ -17,8 +17,18 @@
* under the License.
*/
-import { ChangeDetectorRef, Component, Input, OnChanges, OnInit, SimpleChanges } from "@angular/core";
+import {
+ ChangeDetectorRef,
+ Component,
+ EventEmitter,
+ Input,
+ OnChanges,
+ OnInit,
+ Output,
+ SimpleChanges,
+} from "@angular/core";
import { NgFor, NgIf } from "@angular/common";
+import { NzIconDirective } from "ng-zorro-antd/icon";
import { Subscription } from "rxjs";
import { filter } from "rxjs/operators";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
@@ -62,10 +72,13 @@ export interface OperatorResultPreview {
selector: "texera-operator-result-preview",
templateUrl: "./operator-result-preview.component.html",
styleUrls: ["./operator-result-preview.component.scss"],
- imports: [NgIf, NgFor],
+ imports: [NgIf, NgFor, NzIconDirective],
})
export class OperatorResultPreviewComponent implements OnInit, OnChanges {
@Input() operatorId = "";
+ // whether the card offers to pin the result next to the operator
+ @Input() pinnable = false;
+ @Output() pin = new EventEmitter();
// The first rows of a table result, one string per shown column; undefined while they load.
rows: readonly (readonly string[])[] | undefined = undefined;
diff --git a/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.html b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.html
new file mode 100644
index 00000000000..687abbd3557
--- /dev/null
+++ b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.html
@@ -0,0 +1,70 @@
+
+
+
+
+
1"
+ class="pinned-result__ports"
+ role="tablist">
+
+ {{ port.label }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.scss b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.scss
new file mode 100644
index 00000000000..6d25a26c344
--- /dev/null
+++ b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.scss
@@ -0,0 +1,102 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.pinned-result {
+ width: 520px;
+ // tall enough for the table's first page to hold 5 rows, as the result panel's does
+ height: 480px;
+ min-width: 280px;
+ min-height: 160px;
+ display: flex;
+ flex-direction: column;
+ // the bottom-right corner resizes the window
+ resize: both;
+ overflow: hidden;
+ background: white;
+ border: 1px solid #d9d9d9;
+ border-radius: 6px;
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.16);
+
+ &__header {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: 30px;
+ padding: 0 4px 0 10px;
+ cursor: move;
+ user-select: none;
+ background: #fafafa;
+ border-bottom: 1px solid #f0f0f0;
+ }
+
+ &__pin {
+ color: #1890ff;
+ }
+
+ &__title {
+ flex: 1;
+ min-width: 0;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__close {
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ color: #595959;
+ padding: 2px 6px;
+ }
+
+ &__ports {
+ flex-shrink: 0;
+ display: flex;
+ gap: 2px;
+ overflow-x: auto;
+ padding: 0 6px;
+ border-bottom: 1px solid #f0f0f0;
+ }
+
+ &__port {
+ flex-shrink: 0;
+ height: 28px;
+ padding: 0 10px;
+ border: none;
+ border-bottom: 2px solid transparent;
+ background: transparent;
+ cursor: pointer;
+ color: #595959;
+
+ &--active {
+ border-bottom-color: #1890ff;
+ color: #1890ff;
+ font-weight: 600;
+ }
+ }
+
+ &__body {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 4px;
+ }
+}
diff --git a/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.spec.ts b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.spec.ts
new file mode 100644
index 00000000000..120193b082d
--- /dev/null
+++ b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.spec.ts
@@ -0,0 +1,161 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, Input } from "@angular/core";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
+import { PinnedResultWindowComponent } from "./pinned-result-window.component";
+import { ResultTableFrameComponent } from "../../result-panel/result-table-frame/result-table-frame.component";
+import { VisualizationFrameContentComponent } from "../../visualization-panel-content/visualization-frame-content.component";
+import { OperatorResultPreviewComponent } from "../operator-result-preview/operator-result-preview.component";
+import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service";
+import {
+ OperatorPaginationResultService,
+ WorkflowResultService,
+} from "../../../service/workflow-result/workflow-result.service";
+import { OperatorPredicate, PortDescription } from "../../../types/workflow-common.interface";
+
+// Stand-ins for the frames the window holds, so the test needs none of their services.
+@Component({ selector: "texera-result-table-frame", template: "", standalone: true })
+class StubTableFrame {
+ @Input() operatorId?: string;
+ @Input() portIndex = 0;
+ @Input() containerHeight?: number;
+}
+
+@Component({ selector: "texera-visualization-panel-content", template: "", standalone: true })
+class StubChartFrame {
+ @Input() operatorId?: string;
+}
+
+@Component({ selector: "texera-operator-result-preview", template: "", standalone: true })
+class StubPreview {
+ @Input() operatorId?: string;
+}
+
+describe("PinnedResultWindowComponent", () => {
+ let fixture: ComponentFixture;
+ let operator: OperatorPredicate;
+ let hasTable: boolean;
+
+ const ports = (...names: (string | undefined)[]): PortDescription[] =>
+ names.map((displayName, i) => ({ portID: `output-${i}`, displayName }));
+
+ beforeEach(async () => {
+ operator = { operatorID: "op1", operatorType: "Split", outputPorts: ports(undefined) } as OperatorPredicate;
+ hasTable = true;
+ const graph = { hasOperator: () => true, getOperator: () => operator };
+
+ await TestBed.configureTestingModule({
+ imports: [PinnedResultWindowComponent],
+ providers: [
+ { provide: WorkflowActionService, useValue: { getTexeraGraph: () => graph } },
+ {
+ provide: WorkflowResultService,
+ useValue: {
+ getPaginatedResultService: () => (hasTable ? ({} as OperatorPaginationResultService) : undefined),
+ getResultService: () => undefined,
+ },
+ },
+ ],
+ })
+ .overrideComponent(PinnedResultWindowComponent, {
+ remove: {
+ imports: [ResultTableFrameComponent, VisualizationFrameContentComponent, OperatorResultPreviewComponent],
+ },
+ add: { imports: [StubTableFrame, StubChartFrame, StubPreview] },
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(PinnedResultWindowComponent);
+ fixture.componentRef.setInput("operatorId", "op1");
+ });
+
+ const tabs = () => fixture.debugElement.queryAll(By.css(".pinned-result__port"));
+ const table = () => fixture.debugElement.query(By.directive(StubTableFrame)).componentInstance as StubTableFrame;
+
+ it("shows no port tabs for an operator with one output", () => {
+ fixture.detectChanges();
+
+ expect(tabs().length).toBe(0);
+ expect(table().portIndex).toBe(0);
+ });
+
+ it("shows a tab for each output, named as on the canvas or by its place, the first one shown", () => {
+ operator = { ...operator, outputPorts: ports("False", "True") };
+ fixture.detectChanges();
+ expect(tabs().map(tab => (tab.nativeElement as HTMLElement).textContent?.trim())).toEqual(["False", "True"]);
+
+ operator = { ...operator, outputPorts: ports(undefined, "") };
+ fixture.detectChanges();
+ expect(tabs().map(tab => (tab.nativeElement as HTMLElement).textContent?.trim())).toEqual(["Output 1", "Output 2"]);
+ expect(tabs()[0].classes["pinned-result__port--active"]).toBe(true);
+ expect(table().portIndex).toBe(0);
+ });
+
+ it("shows the output whose tab is clicked", () => {
+ operator = { ...operator, outputPorts: ports("False", "True") };
+ fixture.detectChanges();
+
+ (tabs()[1].nativeElement as HTMLElement).click();
+ fixture.detectChanges();
+
+ expect(table().portIndex).toBe(1);
+ expect(table().operatorId).toBe("op1");
+ expect(tabs()[1].attributes["aria-selected"]).toBe("true");
+ expect(tabs()[0].attributes["aria-selected"]).toBe("false");
+ });
+
+ it("shows the last output when the chosen one is removed, and the chosen one again when it is back", () => {
+ operator = { ...operator, outputPorts: ports("Out 1", "Out 2", "Out 3") };
+ fixture.detectChanges();
+ (tabs()[2].nativeElement as HTMLElement).click();
+ fixture.detectChanges();
+
+ operator = { ...operator, outputPorts: ports("Out 1", "Out 2") };
+ fixture.detectChanges();
+ expect(table().portIndex).toBe(1);
+ expect(tabs()[1].classes["pinned-result__port--active"]).toBe(true);
+
+ operator = { ...operator, outputPorts: ports("Out 1") };
+ fixture.detectChanges();
+ expect(tabs().length).toBe(0);
+ expect(table().portIndex).toBe(0);
+
+ operator = { ...operator, outputPorts: ports("Out 1", "Out 2", "Out 3") };
+ fixture.detectChanges();
+ expect(table().portIndex).toBe(2);
+ });
+
+ it("hands the table the height its body leaves, not the result panel's", () => {
+ fixture.detectChanges();
+ fixture.componentInstance.tableHeight = 400;
+ fixture.detectChanges();
+
+ expect(table().containerHeight).toBe(400);
+ });
+
+ it("shows no port tabs when the result is not a table", () => {
+ operator = { ...operator, outputPorts: ports("False", "True") };
+ hasTable = false;
+ fixture.detectChanges();
+
+ expect(tabs().length).toBe(0);
+ });
+});
diff --git a/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.ts b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.ts
new file mode 100644
index 00000000000..879b36ae570
--- /dev/null
+++ b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.ts
@@ -0,0 +1,139 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ AfterViewInit,
+ ChangeDetectorRef,
+ Component,
+ ElementRef,
+ EventEmitter,
+ Input,
+ OnDestroy,
+ Output,
+ ViewChild,
+} from "@angular/core";
+import { NgFor, NgIf } from "@angular/common";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service";
+import { WorkflowResultService } from "../../../service/workflow-result/workflow-result.service";
+import { ResultTableFrameComponent } from "../../result-panel/result-table-frame/result-table-frame.component";
+import { VisualizationFrameContentComponent } from "../../visualization-panel-content/visualization-frame-content.component";
+import { OperatorResultPreviewComponent } from "../operator-result-preview/operator-result-preview.component";
+
+/**
+ * An operator's result pinned next to it on the canvas. It shows the whole result, the way the
+ * result panel would, and stays until it is closed. Dragging its header moves it away from the
+ * operator; the editor keeps it at that offset when the operator or the canvas moves.
+ */
+@Component({
+ selector: "texera-pinned-result-window",
+ templateUrl: "./pinned-result-window.component.html",
+ styleUrls: ["./pinned-result-window.component.scss"],
+ imports: [
+ NgIf,
+ NgFor,
+ NzIconDirective,
+ ResultTableFrameComponent,
+ VisualizationFrameContentComponent,
+ OperatorResultPreviewComponent,
+ ],
+})
+export class PinnedResultWindowComponent implements AfterViewInit, OnDestroy {
+ @Input() operatorId = "";
+ @Output() closed = new EventEmitter();
+ @Output() moved = new EventEmitter<{ dx: number; dy: number }>();
+ @ViewChild("body") bodyElement?: ElementRef;
+
+ /** The height the window's body leaves for the table, which fills it. */
+ tableHeight?: number;
+ private resizeObserver?: ResizeObserver;
+
+ constructor(
+ private workflowActionService: WorkflowActionService,
+ private workflowResultService: WorkflowResultService,
+ private changeDetectorRef: ChangeDetectorRef
+ ) {}
+
+ ngAfterViewInit(): void {
+ const element = this.bodyElement?.nativeElement;
+ if (!element || typeof ResizeObserver === "undefined") return;
+ // the user resizes the window by its corner, so the body's height is read as it changes
+ this.resizeObserver = new ResizeObserver(() => {
+ const style = getComputedStyle(element);
+ const height = element.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
+ if (height === this.tableHeight) return;
+ this.tableHeight = height;
+ this.changeDetectorRef.detectChanges();
+ });
+ this.resizeObserver.observe(element);
+ }
+
+ ngOnDestroy(): void {
+ this.resizeObserver?.disconnect();
+ }
+
+ get title(): string {
+ const graph = this.workflowActionService.getTexeraGraph();
+ if (!graph.hasOperator(this.operatorId)) return this.operatorId;
+ const operator = graph.getOperator(this.operatorId);
+ return operator.customDisplayName ?? operator.operatorType;
+ }
+
+ /** The output port chosen by its tab. */
+ selectedPort = 0;
+
+ /** The port shown: the one chosen, or the last when the chosen one is gone. */
+ get shownPort(): number {
+ return Math.min(this.selectedPort, Math.max(this.outputPorts.length - 1, 0));
+ }
+
+ /** The operator's output ports, each named as on the canvas, or by its place when unnamed. */
+ get outputPorts(): { index: number; label: string }[] {
+ const graph = this.workflowActionService.getTexeraGraph();
+ if (!graph.hasOperator(this.operatorId)) return [];
+ return graph
+ .getOperator(this.operatorId)
+ .outputPorts.map((port, index) => ({ index, label: port.displayName || `Output ${index + 1}` }));
+ }
+
+ get kind(): "table" | "chart" | "other" {
+ if (this.workflowResultService.getPaginatedResultService(this.operatorId)) return "table";
+ if (this.workflowResultService.getResultService(this.operatorId)) return "chart";
+ return "other";
+ }
+
+ /** Drag by the header. Reports how far the pointer moved, so the editor can keep the offset. */
+ startDrag(event: MouseEvent): void {
+ if ((event.target as HTMLElement).closest("button")) return;
+ event.preventDefault();
+ let lastX = event.clientX;
+ let lastY = event.clientY;
+ const move = (e: MouseEvent) => {
+ this.moved.emit({ dx: e.clientX - lastX, dy: e.clientY - lastY });
+ lastX = e.clientX;
+ lastY = e.clientY;
+ };
+ const stop = () => {
+ window.removeEventListener("mousemove", move);
+ window.removeEventListener("mouseup", stop);
+ };
+ window.addEventListener("mousemove", move);
+ window.addEventListener("mouseup", stop);
+ }
+}
diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html
index 7e777e5f48d..fc441271276 100644
--- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html
+++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html
@@ -31,10 +31,29 @@
{{ heatmapTooltip.metricLabel }}
Heat: {{ heatmapTooltip.heatLabel }}
+ 0"
+ class="pinned-result-connectors">
+
+
+
{
expect(component.resultPreview).toBeNull();
}));
+ describe("pinned result windows", () => {
+ const pinnedWindows = () => fixture.debugElement.queryAll(By.css("texera-pinned-result-window"));
+
+ /** Open the scan operator's card and pin it, as its Pin button does. */
+ function pinScan(): void {
+ const view = component.paper.findViewByModel(mockScanPredicate.operatorID);
+ component.paper.trigger("element:mouseenter", view, new MouseEvent("mouseenter"));
+ tick(RESULT_PREVIEW_DELAY_MS);
+ component.pinResultPreview();
+ fixture.detectChanges();
+ }
+
+ /** The scan operator's box as drawn, in the editor's coordinates. */
+ function scanBox(): { left: number; right: number; top: number; bottom: number } {
+ const bbox = component.paper.getModelById(mockScanPredicate.operatorID).getBBox();
+ const { sx, sy } = component.paper.scale();
+ const { tx, ty } = component.paper.translate();
+ return {
+ left: bbox.x * sx + tx,
+ right: (bbox.x + bbox.width) * sx + tx,
+ top: bbox.y * sy + ty,
+ bottom: (bbox.y + bbox.height) * sy + ty,
+ };
+ }
+
+ it("pins the card's operator as a window and closes the card, which that operator no longer opens", fakeAsync(() => {
+ addLinkedPair();
+ pinScan();
+
+ expect(component.resultPreview).toBeNull();
+ expect(component.pinnedResults).toEqual([{ operatorId: mockScanPredicate.operatorID, dx: 0, dy: 0 }]);
+ expect(pinnedWindows().length).toBe(1);
+ expect(pinnedWindows()[0].attributes["data-operator-id"]).toBe(mockScanPredicate.operatorID);
+
+ const view = component.paper.findViewByModel(mockScanPredicate.operatorID);
+ component.paper.trigger("element:mouseenter", view, new MouseEvent("mouseenter"));
+ tick(RESULT_PREVIEW_DELAY_MS);
+ expect(component.resultPreview).toBeNull();
+ }));
+
+ it("unpins a window when it is closed", fakeAsync(() => {
+ addLinkedPair();
+ pinScan();
+
+ (pinnedWindows()[0].componentInstance as PinnedResultWindowComponent).closed.emit();
+ fixture.detectChanges();
+
+ expect(component.pinnedResults).toEqual([]);
+ expect(pinnedWindows().length).toBe(0);
+ }));
+
+ it("unpins the window of a deleted operator, but not another's", fakeAsync(() => {
+ addLinkedPair();
+ pinScan();
+ const workflowActionService = TestBed.inject(WorkflowActionService);
+
+ workflowActionService.deleteOperator(mockResultPredicate.operatorID);
+ expect(component.pinnedResults.map(pinned => pinned.operatorId)).toEqual([mockScanPredicate.operatorID]);
+
+ workflowActionService.deleteOperator(mockScanPredicate.operatorID);
+ expect(component.pinnedResults).toEqual([]);
+ expect(pinnedWindows().length).toBe(0);
+ }));
+
+ it("places a window just right of its operator, level with its top, and keeps it there as the canvas moves", fakeAsync(() => {
+ addLinkedPair();
+ pinScan();
+ const pinned = component.pinnedResults[0];
+
+ let box = scanBox();
+ expect(component.pinnedResultPosition(pinned)).toEqual({ x: box.right + 12, y: box.top });
+
+ component.paper.translate(40, -25);
+ component.paper.scale(0.5, 0.5);
+ box = scanBox();
+ expect(component.pinnedResultPosition(pinned)).toEqual({ x: box.right + 12, y: box.top });
+ }));
+
+ it("keeps a dragged window at the offset it was dragged to", fakeAsync(() => {
+ addLinkedPair();
+ pinScan();
+ const box = scanBox();
+
+ component.movePinnedResult(mockScanPredicate.operatorID, { dx: 30, dy: 10 });
+ component.movePinnedResult(mockScanPredicate.operatorID, { dx: -5, dy: 15 });
+ component.movePinnedResult("another-operator", { dx: 100, dy: 100 });
+
+ expect(component.pinnedResults[0]).toEqual({ operatorId: mockScanPredicate.operatorID, dx: 25, dy: 25 });
+ expect(component.pinnedResultPosition(component.pinnedResults[0])).toEqual({
+ x: box.right + 12 + 25,
+ y: box.top + 25,
+ });
+ }));
+
+ describe("the line from an operator to its window", () => {
+ const WIDTH = 300;
+ const HEIGHT = 200;
+
+ // a window's size is read from the page, which lays nothing out in a test
+ beforeEach(() =>
+ vi.spyOn(component as any, "pinnedWindowSize").mockReturnValue({ width: WIDTH, height: HEIGHT })
+ );
+
+ /** The path's start and end points. */
+ function ends(path: string): number[][] {
+ const numbers = path.match(/-?[\d.]+/g)!.map(Number);
+ return [numbers.slice(0, 2), numbers.slice(-2)];
+ }
+
+ it("runs from the operator's right side into the header of a window on its right", fakeAsync(() => {
+ addLinkedPair();
+ pinScan();
+ const box = scanBox();
+ const corner = component.pinnedResultPosition(component.pinnedResults[0]);
+
+ expect(ends(component.pinnedConnectorPath(component.pinnedResults[0]))).toEqual([
+ [box.right, (box.top + box.bottom) / 2],
+ [corner.x, corner.y + PINNED_HEADER_HEIGHT / 2],
+ ]);
+ }));
+
+ it("runs from the operator's left side into the header of a window dragged to its left", fakeAsync(() => {
+ addLinkedPair();
+ pinScan();
+ const box = scanBox();
+ component.movePinnedResult(mockScanPredicate.operatorID, {
+ dx: -(box.right - box.left) - WIDTH - 100,
+ dy: 0,
+ });
+ const corner = component.pinnedResultPosition(component.pinnedResults[0]);
+
+ expect(ends(component.pinnedConnectorPath(component.pinnedResults[0]))).toEqual([
+ [box.left, (box.top + box.bottom) / 2],
+ [corner.x + WIDTH, corner.y + PINNED_HEADER_HEIGHT / 2],
+ ]);
+ }));
+
+ it("runs from the operator's bottom to the top of a window dragged below it, and top to bottom above", fakeAsync(() => {
+ addLinkedPair();
+ pinScan();
+ const box = scanBox();
+ const midX = (box.left + box.right) / 2;
+ // under the operator: the window's left edge left of the operator's middle
+ component.movePinnedResult(mockScanPredicate.operatorID, { dx: -(box.right - box.left), dy: 0 });
+ component.movePinnedResult(mockScanPredicate.operatorID, { dx: 0, dy: box.bottom - box.top + 50 });
+ let corner = component.pinnedResultPosition(component.pinnedResults[0]);
+ expect(ends(component.pinnedConnectorPath(component.pinnedResults[0]))).toEqual([
+ [midX, box.bottom],
+ [midX, corner.y],
+ ]);
+
+ component.movePinnedResult(mockScanPredicate.operatorID, {
+ dx: 0,
+ dy: -(box.bottom - box.top + 50) - HEIGHT - 80,
+ });
+ corner = component.pinnedResultPosition(component.pinnedResults[0]);
+ expect(ends(component.pinnedConnectorPath(component.pinnedResults[0]))).toEqual([
+ [midX, box.top],
+ [midX, corner.y + HEIGHT],
+ ]);
+ }));
+ });
+ });
+
/** Select the scan operator the way a click does and return its model, whose attrs say what is on show. */
function selectScanOperator(): joint.dia.Cell {
addLinkedPair();
diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
index 023f2624da8..90a7027f59c 100644
--- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
+++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
@@ -52,10 +52,11 @@ import concaveman from "concaveman";
import { OperatorResultSummary, AgentService } from "../../service/agent/agent.service";
import { NzNoAnimationDirective } from "ng-zorro-antd/core/animation";
import { ContextMenuComponent } from "./context-menu/context-menu/context-menu.component";
-import { NgIf } from "@angular/common";
+import { NgFor, NgIf } from "@angular/common";
import { AgentInteractionComponent } from "../agent/agent-interaction/agent-interaction.component";
import { HeatmapLegendComponent } from "../heatmap-legend/heatmap-legend.component";
import { OperatorResultPreviewComponent } from "./operator-result-preview/operator-result-preview.component";
+import { PinnedResultWindowComponent } from "./pinned-result-window/pinned-result-window.component";
import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service";
// jointjs interactive options for enabling and disabling interactivity
@@ -83,6 +84,8 @@ export const MAIN_CANVAS = {
export const RESULT_PREVIEW_DELAY_MS = 500;
/** How long the card stays after the pointer leaves, so the pointer can move into it. */
export const RESULT_PREVIEW_HIDE_MS = 200;
+/** The height of a pinned window's header, where the line from its operator comes in. */
+export const PINNED_HEADER_HEIGHT = 30;
/** Whether a mouse button is down. JointJS hands over a jQuery event, which keeps the DOM one inside. */
function isButtonHeld(evt: unknown): boolean {
@@ -116,6 +119,8 @@ function isButtonHeld(evt: unknown): boolean {
AgentInteractionComponent,
HeatmapLegendComponent,
OperatorResultPreviewComponent,
+ PinnedResultWindowComponent,
+ NgFor,
],
})
export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy {
@@ -134,6 +139,8 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
} | null = null;
// The hovered operator's result preview, and where to show it. Null when hidden.
public resultPreview: { operatorId: string; x: number; y: number } | null = null;
+ // Results pinned beside their operators, each with how far it was dragged from its spot.
+ public pinnedResults: { operatorId: string; dx: number; dy: number }[] = [];
private previewShowTimer?: ReturnType;
// The operator previewShowTimer opens the card for.
private previewPendingOperatorId?: string;
@@ -614,8 +621,9 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
}
/**
- * Shows the hovered operator's result preview beside it. Off when Layers > Result preview is
- * unchecked, and while the heat-map overlay is on, whose own tooltip answers the hover then.
+ * Shows the hovered operator's result preview beside it; its Pin button keeps the whole result
+ * there. Off when Layers > Result preview is unchecked, and while the heat-map overlay is on,
+ * whose own tooltip answers the hover then.
*/
private handleResultPreviewHover(): void {
fromJointPaperEvent(this.paper, "element:mouseenter")
@@ -628,7 +636,11 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
}
const operatorId = elementView.model.id.toString();
// no preview while a button is held: the pointer is dragging an operator or a link
- if (!this.workflowActionService.getTexeraGraph().hasOperator(operatorId) || isButtonHeld(evt)) {
+ if (
+ !this.workflowActionService.getTexeraGraph().hasOperator(operatorId) ||
+ isButtonHeld(evt) ||
+ this.pinnedResults.some(pinned => pinned.operatorId === operatorId)
+ ) {
return;
}
// Only a pointer that rests on the operator opens the card, so passing over one does not.
@@ -658,16 +670,27 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
this.changeDetectorRef.detectChanges();
});
- // A deleted operator takes its card along, and a card still waiting to open for it.
+ // Pinned windows sit beside their operators, so they move when an operator or the canvas does.
+ merge(
+ fromJointPaperEvent(this.paper, "translate"),
+ fromJointPaperEvent(this.paper, "scale"),
+ fromEvent(this.paper.model as any, "change:position")
+ )
+ .pipe(untilDestroyed(this))
+ .subscribe(() => {
+ if (this.pinnedResults.length > 0) this.changeDetectorRef.detectChanges();
+ });
+
+ // A deleted operator takes its window and its card along, and a card still waiting to open for it.
this.workflowActionService
.getTexeraGraph()
.getOperatorDeleteStream()
.pipe(untilDestroyed(this))
.subscribe(({ deletedOperatorID }) => {
if (this.previewPendingOperatorId === deletedOperatorID) clearTimeout(this.previewShowTimer);
- if (this.resultPreview?.operatorId !== deletedOperatorID) return;
- this.resultPreview = null;
- this.changeDetectorRef.detectChanges();
+ if (this.resultPreview?.operatorId === deletedOperatorID) this.resultPreview = null;
+ // detects changes for both
+ this.unpinResult(deletedOperatorID);
});
}
@@ -685,6 +708,78 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
clearTimeout(this.previewHideTimer);
}
+ public pinResultPreview(): void {
+ if (this.resultPreview === null) return;
+ this.pinnedResults = [...this.pinnedResults, { operatorId: this.resultPreview.operatorId, dx: 0, dy: 0 }];
+ this.resultPreview = null;
+ }
+
+ public unpinResult(operatorId: string): void {
+ this.pinnedResults = this.pinnedResults.filter(pinned => pinned.operatorId !== operatorId);
+ this.changeDetectorRef.detectChanges();
+ }
+
+ // A window is its operator's, so a move keeps the window being dragged instead of rebuilding it.
+ public trackPinnedResult(_index: number, pinned: { operatorId: string }): string {
+ return pinned.operatorId;
+ }
+
+ public movePinnedResult(operatorId: string, delta: { dx: number; dy: number }): void {
+ this.pinnedResults = this.pinnedResults.map(pinned =>
+ pinned.operatorId === operatorId ? { ...pinned, dx: pinned.dx + delta.dx, dy: pinned.dy + delta.dy } : pinned
+ );
+ }
+
+ /** Where a pinned window goes: beside its operator, plus however far it was dragged from there. */
+ public pinnedResultPosition(pinned: { operatorId: string; dx: number; dy: number }): { x: number; y: number } {
+ const anchor = this.operatorAnchor(pinned.operatorId);
+ return { x: anchor.x + pinned.dx, y: anchor.y + pinned.dy };
+ }
+
+ /**
+ * The line from an operator to its pinned window, a curve like the links between operators.
+ * Beside the operator, it runs side to side into the window's header; above or below, it runs
+ * from the operator's top or bottom to the window's nearest edge.
+ */
+ public pinnedConnectorPath(pinned: { operatorId: string; dx: number; dy: number }): string {
+ const cell = this.paper.getModelById(pinned.operatorId);
+ if (!cell) return "";
+ const bbox = cell.getBBox();
+ const scale = this.paper.scale();
+ const translate = this.paper.translate();
+ const op = {
+ left: bbox.x * scale.sx + translate.tx,
+ right: (bbox.x + bbox.width) * scale.sx + translate.tx,
+ top: bbox.y * scale.sy + translate.ty,
+ bottom: (bbox.y + bbox.height) * scale.sy + translate.ty,
+ };
+ const midX = (op.left + op.right) / 2;
+ const midY = (op.top + op.bottom) / 2;
+
+ const corner = this.pinnedResultPosition(pinned);
+ const size = this.pinnedWindowSize(pinned.operatorId);
+ const win = { left: corner.x, right: corner.x + size.width, top: corner.y, bottom: corner.y + size.height };
+ const headerY = win.top + PINNED_HEADER_HEIGHT / 2;
+
+ const curve = (x1: number, y1: number, x2: number, y2: number, horizontal: boolean) => {
+ const bend = Math.max(30, (horizontal ? Math.abs(x2 - x1) : Math.abs(y2 - y1)) / 2);
+ const [bx, by] = horizontal ? [Math.sign(x2 - x1) * bend, 0] : [0, Math.sign(y2 - y1) * bend];
+ return `M ${x1} ${y1} C ${x1 + bx} ${y1 + by}, ${x2 - bx} ${y2 - by}, ${x2} ${y2}`;
+ };
+ if (win.left >= op.right) return curve(op.right, midY, win.left, headerY, true);
+ if (win.right <= op.left) return curve(op.left, midY, win.right, headerY, true);
+ // the window overlaps the operator's column: join the facing edges, at the point under the operator
+ const x = Math.min(Math.max(midX, win.left + 12), win.right - 12);
+ return win.top >= op.bottom ? curve(midX, op.bottom, x, win.top, false) : curve(midX, op.top, x, win.bottom, false);
+ }
+
+ /** A pinned window's size as drawn: the user can resize it, so it is read from the page. */
+ private pinnedWindowSize(operatorId: string): { width: number; height: number } {
+ const host = this.elementRef.nativeElement as HTMLElement;
+ const box = host.querySelector(`[data-operator-id="${operatorId}"] .pinned-result`);
+ return { width: box?.offsetWidth ?? 0, height: box?.offsetHeight ?? 0 };
+ }
+
/** Just right of the operator's box, level with its top, in the editor's coordinates. */
private operatorAnchor(operatorId: string): { x: number; y: number } {
const cell = this.paper.getModelById(operatorId);