diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
index 5326030d78a..2d2e6bd4a5e 100644
--- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
+++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
@@ -161,6 +161,38 @@ describe("OperatorPropertyEditFrameComponent", () => {
expect(component).toBeTruthy();
});
+ it("broadcasts currentlyEditing and syncs the operator version by default when an operator opens", () => {
+ workflowActionService.addOperator(mockScanPredicate, mockPoint);
+ const spy = vi.spyOn(workflowActionService.getTexeraGraph(), "updateSharedModelAwareness");
+ const versionSpy = vi.spyOn(workflowActionService, "setOperatorVersion");
+
+ component.ngOnChanges({
+ currentOperatorId: new SimpleChange(undefined, mockScanPredicate.operatorID, true),
+ });
+ fixture.detectChanges();
+
+ expect(spy).toHaveBeenCalledWith("currentlyEditing", mockScanPredicate.operatorID);
+ expect(versionSpy).toHaveBeenCalledWith(mockScanPredicate.operatorID, expect.anything());
+ });
+
+ it("neither broadcasts nor mutates the operator version when broadcastEditing is false (read-only inspect)", () => {
+ // The Form View mounts this frame with broadcastEditing=false so a reader inspecting a step is
+ // not announced as editing the graph, and opening the step does not write the operator version
+ // into the shared model.
+ workflowActionService.addOperator(mockScanPredicate, mockPoint);
+ component.broadcastEditing = false;
+ const spy = vi.spyOn(workflowActionService.getTexeraGraph(), "updateSharedModelAwareness");
+ const versionSpy = vi.spyOn(workflowActionService, "setOperatorVersion");
+
+ component.ngOnChanges({
+ currentOperatorId: new SimpleChange(undefined, mockScanPredicate.operatorID, true),
+ });
+ fixture.detectChanges();
+
+ expect(spy).not.toHaveBeenCalledWith("currentlyEditing", mockScanPredicate.operatorID);
+ expect(versionSpy).not.toHaveBeenCalled();
+ });
+
/**
* test if the property editor correctly receives the operator highlight stream,
* get the operator data (id, property, and metadata), and then display the form.
diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
index be354825fa0..d3fa940ee05 100644
--- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
+++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
@@ -178,6 +178,11 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
/** True while an author is choosing which properties appear on the Form View; adds a tick
* box beside each. Off, the property editor is unchanged. */
@Input() exposeChoosing = false;
+ /** Whether opening an operator may write to the shared workflow: the "currently editing" co-editor
+ * broadcast AND the operator-version sync (both are shared-model writes). True on the operator
+ * canvas; the Form View sets it false to inspect a step read-only, so opening one neither shows
+ * the reader as a co-editor nor mutates the operator's version. */
+ @Input() broadcastEditing = true;
currentOperatorSchema?: OperatorSchema;
@@ -631,10 +636,19 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
this.currentOperatorSchema = this.dynamicSchemaService.getDynamicSchema(this.currentOperatorId);
this.currentOperatorStatus = this.workflowStatusSerivce.getCurrentStatus()[this.currentOperatorId];
- this.workflowActionService.getTexeraGraph().updateSharedModelAwareness("currentlyEditing", this.currentOperatorId);
+ if (this.broadcastEditing) {
+ this.workflowActionService
+ .getTexeraGraph()
+ .updateSharedModelAwareness("currentlyEditing", this.currentOperatorId);
+ }
const operator = this.workflowActionService.getTexeraGraph().getOperator(this.currentOperatorId);
- // set the operator data needed
- this.workflowActionService.setOperatorVersion(operator.operatorID, this.currentOperatorSchema.operatorVersion);
+ // Syncing the operator to the current schema version writes the new version into the Yjs shared
+ // model (changeOperatorVersion), which broadcasts and persists. That is right on the canvas, but
+ // a read-only inspect (broadcastEditing=false) must not mutate the workflow just by opening a
+ // step, so skip the sync there and show the version as stored.
+ if (this.broadcastEditing) {
+ this.workflowActionService.setOperatorVersion(operator.operatorID, this.currentOperatorSchema.operatorVersion);
+ }
this.operatorVersion = operator.operatorVersion.slice(0, 9);
this.setFormlyFormBinding(this.currentOperatorSchema.jsonSchema);
this.formTitle = operator.customDisplayName ?? this.currentOperatorSchema.additionalMetadata.userFriendlyName;
diff --git a/frontend/src/app/workspace/component/property-editor/property-editor.component.spec.ts b/frontend/src/app/workspace/component/property-editor/property-editor.component.spec.ts
index 67ea6d0e7d9..68e36e5fe8c 100644
--- a/frontend/src/app/workspace/component/property-editor/property-editor.component.spec.ts
+++ b/frontend/src/app/workspace/component/property-editor/property-editor.component.spec.ts
@@ -81,6 +81,34 @@ describe("PropertyEditorComponent", () => {
expect(component).toBeTruthy();
});
+ // The Form View mounts this panel with persistPlacement=false. It must not persist the docked
+ // canvas panel's geometry -- it is not that panel, and writing these keys would overwrite the
+ // real one's saved size. (The ngOnInit restore is guarded by the same flag; its canvas path is
+ // exercised by the default fixture above.)
+ it("does not persist the docked panel geometry when persistPlacement is false", () => {
+ component.persistPlacement = false;
+ const setItem = vi.spyOn(Storage.prototype, "setItem");
+
+ component.ngOnDestroy();
+
+ expect(setItem).not.toHaveBeenCalledWith("right-panel-width", expect.anything());
+ expect(setItem).not.toHaveBeenCalledWith("right-panel-style", expect.anything());
+ });
+
+ // The crash this flag fixes: ngOnInit reads #right-container to restore the docked panel's
+ // placement, and that element only exists in the canvas layout. The Form View mounts the panel
+ // with persistPlacement=false, where the element is absent -- reading it there would throw. With
+ // the flag off, ngOnInit must not go near it. Re-run ngOnInit on the existing instance (no second
+ // fixture, which would pollute TestBed) with the flag off and assert the lookup never happens.
+ it("does not read #right-container in ngOnInit when persistPlacement is false", () => {
+ component.persistPlacement = false;
+ const getById = vi.spyOn(document, "getElementById");
+
+ expect(() => component.ngOnInit()).not.toThrow();
+
+ expect(getById).not.toHaveBeenCalledWith("right-container");
+ });
+
/**
* test if the property editor correctly receives the operator unhighlight stream
* and clears all the operator data, and hide the form.
@@ -98,6 +126,7 @@ describe("PropertyEditorComponent", () => {
expect(component.componentInputs).toEqual({
currentOperatorId: mockScanPredicate.operatorID,
exposeChoosing: false,
+ broadcastEditing: true,
});
// unhighlight the operator
@@ -148,6 +177,7 @@ describe("PropertyEditorComponent", () => {
expect(component.componentInputs).toEqual({
currentOperatorId: mockScanPredicate.operatorID,
exposeChoosing: false,
+ broadcastEditing: true,
});
// unhighlight the operator
@@ -164,6 +194,7 @@ describe("PropertyEditorComponent", () => {
expect(component.componentInputs).toEqual({
currentOperatorId: mockResultPredicate.operatorID,
exposeChoosing: false,
+ broadcastEditing: true,
});
});
diff --git a/frontend/src/app/workspace/component/property-editor/property-editor.component.ts b/frontend/src/app/workspace/component/property-editor/property-editor.component.ts
index e6006926043..26d66845c89 100644
--- a/frontend/src/app/workspace/component/property-editor/property-editor.component.ts
+++ b/frontend/src/app/workspace/component/property-editor/property-editor.component.ts
@@ -92,6 +92,24 @@ export class PropertyEditorComponent implements OnInit, OnDestroy, OnChanges {
* Forwarded to the operator frame, which puts a tick box beside each property.
*/
@Input() exposeChoosing = false;
+ /**
+ * Whether this panel owns the docked canvas panel's saved size/position. The Form View mounts
+ * this same component read-only inside a preview box, where the canvas layout (#right-container)
+ * does not exist; with `false` it neither restores nor persists that shared placement, so it
+ * cannot crash on the missing element and cannot overwrite the canvas panel's geometry. Default
+ * `true` keeps the operator canvas exactly as it was.
+ */
+ @Input() persistPlacement = true;
+ /**
+ * Whether opening an operator here broadcasts "currently editing this operator" on the shared
+ * co-editor channel. On the operator canvas that is right (a co-editor should see who is editing
+ * what). The Form View opens the panel only to inspect a step read-only, so it mounts with
+ * `false`: a reader is not editing the graph, and broadcasting would print the reader's own name
+ * in colour over that operator on everyone else's canvas. Default `true` keeps the canvas as it
+ * was. Forwarded to the operator frame, which owns the actual writes (the editing broadcast and
+ * the operator-version sync).
+ */
+ @Input() broadcastEditing = true;
/** Set from the toolbar toggle on the operator canvas; the input covers the form view. */
private choosingFromToolbar = false;
@@ -137,11 +155,16 @@ export class PropertyEditorComponent implements OnInit, OnDestroy, OnChanges {
}
ngOnInit(): void {
- const style = localStorage.getItem("right-panel-style");
- if (style) document.getElementById("right-container")!.style.cssText = style;
- const translates = document.getElementById("right-container")!.style.transform;
- const [xOffset, yOffset, _] = calculateTotalTranslate3d(translates);
- this.returnPosition = { x: -xOffset, y: -yOffset };
+ // Restoring the docked panel's saved placement reads #right-container, which only exists in the
+ // canvas layout. The Form View mounts this panel with persistPlacement=false, where that element
+ // is absent, so skip the restore there (it would throw on the missing element).
+ if (this.persistPlacement) {
+ const style = localStorage.getItem("right-panel-style");
+ if (style) document.getElementById("right-container")!.style.cssText = style;
+ const translates = document.getElementById("right-container")!.style.transform;
+ const [xOffset, yOffset, _] = calculateTotalTranslate3d(translates);
+ this.returnPosition = { x: -xOffset, y: -yOffset };
+ }
this.registerHighlightEventsHandler();
// The toolbar's "choose fields" toggle lives in the service so both the canvas toolbar
// and this panel see the same state. Re-emit the frame's inputs when it changes, so tick
@@ -205,6 +228,11 @@ export class PropertyEditorComponent implements OnInit, OnDestroy, OnChanges {
@HostListener("window:beforeunload")
ngOnDestroy(): void {
+ // The Form View's read-only copy (persistPlacement=false) must not persist geometry: it is not
+ // the docked canvas panel, so writing these keys would overwrite the real panel's saved size.
+ if (!this.persistPlacement) {
+ return;
+ }
localStorage.setItem("right-panel-width", String(this.width));
localStorage.setItem("right-panel-height", String(this.height));
@@ -248,7 +276,11 @@ export class PropertyEditorComponent implements OnInit, OnDestroy, OnChanges {
if (highlightedOperators.length === 1 && highlightLinks.length === 0 && highlightedPorts.length === 0) {
this.currentComponent = OperatorPropertyEditFrameComponent;
- this.componentInputs = { currentOperatorId: highlightedOperators[0], exposeChoosing: this.choosing };
+ this.componentInputs = {
+ currentOperatorId: highlightedOperators[0],
+ exposeChoosing: this.choosing,
+ broadcastEditing: this.broadcastEditing,
+ };
} else if (highlightedPorts.length === 1 && highlightLinks.length === 0) {
this.currentComponent = PortPropertyEditFrameComponent;
this.componentInputs = { currentPortID: highlightedPorts[0] };
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 c924532d055..2d19de54554 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
@@ -70,6 +70,39 @@
+ {{ runError || "Running -- this keeps going if you look away." }}
+
+
+
@@ -139,7 +221,111 @@
+
+
+
+
+
+
+
+
+
+
+
+
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 54e7ffd7bc5..4490bb50e4d 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
@@ -190,6 +190,82 @@ $shell: #fafafa;
background: #fff;
}
+/* ---------- instruction ---------- */
+
+.instr {
+ margin-bottom: 26px;
+
+ .instr-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 13px 16px;
+ cursor: pointer;
+ user-select: none;
+ appearance: none;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+
+ h2 {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+ flex: 1;
+ }
+
+ .lead {
+ color: $blue;
+ }
+
+ .chev {
+ color: $text-2;
+ transform: rotate(-90deg);
+ transition: transform 0.2s;
+ }
+ }
+
+ &.open .instr-bar .chev {
+ transform: rotate(0deg);
+ }
+
+ .instr-body {
+ border-top: 1px solid $divider;
+ padding: 16px;
+ /* A long explanation scrolls rather than pushing the form off screen, and can be dragged
+ taller by anyone who wants to read it all at once. */
+ max-height: 340px;
+ overflow-y: auto;
+ resize: vertical;
+ }
+}
+
+.md {
+ :first-child {
+ margin-top: 0;
+ }
+
+ :last-child {
+ margin-bottom: 0;
+ }
+
+ img {
+ max-width: 100%;
+ border: 1px solid $divider;
+ border-radius: 6px;
+ }
+
+ code {
+ background: $divider;
+ padding: 1px 6px;
+ border-radius: 4px;
+ font-size: 12.5px;
+ }
+}
+
/* ---------- inputs ---------- */
.pc-section-head {
@@ -311,6 +387,103 @@ $shell: #fafafa;
}
}
+/* ---------- run ---------- */
+
+.runbar {
+ display: flex;
+ align-items: center;
+ margin-top: 26px;
+}
+
+/* Run and its computing unit as one control. The seam between them is what says the two belong
+ together. */
+.run-group {
+ display: inline-flex;
+ align-items: center;
+ gap: 12px;
+
+ /* Quiet beside Run: it is something you glance at, not a control. Tabular figures stop the row
+ twitching as the seconds tick over. */
+ .run-clock {
+ align-self: center;
+ margin-left: 4px;
+ color: #5c6672;
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.02em;
+ }
+
+ .run-unit {
+ display: flex;
+ align-items: center;
+
+ /* The selector reserves 220px and right-aligns inside it, which on the toolbar keeps a row of
+ controls steady but here left a wide gap between Run and a box that belongs next to it. Let
+ it be as wide as its content. */
+ ::ng-deep .computing-units-selection {
+ min-width: 0;
+ }
+
+ /* Matched to Run's height so the pair reads as one row of controls. */
+ ::ng-deep .computing-units-dropdown-button {
+ height: 40px;
+ }
+ }
+}
+
+.run {
+ height: 40px;
+ // Fixed width, content centred, so the button is the same size in every state (Run / Stop /
+ // Connect / Connecting / Invalid / Empty) instead of shrinking to "Run" and jumping wide for the
+ // others. Sized to the longest label ("Connecting"); the disabled states are kept to one word so
+ // this stays compact.
+ min-width: 160px;
+ padding: 0 26px;
+ font-size: 15px;
+ font-weight: 600;
+ border-radius: 8px;
+ border: 1px solid $blue;
+ background: $blue;
+ color: #fff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 9px;
+ cursor: pointer;
+
+ &:hover {
+ background: #40a9ff;
+ border-color: #40a9ff;
+ }
+
+ &.stop {
+ background: #fff;
+ color: #ff4d4f;
+ border-color: #ff4d4f;
+
+ &:hover {
+ background: #fff1f0;
+ }
+ }
+
+ &:disabled {
+ background: $shell;
+ border-color: $border;
+ color: rgba(0, 0, 0, 0.25);
+ cursor: not-allowed;
+ }
+}
+
+.run-note {
+ font-size: 13px;
+ color: $text-2;
+ margin: 9px 0 0;
+
+ &.err {
+ color: #ff4d4f;
+ }
+}
+
/* ---------- workflow preview ---------- */
.wf {
@@ -395,3 +568,214 @@ $shell: #fafafa;
}
}
}
+
+/* ---------- inspect a step (read-only property panel over the preview) ---------- */
+
+/* Sibling of the panel, pinned to the same corner and above it (see the html note). */
+.panel-close {
+ position: absolute;
+ top: 18px;
+ right: 20px;
+ z-index: 20;
+ width: 24px;
+ height: 24px;
+ border: 0;
+ border-radius: 4px;
+ background: none;
+ color: rgba(0, 0, 0, 0.45);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:hover {
+ background: #f5f5f5;
+ color: rgba(0, 0, 0, 0.85);
+ }
+
+ &:focus-visible {
+ outline: 2px solid $blue;
+ outline-offset: 1px;
+ }
+}
+
+/* The property panel floats over the preview so it costs no layout space and the workflow keeps the
+ full width. It is the scroll container (so a long panel is still readable); the property editor
+ inside is `inert`, which is what makes it read-only for pointer AND keyboard. Its own styling is
+ left alone -- it should look exactly like the panel on the operator canvas. */
+.panel {
+ position: absolute;
+ right: 10px;
+ top: 10px;
+ bottom: 10px;
+ width: 300px;
+ z-index: 5;
+ background: #fff;
+ border: 1px solid $border;
+ border-radius: 8px;
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
+ overflow: auto;
+ display: flex;
+
+ /* Unpin the editor: on the operator canvas it fixes itself to the window edge, which inside this
+ box would put it off-screen. Here it simply fills the panel. */
+ texera-property-editor {
+ display: block;
+ flex: 1;
+ position: static;
+ overflow: visible;
+ }
+
+ /* The panel's own minimise/reset controls belong to the docked canvas panel; here the panel is
+ embedded and sized by this page, so they would only misbehave. */
+ ::ng-deep #property-buttons,
+ ::ng-deep #docked-buttons {
+ display: none !important;
+ }
+
+ /* The canvas panel can be dragged and resized; inside this preview it is simply the right-hand
+ pane -- fixed, full height, dismissed with its close button or by clicking empty canvas. */
+ ::ng-deep #right-container,
+ ::ng-deep #right-container-legacy {
+ position: static !important;
+ transform: none !important;
+ width: 100% !important;
+ height: 100% !important;
+ max-height: none !important;
+ resize: none !important;
+ box-shadow: none !important;
+ cursor: default !important;
+ }
+
+ ::ng-deep nz-resize-handles,
+ ::ng-deep .nz-resizable-handle {
+ display: none !important;
+ }
+}
+
+/* ---------- 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 543eb940057..5ba57ebec71 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
@@ -17,7 +17,7 @@
* under the License.
*/
-import { FormControl } from "@angular/forms";
+import { FormArray, FormControl, FormGroup, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { of, throwError } from "rxjs";
@@ -26,6 +26,8 @@ import { setupHarness, formViewWorkflow, resolved } from "./workflow-form.spec-h
import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant";
import { DefaultView } from "../../../dashboard/type/workflow-metadata.interface";
import { FORM_DEBOUNCE_TIME_MS } from "../../service/execute-workflow/execute-workflow.service";
+import { ExecutionState } from "../../types/execute-workflow.interface";
+import { ComputingUnitState } from "../../../common/type/computing-unit-connection.interface";
/**
* These exercise the page's own decisions -- what a reader is shown, where an ordinary
@@ -56,14 +58,18 @@ describe("WorkflowFormComponent", () => {
h.workflowResultService as any,
h.notificationService as any,
h.userService as any,
+ h.markdownService as any,
h.formlyJsonschema as any,
h.cdr as any,
h.dynamicSchemaService as any,
h.workflowCompilingService as any,
h.computingUnitStatusService as any,
h.workflowConsoleService as any,
+ h.workflowWebsocketService as any,
h.host as any,
h.datePipe as any,
+ h.panelResizeService as any,
+ h.validationWorkflowService as any,
h.config as any
);
return component;
@@ -882,4 +888,544 @@ describe("WorkflowFormComponent", () => {
document.body.removeChild(editable);
});
});
+
+ describe("the author's instruction", () => {
+ it("shows the instruction as rendered markdown when there is one", async () => {
+ formBindingService.getConfig.mockReturnValue({
+ instruction: { title: "Read me", body: "**bold**" },
+ fields: [],
+ resultOperatorIds: [],
+ });
+ build(formViewWorkflow).ngOnInit();
+ // renderInstruction resolves the parsed markdown on a microtask; let it settle.
+ await Promise.resolve();
+
+ expect(component.hasInstruction).toBe(true);
+ expect(component.instructionTitle).toBe("Read me");
+ // The markdown mock returns its input; the point is renderInstruction populated the html.
+ expect(component.instructionPreviewHtml).toBe("**bold**");
+ });
+
+ it("has no instruction when the body is blank", async () => {
+ formBindingService.getConfig.mockReturnValue({
+ instruction: { title: "T", body: " " },
+ fields: [],
+ resultOperatorIds: [],
+ });
+ build(formViewWorkflow).ngOnInit();
+ await Promise.resolve();
+
+ expect(component.hasInstruction).toBe(false);
+ expect(component.instructionPreviewHtml).toBe("");
+ });
+
+ it("discards a stale instruction render when the body changed while parsing", async () => {
+ build(formViewWorkflow).ngOnInit();
+ (component as any).instructionBody = "first";
+ const pending = (component as any).renderInstruction();
+ // A newer readConfig sets a different body before the parse microtask resolves.
+ (component as any).instructionBody = "second";
+ await pending;
+
+ // The stale "first" result is dropped rather than overwriting the newer body's render.
+ expect(component.instructionPreviewHtml).not.toBe("first");
+ });
+
+ it("toggles the instruction open and closed", () => {
+ build(formViewWorkflow).ngOnInit();
+ expect(component.instructionOpen).toBe(true);
+
+ component.toggleInstruction();
+
+ expect(component.instructionOpen).toBe(false);
+ });
+ });
+
+ describe("the run button, mirroring the operator canvas", () => {
+ // Put the page in a ready-to-run state: a WRITE-access unit is up, the socket is connected, the
+ // graph valid.
+ const makeReady = () => {
+ h.workflowWebsocketService.isConnected = true;
+ h.statusStream.next(ComputingUnitState.Running);
+ (component as any).selectedUnit = { accessPrivilege: "WRITE" };
+ h.validationStream.next({ errors: {}, workflowEmpty: false });
+ };
+
+ it("stores the selected unit from the status service so Run can gate on write access", () => {
+ build(formViewWorkflow).ngOnInit();
+
+ h.selectedUnitStream.next({ accessPrivilege: "WRITE" });
+
+ expect((component as any).selectedUnit).toEqual({ accessPrivilege: "WRITE" });
+ });
+
+ it("offers Connect before a unit is chosen", () => {
+ build(formViewWorkflow).ngOnInit();
+
+ expect(component.runButtonState).toEqual({ label: "Connect", icon: "plus-circle", disabled: true });
+ });
+
+ it("offers Run once a unit is up and the graph is valid", () => {
+ build(formViewWorkflow).ngOnInit();
+ makeReady();
+
+ expect(component.runButtonState.label).toBe("Run");
+ expect(component.runButtonState.disabled).toBe(false);
+ });
+
+ it("shows Stop while running", () => {
+ build(formViewWorkflow).ngOnInit();
+ h.executionStateStream.next({ current: { state: ExecutionState.Running } });
+
+ expect(component.isRunning).toBe(true);
+ expect(component.runButtonState).toEqual({ label: "Stop", icon: "stop", disabled: false });
+ });
+
+ it("disables and says Invalid for a broken graph", () => {
+ build(formViewWorkflow).ngOnInit();
+ makeReady();
+ h.validationStream.next({ errors: { op: {} }, workflowEmpty: false });
+
+ expect(component.runButtonState).toEqual({ label: "Invalid", icon: "warning", disabled: true });
+ });
+
+ it("disables and says Empty for an empty graph", () => {
+ build(formViewWorkflow).ngOnInit();
+ makeReady();
+ h.validationStream.next({ errors: {}, workflowEmpty: true });
+
+ expect(component.runButtonState).toEqual({ label: "Empty", icon: "info-circle", disabled: true });
+ });
+
+ it("disables and says Connecting while the unit's socket comes up", () => {
+ build(formViewWorkflow).ngOnInit();
+ h.statusStream.next(ComputingUnitState.Running);
+ h.validationStream.next({ errors: {}, workflowEmpty: false });
+ h.workflowWebsocketService.isConnected = false;
+
+ expect(component.runButtonState).toEqual({ label: "Connecting", icon: "loading", disabled: true });
+ });
+
+ it("disables with No access when the chosen unit is shared read-only", () => {
+ build(formViewWorkflow).ngOnInit();
+ h.workflowWebsocketService.isConnected = true;
+ h.statusStream.next(ComputingUnitState.Running);
+ h.validationStream.next({ errors: {}, workflowEmpty: false });
+ (component as any).selectedUnit = { accessPrivilege: "READ" };
+
+ expect(component.runButtonState).toEqual({ label: "No access", icon: "lock", disabled: true });
+ });
+
+ it("does not offer a dead Stop when the socket drops mid-run", () => {
+ build(formViewWorkflow).ngOnInit();
+ h.statusStream.next(ComputingUnitState.Running); // a unit is selected
+ h.executionStateStream.next({ current: { state: ExecutionState.Running } }); // a run is in flight
+ h.workflowWebsocketService.isConnected = false; // its socket drops
+
+ // Still "running", but the button must not offer a Stop that would kill through a dead socket.
+ expect(component.isRunning).toBe(true);
+ expect(component.runButtonState).toEqual({ label: "Connecting", icon: "loading", disabled: true });
+ });
+
+ it("repaints when the websocket connection status changes", () => {
+ build(formViewWorkflow).ngOnInit();
+ h.cdr.markForCheck.mockClear();
+
+ h.connectionStream.next(true);
+
+ expect(h.cdr.markForCheck).toHaveBeenCalled();
+ });
+ });
+
+ describe("running", () => {
+ const makeReady = () => {
+ h.workflowWebsocketService.isConnected = true;
+ h.statusStream.next(ComputingUnitState.Running);
+ (component as any).selectedUnit = { accessPrivilege: "WRITE" };
+ h.validationStream.next({ errors: {}, workflowEmpty: false });
+ };
+
+ it("runs the workflow with its name and clears any prior error", () => {
+ build(formViewWorkflow).ngOnInit();
+ makeReady();
+ component.runError = "old error";
+
+ component.onRun();
+
+ expect(h.executeWorkflowService.executeWorkflow).toHaveBeenCalledWith("scGPT");
+ 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 } });
+
+ component.onRun();
+
+ expect(h.executeWorkflowService.killWorkflow).toHaveBeenCalled();
+ expect(h.executeWorkflowService.executeWorkflow).not.toHaveBeenCalled();
+ });
+
+ it("does nothing when the button is disabled", () => {
+ build(formViewWorkflow).ngOnInit();
+ // Default state is "Connect" (disabled): no unit chosen.
+
+ component.onRun();
+
+ expect(h.executeWorkflowService.executeWorkflow).not.toHaveBeenCalled();
+ expect(h.executeWorkflowService.killWorkflow).not.toHaveBeenCalled();
+ });
+
+ it("counts the run clock off the engine's duration event", () => {
+ build(formViewWorkflow).ngOnInit();
+
+ h.durationEvents.next({ duration: 5000, isRunning: false });
+
+ expect(component.executionDuration).toBe(5000);
+ });
+
+ it("ticks the clock a second at a time while a run is going", () => {
+ vi.useFakeTimers();
+ build(formViewWorkflow).ngOnInit();
+
+ h.durationEvents.next({ duration: 1000, isRunning: true });
+ vi.advanceTimersByTime(1000);
+ vi.useRealTimers();
+
+ expect(component.executionDuration).toBe(2000);
+ });
+ });
+
+ 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();
+ const form = new FormGroup({ v: new FormControl("", Validators.required) });
+ component.rendered = [{ form } as any];
+
+ h.executionStateStream.next({ current: { state: ExecutionState.Failed, errorMessages: [{ message: "x" }] } });
+
+ expect(component.runError).toBe("Run failed: please fill in the required fields.");
+ });
+
+ it("finds a required error nested inside an array input", () => {
+ build(formViewWorkflow).ngOnInit();
+ const form = new FormGroup({ arr: new FormArray([new FormControl("", Validators.required)]) });
+ component.rendered = [{ form } as any];
+
+ h.executionStateStream.next({ current: { state: ExecutionState.Failed, errorMessages: [{ message: "x" }] } });
+
+ expect(component.runError).toBe("Run failed: please fill in the required fields.");
+ });
+
+ it("does not blame required fields for a non-required validation error", () => {
+ build(formViewWorkflow).ngOnInit();
+ // A pattern failure, not an empty required field: the reader gets the engine message, not
+ // "fill in the required fields".
+ const form = new FormGroup({ v: new FormControl("abc", Validators.pattern(/^\d+$/)) });
+ component.rendered = [{ form } as any];
+
+ h.executionStateStream.next({ current: { state: ExecutionState.Failed, errorMessages: [{ message: "boom" }] } });
+
+ expect(component.runError).toBe("Run failed: boom");
+ });
+
+ it("keeps a short human message, dropping the exception prefix", () => {
+ build(formViewWorkflow).ngOnInit();
+
+ h.executionStateStream.next({
+ current: {
+ state: ExecutionState.Failed,
+ errorMessages: [{ message: "java.lang.RuntimeException: too many rows" }],
+ },
+ });
+
+ expect(component.runError).toBe("Run failed: too many rows");
+ });
+
+ it("collapses an opaque engine trace to a reload sentence", () => {
+ build(formViewWorkflow).ngOnInit();
+
+ h.executionStateStream.next({
+ current: {
+ state: ExecutionState.Failed,
+ errorMessages: [{ message: "org.jooq.DataAccessException: SQL [..]" }],
+ },
+ });
+
+ expect(component.runError).toBe("Run failed -- please reload and try again.");
+ });
+
+ it("collapses an empty error message to the reload sentence too", () => {
+ build(formViewWorkflow).ngOnInit();
+
+ h.executionStateStream.next({ current: { state: ExecutionState.Failed, errorMessages: [] } });
+
+ expect(component.runError).toBe("Run failed -- please reload and try again.");
+ });
+
+ it("gives a generic tail when the message cleans down to nothing", () => {
+ build(formViewWorkflow).ngOnInit();
+
+ h.executionStateStream.next({
+ current: { state: ExecutionState.Failed, errorMessages: [{ message: "requirement failed: " }] },
+ });
+
+ expect(component.runError).toBe("Run failed: please check your inputs and try again.");
+ });
+ });
+
+ describe("inspecting a step read-only", () => {
+ const withOp = () => {
+ h.hasOperatorIds.add("op-1");
+ h.graphOperators.push({ operatorID: "op-1", operatorType: "Filter" });
+ };
+
+ // Model a highlight the way the real graph does: the stream emits only the newly-highlighted
+ // ids (the delta), while getCurrentHighlightedOperatorIDs returns the whole selection. So set
+ // the full selection first, then emit the delta.
+ const highlight = (full: string[], delta: string[] = full) => {
+ h.highlightedIds.length = 0;
+ h.highlightedIds.push(...full);
+ h.highlightStream.next(delta);
+ };
+
+ it("turns highlighting on so a click selects a step", () => {
+ build(formViewWorkflow).ngOnInit();
+ expect(workflowActionService.setHighlightingEnabled).toHaveBeenCalledWith(true);
+ });
+
+ it("opens the read-only panel for the clicked step", () => {
+ build(formViewWorkflow).ngOnInit();
+ withOp();
+
+ highlight(["op-1"]);
+
+ expect(component.selectedOperatorId).toBe("op-1");
+ });
+
+ it("never broadcasts editing itself: silence is delegated to the panel (broadcastEditing=false)", () => {
+ build(formViewWorkflow).ngOnInit();
+ withOp();
+
+ highlight(["op-1"]);
+
+ // The form component does not touch the co-editor channel at all; the panel is mounted with
+ // [broadcastEditing]="false", which suppresses the broadcast at the frame (the only writer).
+ // The frame's suppression is covered in operator-property-edit-frame.component.spec.ts.
+ expect(h.updateSharedModelAwareness).not.toHaveBeenCalled();
+ });
+
+ it("clears the selection when the clicked step is not on the graph", () => {
+ build(formViewWorkflow).ngOnInit();
+ (component as any).selectedOperatorId = "old";
+
+ highlight(["ghost"]);
+
+ expect(component.selectedOperatorId).toBeUndefined();
+ });
+
+ it("closes the panel when the canvas clears its highlight", () => {
+ build(formViewWorkflow).ngOnInit();
+ withOp();
+ highlight(["op-1"]);
+
+ h.highlightedIds.length = 0; // nothing highlighted any more
+ h.unhighlightStream.next([]);
+
+ expect(component.selectedOperatorId).toBeUndefined();
+ });
+
+ it("keeps the panel while another step is still highlighted", () => {
+ build(formViewWorkflow).ngOnInit();
+ withOp();
+ highlight(["op-1"]);
+ h.highlightedIds.push("op-2"); // a second step is still highlighted (no new emit)
+
+ h.unhighlightStream.next(["op-1"]);
+
+ expect(component.selectedOperatorId).toBe("op-1");
+ });
+
+ it("dismisses the panel via the close button, dropping the highlight", () => {
+ build(formViewWorkflow).ngOnInit();
+ withOp();
+ highlight(["op-1"]);
+
+ component.closeOperatorPanel();
+
+ expect(h.unhighlightOperators).toHaveBeenCalledWith("op-1");
+ expect(component.selectedOperatorId).toBeUndefined();
+ });
+
+ it("ignores a multi-select highlight, closing the panel (no single step to show)", () => {
+ build(formViewWorkflow).ngOnInit();
+ withOp();
+ highlight(["op-1"]);
+ expect(component.selectedOperatorId).toBe("op-1");
+
+ // Shift-clicking a second step: the stream emits only the new id, but the full selection is
+ // now two, so the panel closes rather than opening whichever was clicked last.
+ highlight(["op-1", "op-2"], ["op-2"]);
+
+ expect(component.selectedOperatorId).toBeUndefined();
+ });
+ });
});
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 260f9c23031..0538eb3c6e7 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
@@ -19,21 +19,26 @@
import { ChangeDetectorRef, Component, ElementRef, HostListener, OnDestroy, OnInit } from "@angular/core";
import { CommonModule, DatePipe } from "@angular/common";
-import { FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms";
+import { AbstractControl, FormArray, FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { FormlyFieldConfig, FormlyModule } from "@ngx-formly/core";
import { FormlyJsonschema } from "@ngx-formly/core/json-schema";
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 { forkJoin, Subject } from "rxjs";
-import { debounceTime, takeUntil } from "rxjs/operators";
+import { MarkdownService } from "ngx-markdown";
+import { EMPTY, forkJoin, Subject, timer } from "rxjs";
+import { debounceTime, switchMap, takeUntil, tap } from "rxjs/operators";
import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant";
import { FormFieldBinding, Workflow, WorkflowContent } from "../../../common/type/workflow";
import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
+import { ComputingUnitState } from "../../../common/type/computing-unit-connection.interface";
+import { DashboardWorkflowComputingUnit } from "../../../common/type/workflow-computing-unit";
import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service";
import { NotificationService } from "../../../common/service/notification/notification.service";
import { UserService } from "../../../common/service/user/user.service";
@@ -44,10 +49,18 @@ import { ExecuteWorkflowService, FORM_DEBOUNCE_TIME_MS } from "../../service/exe
import { OperatorMetadataService } from "../../service/operator-metadata/operator-metadata.service";
import { FormBindingService, ResolvedField } from "../../service/form-binding/form-binding.service";
import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service";
+import { ValidationWorkflowService } from "../../service/validation/validation-workflow.service";
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 { PropertyEditorComponent } from "../property-editor/property-editor.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";
@@ -72,8 +85,14 @@ interface RenderedField {
* operator's own formly field, so a file property gets the real picker and an attribute a column
* dropdown -- and writes a filled-in value straight back to its operator, the same edit the canvas
* makes, with each sub-field of a nested or repeated property renamed and hidden as the author set
- * it up. Running the workflow and showing results are added by later PRs. A view, not a new object:
- * it opens the same workflow the canvas does.
+ * 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. 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. A reader can also
+ * click a step on the embedded preview to open its property panel read-only (inert). The authoring
+ * mode that turns that panel live and picks what to show is a later PR. A view, not
+ * a new object: it opens the same workflow the canvas does.
*/
@UntilDestroy()
@Component({
@@ -87,7 +106,13 @@ interface RenderedField {
FormlyModule,
NzAvatarModule,
NzIconModule,
+ NzButtonModule,
+ NzTooltipModule,
UserIconComponent,
+ ComputingUnitSelectionComponent,
+ PropertyEditorComponent,
+ ResultTableFrameComponent,
+ VisualizationFrameContentComponent,
WorkflowEditorComponent,
MiniMapComponent,
CoeditorUserIconComponent,
@@ -108,6 +133,49 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
/** Torn down and replaced whenever the form is rebuilt, so an old field's write-back stops. */
private formsRebuilt = new Subject();
+ /** The author's instruction, rendered above the inputs; the reader always sees it as markdown. */
+ public instructionOpen = true;
+ public instructionTitle = "";
+ public instructionBody = "";
+ public instructionPreviewHtml = "";
+
+ /**
+ * Milliseconds the current run has been going, counted from the same engine event the operator
+ * canvas counts: the engine reports the real elapsed time, and a local 1s timer fills in between
+ * reports so the display ticks instead of jumping.
+ */
+ public executionDuration = 0;
+ public executionState: ExecutionState = ExecutionState.Uninitialized;
+ public runError = "";
+ /** The picked unit's connection state, mirrored from the same stream the operator canvas reads,
+ * so "Connecting" here means exactly what it means there. */
+ public computingUnitStatus: ComputingUnitState = ComputingUnitState.NoComputingUnit;
+ /** The picked unit itself, kept so Run can be gated on write access to it -- the same gate the
+ * operator canvas applies (a READ/NONE-shared unit can be viewed but not executed on). */
+ private selectedUnit: DashboardWorkflowComputingUnit | null = null;
+ /** Workflow validity, read from the same validation stream the operator canvas uses, so Run is
+ * disabled ("Invalid" / "Empty") in the same cases. */
+ public isWorkflowValid = true;
+ public isWorkflowEmpty = false;
+
+ /** The step whose property panel is open for read-only inspection, if any. The panel shows the
+ * operator's own title, so this id is all the page needs to track. */
+ public selectedOperatorId?: string;
+
+ /**
+ * 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. */
@@ -137,6 +205,7 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
private workflowResultService: WorkflowResultService,
private notificationService: NotificationService,
private userService: UserService,
+ private markdownService: MarkdownService,
private formlyJsonschema: FormlyJsonschema,
private cdr: ChangeDetectorRef,
// Injected for its side effect: it fills its map from the operator-add stream, so it has to
@@ -150,8 +219,16 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
private workflowCompilingService: WorkflowCompilingService,
private computingUnitStatusService: ComputingUnitStatusService,
private workflowConsoleService: WorkflowConsoleService,
+ 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,
private config: GuiConfigService
) {}
@@ -162,8 +239,163 @@ 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);
+ // Highlighting is off by default; turning it on is what makes a click on a step select it,
+ // which is how a reader opens that step's panel to inspect it (and, later, an author to expose).
+ this.workflowActionService.setHighlightingEnabled(true);
this.load(wid);
+ // Selecting a step on the embedded (read-only) canvas opens its property panel read-only. The
+ // canvas is not editable, but highlighting still works, so reuse it rather than teach the editor
+ // a second click mode.
+ this.workflowActionService
+ .getJointGraphWrapper()
+ .getJointOperatorHighlightStream()
+ .pipe(untilDestroyed(this))
+ .subscribe(() => {
+ // The stream emits only the newly-highlighted ids, not the whole selection, so read the
+ // current selection to decide -- the same source the property panel uses. Exactly one
+ // highlighted step opens the panel; a shift-click multi-select opens nothing (the panel
+ // shows no single operator either), rather than opening whichever step was clicked last.
+ const selected = this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs();
+ if (selected.length === 1) {
+ this.onOperatorClicked(selected[0]);
+ } else {
+ this.clearSelection();
+ }
+ // The panel is mounted with [broadcastEditing]="false", so opening a step here never
+ // announces "currently editing this operator" on the shared co-editor channel -- a reader
+ // inspecting a step is not editing the graph, and broadcasting would print the reader's own
+ // name in colour over that operator on everyone else's canvas. Suppressed at the frame (the
+ // only place that writes it), not here, so it cannot be re-set after this handler runs.
+ });
+
+ // Clicking empty canvas clears the highlight; the panel should go with it.
+ this.workflowActionService
+ .getJointGraphWrapper()
+ .getJointOperatorUnhighlightStream()
+ .pipe(untilDestroyed(this))
+ .subscribe(() => {
+ if (this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs().length === 0) {
+ this.clearSelection();
+ this.cdr.detectChanges();
+ }
+ });
+
+ // 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.
+ this.workflowWebsocketService
+ .subscribeToEvent("ExecutionDurationUpdateEvent")
+ .pipe(
+ tap(event => (this.executionDuration = event.duration)),
+ switchMap(event => (event.isRunning ? timer(1000, 1000) : EMPTY)),
+ untilDestroyed(this)
+ )
+ .subscribe(() => {
+ this.executionDuration += 1000;
+ this.cdr.markForCheck();
+ });
+
+ // The run button's state is read from getters, so a change in unit/connection/validity has to
+ // repaint the view. markForCheck, not detectChanges: a synchronous pass can be thrown out of by
+ // an unrelated component's NG0100, killing the subscription.
+ this.computingUnitStatusService
+ .getSelectedComputingUnit()
+ .pipe(untilDestroyed(this))
+ .subscribe(unit => {
+ this.selectedUnit = unit;
+ this.cdr.markForCheck();
+ });
+ this.computingUnitStatusService
+ .getStatus()
+ .pipe(untilDestroyed(this))
+ .subscribe(status => {
+ this.computingUnitStatus = status;
+ this.cdr.markForCheck();
+ });
+ this.workflowWebsocketService
+ .getConnectionStatusStream()
+ .pipe(untilDestroyed(this))
+ .subscribe(() => this.cdr.markForCheck());
+ // Validity from the canvas's own stream, so a broken graph disables Run ("Invalid") here
+ // exactly as it does there.
+ this.validationWorkflowService
+ .getWorkflowValidationErrorStream()
+ .pipe(untilDestroyed(this))
+ .subscribe(value => {
+ this.isWorkflowEmpty = value.workflowEmpty;
+ this.isWorkflowValid = Object.keys(value.errors).length === 0;
+ this.cdr.markForCheck();
+ });
+
+ this.executeWorkflowService
+ .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.
+ 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
+ // same word the field itself already shows ("required"), so the two messages are
+ // consistent -- and it covers every operator, not just this one.
+ this.runError = this.hasEmptyRequiredInputs()
+ ? "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
+ // shown; marking dirty and letting the next pass render avoids that.
+ this.cdr.markForCheck();
+ });
+
// Attribute boxes become dropdowns only after compilation writes the column enums into each
// operator's dynamic schema -- which lands after these cards were built. Rebuild on the
// compilation-state stream, a ReplaySubject(1) so a late subscriber (this page reloads fresh
@@ -261,10 +493,27 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
}
private readConfig(): void {
+ const config = this.formBindingService.getConfig();
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();
}
+ /**
+ * Limit the shown results to the author's chosen operators that STILL have view-result on in the
+ * canvas. This is a pure display filter: it reads the canvas's view-result set and never writes
+ * it, so a normal canvas user's result-viewing is unaffected. A chosen operator whose view-result
+ * was turned off (or that was deleted) simply drops out here rather than rendering a stale card.
+ */
+ private refreshShownResults(): void {
+ const viewed = this.workflowActionService.getTexeraGraph().getOperatorsToViewResult();
+ this.shownResultIds = this.formBindingService.getConfig().resultOperatorIds.filter(id => viewed.has(id));
+ }
+
/**
* 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.
@@ -511,6 +760,337 @@ 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
+ // ---------------------------------------------------------------------------
+
+ public get hasInstruction(): boolean {
+ return this.instructionBody.trim().length > 0;
+ }
+
+ private async renderInstruction(): Promise {
+ // Capture the body this render is for: parsing can resolve on a later microtask, and a fresh
+ // readConfig() may start another render meanwhile. If the configured body changed while we were
+ // parsing, this result is stale -- drop it so the newer render's output stands.
+ const body = this.instructionBody;
+ const html = body.trim() ? await Promise.resolve(this.markdownService.parse(body)) : "";
+ if (body !== this.instructionBody) {
+ return;
+ }
+ this.instructionPreviewHtml = html;
+ this.cdr.detectChanges();
+ }
+
+ public toggleInstruction(): void {
+ this.instructionOpen = !this.instructionOpen;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Running the same workflow the canvas runs, through the same execute/kill service. The canvas
+ // wraps its run with completion-email options (executeWorkflowWithEmailNotification); this page
+ // runs plainly (executeWorkflow), so a form-started run does not send that email.
+ // ---------------------------------------------------------------------------
+
+ public get isRunning(): boolean {
+ return (
+ this.executionState !== ExecutionState.Uninitialized &&
+ this.executionState !== ExecutionState.Completed &&
+ this.executionState !== ExecutionState.Failed &&
+ this.executionState !== ExecutionState.Killed &&
+ this.executionState !== ExecutionState.Terminated
+ );
+ }
+
+ /**
+ * 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
+ * (menu.component's getRunButtonBehavior), so the two stay in step.
+ */
+ public get isConnecting(): boolean {
+ return (
+ this.computingUnitStatus !== ComputingUnitState.NoComputingUnit && !this.workflowWebsocketService.isConnected
+ );
+ }
+
+ /** No unit chosen yet: the button shows a disabled "Connect" hint and the unit is picked in the
+ * embedded selector -- unlike the canvas, where the Connect button is itself the click target. */
+ public get hasNoComputingUnit(): boolean {
+ return this.computingUnitStatus === ComputingUnitState.NoComputingUnit;
+ }
+
+ /** Write access to the chosen unit: the canvas gates execution on this (a READ/NONE-shared unit
+ * can be selected and viewed but not run on), so the form must too, or a reader could execute on
+ * a unit they only have read access to. */
+ public get hasUnitWriteAccess(): boolean {
+ return this.selectedUnit?.accessPrivilege === "WRITE";
+ }
+
+ /**
+ * The Run button's label, icon and disabled state. It shares the operator canvas's disable
+ * conditions -- an invalid or empty workflow, a unit still connecting, or no unit chosen each
+ * disable it and say why -- but deliberately simplifies the execution states a reader needs down
+ * to Run and Stop, with no pause/resume: while a run is in flight the button stops (kills) it,
+ * otherwise it runs. (The canvas offers Pause/Resume/Submitting and a clickable Connect; a form
+ * reader does not, and picks the unit in the embedded selector instead.)
+ */
+ public get runButtonState(): { label: string; icon: string; disabled: boolean } {
+ // Connecting is checked before Stop on purpose: if the socket drops mid-run, a "Stop" would
+ // send killWorkflow() through a dead socket and do nothing, so a disconnected unit disables the
+ // button (as the canvas does) rather than offering a kill that cannot be delivered.
+ if (this.isConnecting) {
+ return { label: "Connecting", icon: "loading", disabled: true };
+ }
+ if (this.isRunning) {
+ return { label: "Stop", icon: "stop", disabled: false };
+ }
+ if (!this.isWorkflowValid) {
+ return { label: "Invalid", icon: "warning", disabled: true };
+ }
+ if (this.isWorkflowEmpty) {
+ return { label: "Empty", icon: "info-circle", disabled: true };
+ }
+ if (this.hasNoComputingUnit) {
+ return { label: "Connect", icon: "plus-circle", disabled: true };
+ }
+ // A unit is chosen and connected, but shared to this reader read-only: the canvas gates
+ // execution on write access to the unit, so the form disables Run rather than sending a request
+ // that the unit would reject.
+ if (!this.hasUnitWriteAccess) {
+ return { label: "No access", icon: "lock", disabled: true };
+ }
+ return { label: "Run", icon: "caret-right", disabled: false };
+ }
+
+ public onRun(): void {
+ if (this.isRunning) {
+ this.executeWorkflowService.killWorkflow();
+ return;
+ }
+ // The button is disabled in exactly the states a run cannot start from (invalid/empty workflow,
+ // connecting, or no unit), so a stray call here would be a silent no-op.
+ if (this.runButtonState.disabled) {
+ return;
+ }
+ this.runError = "";
+ // Run as-is, like the canvas -- no client-side "fill everything first" gate (it diverged from
+ // the canvas and could not guarantee success anyway). Empty/invalid inputs surface as a real
+ // engine error via the execution-state stream (see the Failed handler).
+ this.executeWorkflowService.executeWorkflow(this.workflowName);
+ }
+
+ /**
+ * Turn an engine error into something a reader can act on: raw SQL/jOOQ/Java traces collapse to
+ * one plain sentence, a short human message is kept (minus any Java prefix). The full text is
+ * always logged for developers.
+ */
+ private friendlyRunError(raw: string): string {
+ if (raw) {
+ // eslint-disable-next-line no-console
+ console.error("[workflow-form] run failed:", raw);
+ }
+ const opaque =
+ !raw || /\bSQL \[|org\.jooq|org\.apache|org\.postgresql|foreign key|constraint|jdbc|\bat [\w.$]+\(/i.test(raw);
+ if (opaque) {
+ return "Run failed -- please reload and try again.";
+ }
+ const cleaned = raw
+ .replace(/^[\w.$]+(?:Exception|Error):\s*/, "")
+ .replace(/^requirement failed:\s*/i, "")
+ .trim();
+ return `Run failed: ${cleaned || "please check your inputs and try again."}`;
+ }
+
+ /**
+ * Whether any exposed input that is required is still empty. Reuses formly's own per-field
+ * required validation -- the very thing that renders "This field is required" under the box -- so
+ * the run-failure message stays consistent with the field hint.
+ */
+ private hasEmptyRequiredInputs(): boolean {
+ // Specifically a `required` error (a required field left empty), not just any invalid control:
+ // a pattern or range failure is a different problem and should not be answered with "fill in the
+ // required fields". Walk the control tree for a real required error.
+ const hasRequiredError = (control: AbstractControl): boolean => {
+ if (control.hasError("required")) {
+ return true;
+ }
+ if (control instanceof FormGroup) {
+ return Object.values(control.controls).some(hasRequiredError);
+ }
+ if (control instanceof FormArray) {
+ return control.controls.some(hasRequiredError);
+ }
+ return false;
+ };
+ return this.rendered.some(r => hasRequiredError(r.form));
+ }
+
+ // ---------------------------------------------------------------------------
+ // Inspecting a step: its property panel, opened read-only from the preview
+ // ---------------------------------------------------------------------------
+
+ /** Clicking a step opens the workflow's own property panel for it, read-only. */
+ public onOperatorClicked(operatorID: string): void {
+ const graph = this.workflowActionService.getTexeraGraph();
+ if (!graph.hasOperator(operatorID)) {
+ this.clearSelection();
+ return;
+ }
+ this.selectedOperatorId = operatorID;
+ this.cdr.detectChanges();
+ }
+
+ /** Dismiss the panel: the selection is what holds it open, so drop the highlight and the selection. */
+ public closeOperatorPanel(): void {
+ const wrapper = this.workflowActionService.getJointGraphWrapper();
+ wrapper.unhighlightOperators(...wrapper.getCurrentHighlightedOperatorIDs());
+ this.clearSelection();
+ this.cdr.detectChanges();
+ }
+
+ /** No operator selected: this is what closes the property panel. */
+ private clearSelection(): void {
+ this.selectedOperatorId = undefined;
+ }
+
/** Open or close the workflow preview; opening it builds the canvas the first time. */
public toggleWorkflow(): void {
this.workflowOpen = !this.workflowOpen;
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 d6e6a4cb93a..c2a3916d2aa 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
@@ -18,11 +18,25 @@
*/
import { DatePipe } from "@angular/common";
+import { Component, Input } from "@angular/core";
import { FormGroup } from "@angular/forms";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, Router } from "@angular/router";
import { FormlyForm, FormlyModule } from "@ngx-formly/core";
import { FormlyJsonschema } from "@ngx-formly/core/json-schema";
+import { NZ_ICONS } from "ng-zorro-antd/icon";
+import {
+ InfoCircleOutline,
+ DownOutline,
+ PlusCircleOutline,
+ CaretRightOutline,
+ StopOutline,
+ WarningOutline,
+ LoadingOutline,
+ LockOutline,
+ MinusOutline,
+ PlusOutline,
+} from "@ant-design/icons-angular/icons";
import { EMPTY, of, Subject } from "rxjs";
import { WorkflowFormComponent } from "./workflow-form.component";
@@ -39,8 +53,22 @@ import { ExecuteWorkflowService } from "../../service/execute-workflow/execute-w
import { WorkflowResultService } from "../../service/workflow-result/workflow-result.service";
import { NotificationService } from "../../../common/service/notification/notification.service";
import { UserService } from "../../../common/service/user/user.service";
+import { MarkdownService } from "ngx-markdown";
import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
import { WorkflowConsoleService } from "../../service/workflow-console/workflow-console.service";
+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 { PropertyEditorComponent } from "../property-editor/property-editor.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";
+import { WorkflowPveService } from "../../service/virtual-environment/virtual-environment.service";
+import { NzModalService } from "ng-zorro-antd/modal";
+import { ExecutionState } from "../../types/execute-workflow.interface";
import { GuiConfigService } from "../../../common/service/gui-config.service";
/**
@@ -49,6 +77,19 @@ import { GuiConfigService } from "../../../common/service/gui-config.service";
* name/avatar row, the Canvas switch actually firing, the loading/body swap, and the co-editor
* row -- which is the review's evidence of the rendered page in place of a screenshot.
*/
+// A stand-in for the always-mounted property panel. The real one is heavy -- its ngOnInit
+// subscribes to the full JointJS highlight-stream set and the panel service -- and it has its
+// own spec. This page only needs the panel present (it lives behind [hidden], not *ngIf, so it
+// is mounted from the start to catch the highlight that opens it), so swap in a stub carrying the
+// two inputs the template binds and nothing else. The swap is on a child of the page, so the
+// page's own template still renders as shipped and stays covered.
+@Component({ selector: "texera-property-editor", template: "", standalone: true })
+class MockPropertyEditorComponent {
+ @Input() exposeChoosing = false;
+ @Input() persistPlacement = true;
+ @Input() broadcastEditing = true;
+}
+
describe("WorkflowFormComponent (rendered template)", () => {
let fixture: ComponentFixture;
let workflow$: Subject;
@@ -71,7 +112,25 @@ describe("WorkflowFormComponent (rendered template)", () => {
// the page's own inputs markup -- the section head, the empty state, the card and the form
// wrapper -- rendered and covered.
TestBed.overrideComponent(FormlyForm, { set: { template: "" } });
+ // Blank the computing-unit selector's own template (a child, not the page): its real markup
+ // needs a modal/executions/PVE service chain out of scope here. Blanking the child -- rather
+ // 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 */
+ // Swap the real property panel (a heavy child with its own spec) for the stub above. Done by
+ // replacing it in the page's imports rather than blanking its template, because the panel's
+ // trouble is its ngOnInit -- the highlight-stream and panel-service subscriptions -- which a
+ // blanked template still runs; a stub component has neither.
+ TestBed.overrideComponent(WorkflowFormComponent, {
+ remove: { imports: [PropertyEditorComponent] },
+ add: { imports: [MockPropertyEditorComponent] },
+ });
await TestBed.configureTestingModule({
// forRoot registers the FormlyConfig the form builder needs: the page imports FormlyModule
@@ -100,10 +159,21 @@ describe("WorkflowFormComponent (rendered template)", () => {
workflowChanged: () => EMPTY,
workflowMetaDataChanged: () => EMPTY,
formBindingChanged$: EMPTY,
+ setHighlightingEnabled: vi.fn(),
getTexeraGraph: () => ({
triggerCenterEvent: vi.fn(),
hasOperator: () => false,
getOperator: () => undefined,
+ getAllOperators: () => [],
+ getOperatorsToViewResult: () => new Set(),
+ getViewResultOperatorsChangedStream: () => EMPTY,
+ updateSharedModelAwareness: vi.fn(),
+ }),
+ getJointGraphWrapper: () => ({
+ getJointOperatorHighlightStream: () => EMPTY,
+ getJointOperatorUnhighlightStream: () => EMPTY,
+ getCurrentHighlightedOperatorIDs: () => [],
+ unhighlightOperators: vi.fn(),
}),
},
},
@@ -118,7 +188,17 @@ describe("WorkflowFormComponent (rendered template)", () => {
{ provide: OperatorMetadataService, useValue: { getOperatorMetadata: () => of({}) } },
{
provide: FormBindingService,
- useValue: { resolveFields: () => [], readValue: () => undefined, writeValue: vi.fn() },
+ useValue: {
+ // An instruction so the instruction card renders and is covered.
+ getConfig: () => ({
+ instruction: { title: "How to use this", body: "Fill in the inputs." },
+ fields: [],
+ resultOperatorIds: [],
+ }),
+ resolveFields: () => [],
+ readValue: () => undefined,
+ writeValue: vi.fn(),
+ },
},
{ provide: FormlyJsonschema, useValue: { toFieldConfig: () => ({ fieldGroup: [] }) } },
{ provide: DynamicSchemaService, useValue: { getDynamicSchema: () => ({ jsonSchema: {} }) } },
@@ -126,13 +206,71 @@ describe("WorkflowFormComponent (rendered template)", () => {
provide: WorkflowCompilingService,
useValue: { getCompilationStateInfoChangedStream: () => EMPTY },
},
- { provide: ExecuteWorkflowService, useValue: { resetExecutionAndWorkers: vi.fn() } },
- { provide: WorkflowResultService, useValue: { clearResults: vi.fn() } },
+ {
+ provide: ExecuteWorkflowService,
+ useValue: {
+ getExecutionStateStream: () => EMPTY,
+ executeWorkflow: vi.fn(),
+ killWorkflow: vi.fn(),
+ resetExecutionAndWorkers: 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: ComputingUnitStatusService, useValue: { disconnect: vi.fn() } },
+ { provide: MarkdownService, useValue: { parse: (s: string) => s } },
+ {
+ provide: ComputingUnitStatusService,
+ useValue: {
+ disconnect: vi.fn(),
+ getSelectedComputingUnit: () => EMPTY,
+ getStatus: () => EMPTY,
+ // Read by the (blanked) computing-unit selector's own ngOnInit.
+ getAllComputingUnits: () => EMPTY,
+ },
+ },
+ // The blanked computing-unit selector still constructs and runs ngOnInit; give it the few
+ // services it reads so it does not throw. It renders nothing (its template is blanked).
+ { provide: WorkflowComputingUnitManagingService, useValue: { getComputingUnitLimitOptions: () => EMPTY } },
+ { provide: WorkflowExecutionsService, useValue: {} },
+ { provide: ComputingUnitActionsService, useValue: {} },
+ { provide: WorkflowPveService, useValue: {} },
+ { provide: NzModalService, useValue: {} },
{ provide: WorkflowConsoleService, useValue: { clearConsoleMessages: vi.fn() } },
+ {
+ provide: WorkflowWebsocketService,
+ useValue: { subscribeToEvent: () => EMPTY, isConnected: true, getConnectionStatusStream: () => EMPTY },
+ },
+ { provide: ValidationWorkflowService, useValue: { getWorkflowValidationErrorStream: () => EMPTY } },
{ provide: GuiConfigService, useValue: { env: { formViewEnabled: true } } },
+ // Register the icons the run bar and instruction use, so nz-icon renders them inline instead
+ // of fetching each SVG over HTTP (an unresolved fetch that would hang fixture.whenStable).
+ {
+ provide: NZ_ICONS,
+ useValue: [
+ InfoCircleOutline,
+ DownOutline,
+ PlusCircleOutline,
+ CaretRightOutline,
+ StopOutline,
+ WarningOutline,
+ LoadingOutline,
+ LockOutline,
+ MinusOutline,
+ PlusOutline,
+ ],
+ },
DatePipe,
],
}).compileComponents();
@@ -253,6 +391,87 @@ describe("WorkflowFormComponent (rendered template)", () => {
expect(el(".param.read-only")).not.toBeNull();
});
+ it("renders the author's instruction card and toggles it", async () => {
+ fixture.detectChanges();
+ finishLoad();
+ // renderInstruction resolves the markdown on a microtask.
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(el(".card.instr")).not.toBeNull();
+ expect(el(".instr .instr-bar h2")?.textContent?.trim()).toBe("How to use this");
+ expect(el(".instr .md")?.innerHTML).toContain("Fill in the inputs.");
+
+ (el(".instr-bar") as HTMLButtonElement).click();
+ expect(fixture.componentInstance.instructionOpen).toBe(false);
+ });
+
+ it("renders the run bar with the run button and the computing-unit selector", () => {
+ fixture.detectChanges();
+ finishLoad();
+
+ expect(el(".runbar .run")).not.toBeNull();
+ // Default state: no unit chosen, so the button reads Connect and is disabled.
+ expect(el(".runbar .run")?.textContent?.trim()).toContain("Connect");
+ expect((el(".runbar .run") as HTMLButtonElement).disabled).toBe(true);
+ expect(el(".runbar texera-computing-unit-selection")).not.toBeNull();
+ // At rest there is nothing to count and no run note.
+ expect(el(".run-clock")).toBeNull();
+ expect(el(".run-note")).toBeNull();
+ });
+
+ it("fires onRun when the enabled run button is clicked", () => {
+ fixture.detectChanges();
+ finishLoad();
+ // A running state makes the button "Stop" (enabled); a disabled button would swallow the click.
+ fixture.componentInstance.executionState = ExecutionState.Running;
+ fixture.detectChanges();
+ const run = vi.spyOn(fixture.componentInstance, "onRun").mockImplementation(() => {});
+
+ el(".runbar .run")!.click();
+
+ expect(run).toHaveBeenCalled();
+ });
+
+ it("announces a run failure as an alert and a running note as a status", () => {
+ fixture.detectChanges();
+ finishLoad();
+ const c = fixture.componentInstance;
+
+ c.runError = "Run failed: boom";
+ fixture.detectChanges();
+ expect(el(".run-note")?.getAttribute("role")).toBe("alert");
+
+ c.runError = "";
+ c.executionState = ExecutionState.Running;
+ fixture.detectChanges();
+ 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 842cbea78d3..17fc51e33e4 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
@@ -56,10 +56,33 @@ export function setupHarness() {
const workflowMetaDataChangedStream = new Subject();
// Compilation reports column names late; the form rebuilds its inputs off this stream.
const compilationChanged = new Subject();
+ // Run-related streams the run tests drive: execution state, the engine's duration event, the
+ // computing-unit connection status, the workflow validity, and the websocket connection.
+ const executionStateStream = new Subject();
+ const durationEvents = new Subject<{ duration: number; isRunning: boolean }>();
+ const statusStream = new Subject();
+ // The picked computing unit (with its accessPrivilege), separate from the connection status.
+ 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();
+ // Selecting a step on the embedded canvas drives the read-only inspect panel.
+ const highlightStream = new Subject();
+ const unhighlightStream = new Subject();
+ const highlightedIds: string[] = [];
+ const unhighlightOperators = vi.fn();
+ const updateSharedModelAwareness = vi.fn();
+ // Operators that produced a non-empty result -- drives hasNonEmptyResult in the result mock.
+ const anyResultIds = new Set();
// The preview centres the embedded graph once it is built; tests assert this fired.
const triggerCenterEvent = vi.fn();
@@ -76,10 +99,21 @@ export function setupHarness() {
getWorkflowMetadata: () => ({ name: "scGPT", lastModifiedTime: 1767225600000 }),
setWorkflowName: vi.fn(),
setWorkflowMetadata: vi.fn(),
+ setHighlightingEnabled: vi.fn(),
getTexeraGraph: () => ({
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(),
+ updateSharedModelAwareness,
+ }),
+ getJointGraphWrapper: () => ({
+ getJointOperatorHighlightStream: () => highlightStream.asObservable(),
+ getJointOperatorUnhighlightStream: () => unhighlightStream.asObservable(),
+ getCurrentHighlightedOperatorIDs: () => highlightedIds,
+ unhighlightOperators,
}),
// Exposing or un-exposing a property announces on this stream; the form re-reads its config.
formBindingChanged$: new Subject(),
@@ -87,9 +121,14 @@ export function setupHarness() {
// Resolves the exposed inputs and reads/writes their values. Tests point `resolveFields` at the
// inputs they want rendered; `readValue` seeds the write-back guard.
const formBindingService = {
+ // The presentation config: the instruction plus the fields. Tests override getConfig to give an
+ // instruction; resolveFields drives which inputs render.
+ getConfig: vi.fn().mockReturnValue({ instruction: undefined, fields: [], resultOperatorIds: [] }),
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
@@ -160,14 +199,46 @@ export function setupHarness() {
const coeditorPresenceService = { coeditors: [] };
const route = { snapshot: { params: { id: "7" } } };
const operatorMetadataService = { getOperatorMetadata: () => of({}) };
- const executeWorkflowService = { resetExecutionAndWorkers: vi.fn() };
- const workflowResultService = { clearResults: vi.fn() };
+ const executeWorkflowService = {
+ getExecutionStateStream: () => executionStateStream.asObservable(),
+ executeWorkflow: vi.fn(),
+ killWorkflow: vi.fn(),
+ resetExecutionAndWorkers: 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) };
- const cdr = { detectChanges: vi.fn() };
- const computingUnitStatusService = { disconnect: vi.fn() };
+ const markdownService = { parse: (s: string) => s };
+ const cdr = { detectChanges: vi.fn(), markForCheck: vi.fn() };
+ const computingUnitStatusService = {
+ disconnect: vi.fn(),
+ getSelectedComputingUnit: () => selectedUnitStream.asObservable(),
+ getStatus: () => statusStream.asObservable(),
+ };
const workflowConsoleService = { clearConsoleMessages: vi.fn() };
+ // The websocket the run clock and the "Connecting" state read. `isConnected` is a plain settable
+ // flag so a test can put the page in the connecting window.
+ const workflowWebsocketService = {
+ subscribeToEvent: (_: string) => durationEvents.asObservable(),
+ isConnected: true,
+ getConnectionStatusStream: () => connectionStream.asObservable(),
+ };
+ const validationWorkflowService = {
+ getWorkflowValidationErrorStream: () => validationStream.asObservable(),
+ };
// The name field is measured off the host; querySelector returns null so the measuring
// (DOM-layout, jsdom has none) short-circuits. `contains` drives isTypingInTheForm; false by
// default so a rebuild is never suppressed, and overridden by the tests that probe typing.
@@ -195,20 +266,40 @@ export function setupHarness() {
workflowResultService,
notificationService,
userService,
+ markdownService,
formlyJsonschema,
cdr,
dynamicSchemaService,
workflowCompilingService,
computingUnitStatusService,
workflowConsoleService,
+ workflowWebsocketService,
+ panelResizeService,
+ validationWorkflowService,
host,
datePipe,
config,
workflowChangedStream,
workflowMetaDataChangedStream,
compilationChanged,
+ executionStateStream,
+ durationEvents,
+ statusStream,
+ selectedUnitStream,
+ validationStream,
+ connectionStream,
+ resultUpdateStream,
+ viewResultChanged,
hasOperatorIds,
graphOperators,
+ viewResultIds,
+ anyResultIds,
+ snapshotById,
triggerCenterEvent,
+ highlightStream,
+ unhighlightStream,
+ highlightedIds,
+ unhighlightOperators,
+ updateSharedModelAwareness,
};
}
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));
}