You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/09/12 15:23:18 UTC
(texera) branch gh-readonly-queue/main/pr-8516-069cd208c68d3f3a8e50d583a0a0ff7e00a4e536 created (now 0b103f8136)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-8516-069cd208c68d3f3a8e50d583a0a0ff7e00a4e536
in repository https://gitbox.apache.org/repos/asf/texera.git
at 0b103f8136 feat(gui): add the Form View edit mode, with instruction and result authoring (#8516)
This branch includes the following new commits:
new 0b103f8136 feat(gui): add the Form View edit mode, with instruction and result authoring (#8516)
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(texera) 01/01: feat(gui): add the Form View edit mode, with instruction and result authoring (#8516)
Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-8516-069cd208c68d3f3a8e50d583a0a0ff7e00a4e536
in repository https://gitbox.apache.org/repos/asf/texera.git
commit 0b103f813666b13ab4aa2bad7dd29d06a92685a7
Author: yangzhang75 <ya...@uci.edu>
AuthorDate: Sat Sep 12 15:22:55 2026 +0000
feat(gui): add the Form View edit mode, with instruction and result authoring (#8516)
### What changes were proposed in this PR?
Closes #8026. Part of the Form View stack (parent issue #8011), on main.
This is the first half of what was #8455 (closed, split in two at 2200
lines); the second half, authoring the inputs themselves, is #8517
stacked on this one. Four commits: the first is the feature, the second
and third are test-only follow-ups (the Windows CI leg and the template
coverage report), and the last opens the result picker to everyone with
one saved list of shown results (the design changes asked for in review,
mengw15's list shape included). The review commit is the branch tip.
Adds the edit mode of the page and the page-level authoring it enables.
- `toggleAuthoring`: with write access, Edit turns the page into
in-place authoring; Done returns it to the reader state. Edit mode is
the only state that enables workflow modification here
(`applyEditability`), and entering it is refused without write access at
the method itself, not only by hiding the button.
- The inspect panel goes live in edit mode: it acts as an editor
(`actsAsEditor`), its expose tick boxes are on (`exposeChoosing`), and
`inert` comes off, so the author opens a step on the embedded preview
and chooses which of its settings people fill in right there. It stays
inert and read-only for everyone else, as before.
- The embedded preview's right-click menu carries the structure lock.
The menu's re-shaping commands followed the modification flag alone, so
re-enabling it for the panel would have offered cut, paste, delete and
disable on the structure-locked preview. `texera-context-menu` now takes
`[structureLocked]` from the editor and gates those on modification AND
no lock (`canModify`); copy, the result toggles, execute-to and export
do not re-shape the graph and are unchanged.
- Write the author's instruction in place: the heading is edited in the
header (a row rather than one button, so the input is not nested in a
control) and the body as markdown with Write / Preview tabs. A reader
still sees it rendered, only when there is text.
- Pick which results to show, for everyone: the picker lists the final
steps and the intermediate steps that have view-result on the canvas,
and rebuilds live when an eye is toggled or the graph's shape or names
change (a step deleted, disabled, given a downstream link, or renamed, a
co-editor's edit included), from the graph's own streams rather than
only after the debounced compilation. What shows is one saved list,
`shownResultIds` (the shape mengw15 proposed): absent, every final step
shows, as on the canvas; once the author has chosen, exactly the listed
steps show, and `[]` means none, a choice the earlier pair of
opposite-signed lists (featured / turned off) could not store and could
contradict each other on. In edit mode a pill adds the step to or
removes it from that list; the first choice starts the list from the
final steps at that moment, so nothing the author saw disappears. The
accepted cost: a step that becomes final after the author has chosen
does not appear by itself. A definition whose only content is that list,
an empty one included, is still saved. A listed step whose eye has since
been turned off stays offered in edit mode only, so the author can take
it off the list; a reader is not offered a pill that could never turn
on. Anyone else, a writer merely viewing included, gets the same picker
but changes only their own view of the page; nothing is written, so a
reader without write access can choose too, and the choice lasts for the
page. A disabled step is neither offered nor shown, eye or no eye: the
compiled plan leaves it out.
- Edit mode is the only state that unlocks the graph from this page, and
only while no run is in flight (the canvas rule, kept so that entering
edit mode mid-run cannot undo it). The lock is a root-level flag with
writers that know nothing of this page: the execute service unlocks it
whenever a run ends (completed, failed, killed, reset), and the
computing-unit selector embedded here unlocks it when it finds no run on
the chosen unit. Rather than chase each caller, the page clamps at the
stream they all report to (`getWorkflowModificationEnabledStream`):
whenever the flag turns on while the page must stay locked, it is turned
off again, so a writer merely viewing can never reach the preview's
view-result command. In edit mode the canvas rule stands: locked while
running, unlocked when the run ends. The clamp runs a microtask after
the unlocking call, never inside it: `enableWorkflowModification`
enables undo/redo after it emits, and the stream still has other
subscribers to reach, so a nested disable would leave them on the stale
"true"; run afterwards, the disable is the last word and every consumer
sees one locked state. The execute service flips the lock before it
emits the new state; by the time the clamp looks, the execution-state
handler has re-applied the same rule with the final state, so in edit
mode the unlock stands. The step panel follows the same rule
(`panelLive`), not edit mode alone: the property frame does not consult
the lock before its own writes (the version sync on mount, the schema
defaults ajv fills in, the editing marker), so a step selected while a
run is in flight stays a read-only, inert mount even in edit mode and
turns live when the run ends. Done dismisses the step panel before
leaving edit mode, while the frame is still an editor: that is the only
state in which the property editor clears the "currently editing" marker
co-editors see.
- The embedded preview grows no editing buttons: no link tools on hover
(remove, breakpoint), and a selected operator unfolds its state and port
counts but not its delete, chat and add/remove-port buttons. None of
them can act on the structure-locked preview, so they only suggested it
could be edited.
- Open canvas saves first and hands over only once the save has
completed: the switch is a full-page load, which aborts a request still
in flight. Saves go out one at a time, in order: two persists in flight
at once can reach the backend out of order and the older content would
win, so the switch's save waits for an autosave already on its way, and
the page hands over only once the queue has drained: that save and any
asked for while it was in flight (the page stays interactive until the
hand-over) have completed; each request carries the workflow as it was
when the save was asked for, so the last one enqueued is the latest. The
drain outlives the page: the final save on the way out joins the same
queue rather than racing an autosave still in flight, and the queue is
closed after it. A failed save keeps the author on the form with the
error shown and does not stop the queue; a reader with nothing to save
goes straight through. A save's response feeds back the server-owned
metadata (the timestamp, and the normalised name when nothing changed)
but never undoes a rename made while it was in flight, and repaints
nothing once the page is gone. (#8456 does the save-then-navigate part
on the canvas side.)
- Keyboard focus is visible on the pill and tab buttons
(`:focus-visible`). The picker's pills are tracked by step (`trackBy`):
a toggle re-reads the config and rebuilds the choices as new objects,
and re-created buttons would have dropped the keyboard focus from the
pill just pressed. The instruction body carries an `aria-label`, since
its placeholder is gone as soon as there is text.
- `29c54bb85` (test only, workspace menu): the menu's export test
module-mocked the CommonJS `file-saver` package with `vi.mock`. Under
the Angular unit-test builder that call is not reliably hoisted (Vitest
warns about it on every platform), and with this stack's shared-chunk
graph it stopped applying on the Windows leg (7 of the last 8 Windows
runs of the unsplit PR, while main passes). The export now goes through
the existing injectable `FileSaverService`, as the dashboard downloads
already do, and the spec stubs that with TestBed. No behaviour change.
- `695c2fb4f` (test only, Form View rendered spec): the spec swapped the
property panel for a stub by overriding the page's imports, which
JIT-recompiles the page; a JIT template has no mapping back to the
`.component.html`, so the page's template read as 0% covered on codecov
from #8442 on (the 10 "missing" template lines there, 132 on the unsplit
PR). The real panel's template is blanked instead, with its lifecycle
hooks switched off, so the page stays AOT-compiled and its template is
measured again. The edit-mode markup this PR adds is then covered
through the DOM: Edit / Done, the Write / Preview tabs, heading and body
writing through, the picker's pills and empty hint.
The diff is about 1400 added lines because 725 of them are spec against
694 of source (of which 192 are stylesheet); the two test-only commits
and the review-driven redesign of the picker are a large part of it, and
the feature itself is under 700 lines.
Not in this PR: renaming, hiding, reordering, help text and removal of
the exposed inputs, and the author's view of a broken input. Those are
#8517.
### Any related issues, documentation, discussions?
Closes #8026. Part of the Form View feature (parent issue #8011).
Replaces the first half of #8455.
### How was this PR tested?
Unit tests (vitest). Direct-construction tests cover the authoring gate
(enter, leave, refused without write access, always allowed to leave),
the result picker's range (final steps until the author chooses, then
exactly the saved list, `[]` included; viewed and listed intermediates;
disabled steps left out), its live rebuild on an eye toggle, the
edit-mode toggle writing the default (the saved list started from the
final steps on the first choice), a viewer's own toggle writing nothing
and giving way on entering edit mode, a reader not offered a saved pick
whose eye is off, the lock clamped back whenever anything else unlocks
the graph outside edit mode, unlocked in edit mode once a run has ended
in the execute service's real order (unlock before state), and kept
locked when edit mode is entered mid-run, the switch's save queued
behind an autosave in flight with navigation after both, the final save
on the way out drained behind an autosave in flight after the page is
gone, a failed save not stopping the queue, a failed final save still
reported without throwing, an older save's response not undoing a rename
made meanwhile and no metadata repaint after the page is gone, a shown
step dropped from the cards and the picker the moment the graph's shape
makes it unavailable, a pill renamed the moment its step's display name
changes, the hand-over waiting for a save queued behind the switch's and
staying put when that save fails, the step panel dismissed on Done while
the frame is still an editor, the instruction writes going through the
binding service, and the switch's save-then-navigate order (navigates on
complete, stays on error, straight through for a reader). The rendered
spec covers the edit-mode header shape (title input outside the toggle,
`aria-controls`), the panel turning live in edit mode (editor on, tick
boxes on, inert off), and the panel staying read-only while a run is in
flight even in edit mode, turning live when the run ends. The context
menu's spec renders it under a structure lock with modification enabled
and checks cut, paste, delete, disable and enable are off while copy and
the result toggle stay; the editor's spec opens the right-click menu on
a locked editor and checks the lock arrived, and checks a locked preview
grows no link tools on hover or on add, and keeps a selected operator's
delete, chat and port buttons hidden while its state still unfolds,
while the canvas shows all of them. The binding service's spec checks
the first choice starts the list from the default handed in, later
choices flip within the list ignoring the default, and turning off the
only shown step leaves `[]` rather than an absent list; the action
service's spec checks a definition whose only content is
`shownResultIds: []` is still carried in the saved content. Each new
guard was deletion-checked (removing it turns the corresponding test
red). eslint, prettier and the production (AOT) build pass; every
changed line, template lines included, is statement and function covered
(5830 tests).
#### Video
https://github.com/user-attachments/assets/ca2f8e99-357a-48f7-95cb-6bf4e57c1bc4
### Was this PR authored or co-authored using generative AI tooling?
Yes. Generated-by: Claude Code (Claude Fable 5.1, Anthropic).
Co-authored with Claude, reviewed line by line by the author before
submission.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01FVvP3ttj22f9LB4p9u2anY
---------
Co-authored-by: Claude Fable 5.1 <no...@anthropic.com>
Co-authored-by: Meng Wang <me...@uci.edu>
Co-authored-by: mengw15 <12...@users.noreply.github.com>
---
frontend/src/app/common/type/workflow.ts | 11 +-
.../component/menu/menu.component.spec.ts | 10 +-
.../app/workspace/component/menu/menu.component.ts | 10 +-
.../property-editor/property-editor.component.ts | 17 +-
.../context-menu/context-menu.component.html | 17 +-
.../context-menu/context-menu.component.spec.ts | 57 ++
.../context-menu/context-menu.component.ts | 16 +-
.../workflow-editor/workflow-editor.component.html | 2 +-
.../workflow-editor.component.spec.ts | 87 ++-
.../workflow-editor/workflow-editor.component.ts | 24 +-
.../workflow-form/workflow-form.component.html | 219 +++++--
.../workflow-form/workflow-form.component.scss | 201 ++++++-
.../workflow-form/workflow-form.component.spec.ts | 632 ++++++++++++++++++++-
.../workflow-form/workflow-form.component.ts | 454 +++++++++++++--
.../workflow-form/workflow-form.rendered.spec.ts | 252 +++++++-
.../workflow-form/workflow-form.spec-harness.ts | 24 +-
.../form-binding/form-binding.service.spec.ts | 39 +-
.../service/form-binding/form-binding.service.ts | 19 +-
.../service/joint-ui/joint-ui.service.spec.ts | 13 +
.../workspace/service/joint-ui/joint-ui.service.ts | 25 +-
.../model/workflow-action.service.spec.ts | 18 +-
.../model/workflow-action.service.ts | 7 +-
22 files changed, 1945 insertions(+), 209 deletions(-)
diff --git a/frontend/src/app/common/type/workflow.ts b/frontend/src/app/common/type/workflow.ts
index 1aa6b40878..4129346b33 100644
--- a/frontend/src/app/common/type/workflow.ts
+++ b/frontend/src/app/common/type/workflow.ts
@@ -67,13 +67,16 @@ export interface FormBindingConfig {
};
/** Array order is display order; the author reorders by dragging. */
fields: FormFieldBinding[];
- /** View-result operators whose results are also shown under the workflow after a run, on top of
- * the final step's result, which always shows. */
- resultOperatorIds: string[];
+ /** Which steps' results show under the workflow after a run, for everyone. Absent until the author
+ * chooses: then every final (terminal) step shows, as on the canvas. Once set it is exhaustive:
+ * exactly these steps show, and [] means none. One list, so nothing can contradict it; the cost is
+ * that a step which becomes final after the author has chosen does not appear by itself. When
+ * displayed it is kept to steps that still have a result on the canvas. */
+ shownResultIds?: string[];
}
export function getDefaultFormBinding(): FormBindingConfig {
- return { fields: [], resultOperatorIds: [] };
+ return { fields: [] };
}
/**
diff --git a/frontend/src/app/workspace/component/menu/menu.component.spec.ts b/frontend/src/app/workspace/component/menu/menu.component.spec.ts
index c726bfec65..da69bf3adf 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.spec.ts
+++ b/frontend/src/app/workspace/component/menu/menu.component.spec.ts
@@ -45,7 +45,7 @@ import { ExecutionState } from "../../types/execute-workflow.interface";
import { HeatmapView } from "../../service/heatmap/heatmap-scoring";
import { ComputingUnitState } from "../../../common/type/computing-unit-connection.interface";
import { mockPoint, mockScanPredicate } from "../../service/workflow-graph/model/mock-workflow-data";
-import { saveAs } from "file-saver";
+import { FileSaverService } from "../../../dashboard/service/user/file/file-saver.service";
import type { ModalOptions } from "ng-zorro-antd/modal";
import type { ComputingUnitSelectionComponent } from "../power-button/computing-unit-selection.component";
import { WorkflowContent } from "../../../common/type/workflow";
@@ -57,8 +57,6 @@ import { MockGuiConfigService } from "../../../common/service/gui-config.service
import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service";
import type { Mocked } from "vitest";
-vi.mock("file-saver", () => ({ saveAs: vi.fn() }));
-
describe("MenuComponent", () => {
let component: MenuComponent;
let fixture: ComponentFixture<MenuComponent>;
@@ -112,7 +110,6 @@ describe("MenuComponent", () => {
fixture = TestBed.createComponent(MenuComponent);
component = fixture.componentInstance;
fixture.detectChanges();
- vi.mocked(saveAs).mockClear();
});
it("should create", () => {
@@ -525,6 +522,9 @@ describe("MenuComponent", () => {
describe("onClickExportWorkflow (save)", () => {
it("serializes the workflow content as JSON and downloads it under the workflow name", () => {
+ // Stubbed on the injected wrapper rather than by module-mocking file-saver: that CommonJS
+ // mock is order-sensitive under the unit-test builder and was failing on the Windows leg.
+ const saveAs = vi.spyOn(TestBed.inject(FileSaverService), "saveAs").mockImplementation(() => {});
const fakeContent = {
operators: [{ operatorID: "op1" }],
links: [],
@@ -537,7 +537,7 @@ describe("MenuComponent", () => {
component.onClickExportWorkflow();
expect(saveAs).toHaveBeenCalledTimes(1);
- const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0] as [Blob, string];
+ const [blobArg, fileNameArg] = saveAs.mock.calls[0] as [Blob, string];
expect(fileNameArg).toBe("my-workflow.json");
expect(blobArg).toBeInstanceOf(Blob);
expect(blobArg.type).toBe("text/plain;charset=utf-8");
diff --git a/frontend/src/app/workspace/component/menu/menu.component.ts b/frontend/src/app/workspace/component/menu/menu.component.ts
index b5b209b3eb..c3ad4aa979 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.ts
+++ b/frontend/src/app/workspace/component/menu/menu.component.ts
@@ -35,7 +35,7 @@ import { catchError, debounceTime, switchMap, tap } from "rxjs/operators";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { WorkflowUtilService } from "../../service/workflow-graph/util/workflow-util.service";
import { WorkflowVersionService } from "../../../dashboard/service/user/workflow-version/workflow-version.service";
-import { saveAs } from "file-saver";
+import { FileSaverService } from "../../../dashboard/service/user/file/file-saver.service";
import { NotificationService } from "src/app/common/service/notification/notification.service";
import { OperatorMenuService } from "../../service/operator-menu/operator-menu.service";
import { CoeditorPresenceService } from "../../service/workflow-graph/model/coeditor-presence.service";
@@ -188,7 +188,8 @@ export class MenuComponent implements OnInit, OnDestroy {
private computingUnitStatusService: ComputingUnitStatusService,
protected config: GuiConfigService,
private router: Router,
- private jupyterPanelService: JupyterPanelService
+ private jupyterPanelService: JupyterPanelService,
+ private fileSaverService: FileSaverService
) {
workflowWebsocketService
.subscribeToEvent("ExecutionDurationUpdateEvent")
@@ -619,7 +620,10 @@ export class MenuComponent implements OnInit, OnDestroy {
const workflowContent: WorkflowContent = this.workflowActionService.getWorkflowContent();
const workflowContentJson = JSON.stringify(workflowContent, null, 2);
const fileName = this.currentWorkflowName + ".json";
- saveAs(new Blob([workflowContentJson], { type: "text/plain;charset=utf-8" }), fileName);
+ // Through the injectable wrapper (as the dashboard downloads already do), so a spec stubs it
+ // with TestBed instead of module-mocking the CommonJS file-saver package, which the unit-test
+ // builder cannot hoist reliably.
+ this.fileSaverService.saveAs(new Blob([workflowContentJson], { type: "text/plain;charset=utf-8" }), fileName);
}
/**
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 5952b94210..63b97a71c7 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
@@ -235,15 +235,16 @@ export class PropertyEditorComponent implements OnInit, OnDestroy, OnChanges {
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));
+ // Guarding the block rather than returning early keeps any teardown added below it running for
+ // both mounts.
+ if (this.persistPlacement) {
+ localStorage.setItem("right-panel-width", String(this.width));
+ localStorage.setItem("right-panel-height", String(this.height));
- const rightContainer = document.getElementById("right-container");
- if (rightContainer) {
- localStorage.setItem("right-panel-style", rightContainer.style.cssText);
+ const rightContainer = document.getElementById("right-container");
+ if (rightContainer) {
+ localStorage.setItem("right-panel-style", rightContainer.style.cssText);
+ }
}
}
diff --git a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.html b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.html
index 4465d65cb2..7d423f58d5 100644
--- a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.html
+++ b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.html
@@ -17,6 +17,9 @@
under the License.
-->
+<!-- Commands that re-shape the graph (cut, paste, delete, disable/enable) are gated on canModify:
+ modification enabled AND no structure lock from an embedded preview. Copy, the result toggles,
+ execute-to and export do not re-shape it and follow their own rules. -->
<ul nz-menu>
<li
nz-menu-item
@@ -35,7 +38,7 @@
*ngIf="(highlightedOperatorIds.length > 0 ||
highlightedCommentBoxIds.length > 0) &&
!hasHighlightedLinks() &&
- isWorkflowModifiable"
+ canModify"
(click)="onCut()">
<span
nz-icon
@@ -48,7 +51,7 @@
*ngIf="(highlightedOperatorIds.length === 0 &&
highlightedCommentBoxIds.length === 0 &&
!hasHighlightedLinks()) &&
- isWorkflowModifiable"
+ canModify"
(click)="onPaste()">
<span
nz-icon
@@ -59,7 +62,8 @@
<li
nz-menu-item
*ngIf="operatorMenuService.isDisableOperator && operatorMenuService.isDisableOperatorClickable &&
- !hasHighlightedLinks()"
+ !hasHighlightedLinks() &&
+ !structureLocked"
(click)="operatorMenuService.disableHighlightedOperators()">
<span
nz-icon
@@ -70,7 +74,8 @@
<li
nz-menu-item
*ngIf="!operatorMenuService.isDisableOperator && operatorMenuService.isDisableOperatorClickable &&
- !hasHighlightedLinks()"
+ !hasHighlightedLinks() &&
+ !structureLocked"
(click)="operatorMenuService.disableHighlightedOperators()">
<span
nz-icon
@@ -127,7 +132,7 @@
nz-menu-item
*ngIf="(highlightedOperatorIds.length > 0 ||
highlightedCommentBoxIds.length > 0) &&
- isWorkflowModifiable"
+ canModify"
(click)="onDelete()">
<span
nz-icon
@@ -142,7 +147,7 @@
*ngIf="hasHighlightedLinks() &&
highlightedOperatorIds.length === 0 &&
highlightedCommentBoxIds.length === 0 &&
- isWorkflowModifiable"
+ canModify"
(click)="onDelete()">
<span
nz-icon
diff --git a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts
index 2903abc1fb..f88ef9e5cf 100644
--- a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts
+++ b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts
@@ -570,6 +570,63 @@ describe("ContextMenuComponent", () => {
expect(spy).toHaveBeenCalledTimes(1);
});
+ // The Form View's edit mode turns modification on for the property panel while its embedded
+ // preview stays structure-locked; the lock has to reach the menu, or right-click on the preview
+ // could still re-shape the graph.
+ describe("under a structure lock", () => {
+ const renderedLabels = () =>
+ fixture.debugElement.queryAll(By.css("li[nz-menu-item]")).map(li => norm(li.nativeElement.textContent));
+
+ it("keeps cut, delete, disable and enable off even with modification enabled", () => {
+ highlightedOperatorsSubject.next(["op1"]);
+ component.isWorkflowModifiable = true;
+ component.structureLocked = true;
+ operatorMenuService.isDisableOperator = true;
+ operatorMenuService.isDisableOperatorClickable = true;
+ operatorMenuService.isToViewResult = true;
+ operatorMenuService.isToViewResultClickable = true;
+ fixture.detectChanges();
+
+ const labels = renderedLabels();
+ expect(labels).not.toContain("cut");
+ expect(labels).not.toContain("delete");
+ expect(labels).not.toContain("disable");
+ // Copying and the result toggle do not re-shape the graph, so they stay.
+ expect(labels).toContain("copy");
+ expect(labels).toContain("view result");
+
+ operatorMenuService.isDisableOperator = false;
+ fixture.detectChanges();
+ expect(renderedLabels()).not.toContain("enable");
+ });
+
+ it("keeps paste and link deletion off", () => {
+ component.isWorkflowModifiable = true;
+ component.structureLocked = true;
+ highlightedOperatorsSubject.next([]);
+ highlightedCommentBoxesSubject.next([]);
+ fixture.detectChanges();
+ expect(renderedLabels()).not.toContain("paste");
+
+ jointGraphWrapperSpy.getCurrentHighlightedLinkIDs.mockReturnValue(["link1"]);
+ fixture.detectChanges();
+ expect(renderedLabels()).not.toContain("delete");
+ });
+
+ it("canModify needs modification on and no structure lock", () => {
+ component.isWorkflowModifiable = true;
+ component.structureLocked = false;
+ expect(component.canModify).toBe(true);
+
+ component.structureLocked = true;
+ expect(component.canModify).toBe(false);
+
+ component.structureLocked = false;
+ component.isWorkflowModifiable = false;
+ expect(component.canModify).toBe(false);
+ });
+ });
+
it("execute to this operator invokes executeUpToOperator", () => {
highlightedOperatorsSubject.next(["op1"]);
component.isWorkflowModifiable = true;
diff --git a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts
index 019f77f7af..911172b6ba 100644
--- a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts
+++ b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { Component } from "@angular/core";
+import { Component, Input } from "@angular/core";
import { OperatorMenuService } from "src/app/workspace/service/operator-menu/operator-menu.service";
import { WorkflowActionService } from "src/app/workspace/service/workflow-graph/model/workflow-action.service";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
@@ -41,6 +41,20 @@ import { NzIconDirective } from "ng-zorro-antd/icon";
})
export class ContextMenuComponent {
public isWorkflowModifiable: boolean = false;
+
+ /**
+ * Set by an embedded, structure-locked editor (the Form View's preview). Its edit mode turns
+ * workflow modification back on for the property panel, which alone would also bring the
+ * re-shaping commands here back; the lock keeps them off. Copy, the result toggles and export
+ * do not change the graph and are left to their own rules.
+ */
+ @Input() structureLocked = false;
+
+ /** Whether the graph may be re-shaped from this menu: modification on, and no structure lock. */
+ public get canModify(): boolean {
+ return this.isWorkflowModifiable && !this.structureLocked;
+ }
+
public highlightedOperatorIds: readonly string[] = [];
public highlightedCommentBoxIds: readonly string[] = [];
diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html
index 0b652d1a76..43d25120e9 100644
--- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html
+++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html
@@ -34,7 +34,7 @@
<nz-dropdown-menu
nzNoAnimation
#menu="nzDropdownMenu">
- <texera-context-menu></texera-context-menu>
+ <texera-context-menu [structureLocked]="structureLocked"></texera-context-menu>
</nz-dropdown-menu>
<!-- Chat Popover for Operator -->
diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts
index 9597229482..67111e5476 100644
--- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts
+++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts
@@ -56,7 +56,8 @@ import { OperatorLink, OperatorPredicate } from "../../types/workflow-common.int
import { tap } from "rxjs/operators";
import { WorkflowVersionService } from "../../../dashboard/service/user/workflow-version/workflow-version.service";
import { config as rxjsConfig, of, Subject } from "rxjs";
-import { NzContextMenuService, NzDropDownModule } from "ng-zorro-antd/dropdown";
+import { NzContextMenuService, NzDropDownModule, NzDropdownMenuComponent } from "ng-zorro-antd/dropdown";
+import { By } from "@angular/platform-browser";
import { ActivatedRoute, Router } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { ContextMenuComponent } from "./context-menu/context-menu/context-menu.component";
@@ -140,6 +141,22 @@ describe("WorkflowEditorComponent", () => {
expect(editor.classList.contains("hide-operator-status")).toBe(true);
});
+ it("carries its structure lock into the right-click menu", () => {
+ // The Form View's edit mode re-enables workflow modification for the property panel while its
+ // preview stays structure-locked; the menu must see the lock, or right-click could still cut,
+ // paste or delete from the preview.
+ component.structureLocked = true;
+ fixture.detectChanges();
+ const menu = fixture.debugElement.query(By.directive(NzDropdownMenuComponent)).componentInstance;
+ component.nzContextMenu.create(new MouseEvent("contextmenu", { clientX: 5, clientY: 5 }), menu);
+ fixture.detectChanges();
+
+ const contextMenu = fixture.debugElement.query(By.directive(ContextMenuComponent));
+ expect(contextMenu).not.toBeNull();
+ expect((contextMenu.componentInstance as ContextMenuComponent).structureLocked).toBe(true);
+ component.nzContextMenu.close();
+ });
+
// Drives the region-update stream the editor subscribes to in handleRegionEvents, creating
// region-<id> elements around the given operator, and returns the operator id used.
function emitRegionUpdate(regionId: number): string {
@@ -238,6 +255,63 @@ describe("WorkflowEditorComponent", () => {
expect(component.paper.findViewByModel(element2.id)).toBeTruthy();
expect(component.paper.findViewByModel(link1.id)).toBeTruthy();
});
+
+ /** Two operators joined by one link, added the way the workflow is (through the action service, so
+ * the wrapper's cell-add streams fire), returning the link's view on the paper. */
+ function addLinkedPair(): joint.dia.LinkView {
+ const workflowActionService = TestBed.inject(WorkflowActionService);
+ workflowActionService.addOperator(mockScanPredicate, mockPoint);
+ workflowActionService.addOperator(mockResultPredicate, mockPoint);
+ workflowActionService.addLink(mockScanResultLink);
+ return component.paper.findViewByModel(mockScanResultLink.linkID) as joint.dia.LinkView;
+ }
+
+ it("offers the link tools on the operator canvas: remove appears on hover", () => {
+ const linkView = addLinkedPair();
+
+ component.paper.trigger("link:mouseenter", linkView, new MouseEvent("mouseenter"));
+
+ expect(linkView.hasTools()).toBe(true);
+ });
+
+ it("adds no link tools on a structure-locked preview: no remove, no breakpoint", () => {
+ // The Form View's preview cannot remove a link and has no use for breakpoints; buttons that did
+ // nothing would only suggest the preview can be edited.
+ component.structureLocked = true;
+ const linkView = addLinkedPair();
+
+ expect(linkView.hasTools()).toBe(false);
+ component.paper.trigger("link:mouseenter", linkView, new MouseEvent("mouseenter"));
+ expect(linkView.hasTools()).toBe(false);
+ });
+
+ /** Select the scan operator the way a click does and return its model, whose attrs say what is on show. */
+ function selectScanOperator(): joint.dia.Cell {
+ addLinkedPair();
+ const view = component.paper.findViewByModel(mockScanPredicate.operatorID);
+ component.paper.trigger("element:pointerdown", view, new MouseEvent("mousedown"), 0, 0);
+ return component.paper.getModelById(mockScanPredicate.operatorID);
+ }
+
+ it("shows a selected operator's delete and chat buttons on the operator canvas", () => {
+ const model = selectScanOperator();
+
+ expect(model.attr(".delete-button/visibility")).toBe("visible");
+ expect(model.attr(".chat-button/visibility")).toBe("visible");
+ });
+
+ it("keeps a selected operator's buttons hidden on a structure-locked preview, but still shows its state", () => {
+ // The Form View's preview cannot delete an operator or change its ports, and the agent chat is
+ // a canvas tool; buttons that did nothing would only suggest the preview can be edited. The
+ // state text still unfolds, so a run's progress reads there as on the canvas.
+ component.structureLocked = true;
+ const model = selectScanOperator();
+
+ expect(model.attr(".delete-button/visibility")).toBe("hidden");
+ expect(model.attr(".chat-button/visibility")).toBe("hidden");
+ expect(model.attr(".add-input-port-button/visibility")).toBe("hidden");
+ expect(model.attr(".texera-operator-state/visibility")).toBe("visible");
+ });
});
/**
@@ -1610,6 +1684,17 @@ describe("WorkflowEditorComponent link breakpoints", () => {
return { linkID: mockScanResultLink.linkID, model, view: model.findView(component.paper) as any };
}
+ it("attaches no breakpoint tool on a structure-locked preview", () => {
+ // The Form View's preview has no use for breakpoints (a canvas debugging tool), and a hover adds
+ // no remove button there either: buttons that did nothing would suggest the preview can be edited.
+ component.structureLocked = true;
+ const { view } = withLink();
+
+ expect(view.hasTools()).toBe(false);
+ component.paper.trigger("link:mouseenter", view, new MouseEvent("mouseenter"));
+ expect(view.hasTools()).toBe(false);
+ });
+
it("attaches a breakpoint tool to every link, hidden until it is wanted", () => {
// The tool is what the user clicks to set a breakpoint; without it the feature has no entry
// point, and leaving it visible would put a button on every link on the canvas.
diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
index 9234d3338c..9e38d8a782 100644
--- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
+++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
@@ -128,10 +128,10 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
* Set by a view that shows the graph but must never re-shape it. Separate from the
* workflow-modification lock (which also gates property editing, so reusing that alone would
* disable the property panel a later authoring mode needs). It locks the paper's own
- * interactions -- dragging, linking, and the keyboard delete/cut/port commands. The right-click
- * menu's structural commands follow the modification lock instead, so a read-only view like the
- * Form View, which also disables modification, is fully locked; an authoring view that re-enables
- * modification will need to carry this lock into the menu too.
+ * interactions -- dragging, linking, and the keyboard delete/cut/port commands -- and is passed on
+ * to the right-click menu, whose re-shaping commands otherwise follow the modification lock alone:
+ * the Form View's edit mode re-enables modification for the property panel, and without the
+ * hand-off the preview's menu would cut, paste and delete again.
*/
@Input() structureLocked = false;
@@ -1200,7 +1200,9 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
}
this.currentOpenedOperatorID = operatorID;
- this.jointUIService.unfoldOperatorDetails(this.paper, operatorID);
+ // A structure-locked preview (the Form View's) unfolds the state and port counts only: its
+ // delete, chat and port buttons could not act there, and would only suggest it can be edited.
+ this.jointUIService.unfoldOperatorDetails(this.paper, operatorID, !this.structureLocked);
});
fromJointPaperEvent(this.paper, "element:contextmenu")
@@ -1213,7 +1215,7 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
}
this.currentOpenedOperatorID = operatorID;
- this.jointUIService.unfoldOperatorDetails(this.paper, operatorID);
+ this.jointUIService.unfoldOperatorDetails(this.paper, operatorID, !this.structureLocked);
});
// Handle right-click on links
@@ -1533,6 +1535,12 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
.pipe(map(value => value[0]))
.pipe(untilDestroyed(this))
.subscribe(linkView => {
+ // A structure-locked preview (the Form View's) offers neither: the link cannot be removed
+ // there, and a breakpoint is a canvas debugging tool. Buttons that do nothing would only
+ // suggest the preview can be edited.
+ if (this.structureLocked) {
+ return;
+ }
// Create an array to hold the tools
const tools: joint.dia.ToolView[] = [new this.removeButton()];
@@ -1583,6 +1591,10 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
.getJointLinkCellAddStream()
.pipe(this.wrapper.jointGraphContext.bufferWhileAsync, untilDestroyed(this))
.subscribe(link => {
+ // No breakpoint tool on a structure-locked preview either (see handleLinkCursorHover).
+ if (this.structureLocked) {
+ return;
+ }
const linkView = link.findView(this.paper);
const breakpointButtonTool = this.breakpointButton;
const breakpointButton = new breakpointButtonTool();
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 64b30e323a..bad8ee81e6 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
@@ -61,6 +61,30 @@
</div>
<div class="pc-page">
+ <!-- Only a writer sees Edit, which flips the whole page into in-place authoring; the lede appears
+ only while authoring, to say what edit mode is for. -->
+ <header
+ class="pc-head"
+ *ngIf="canEdit">
+ <p
+ class="lede"
+ *ngIf="authoring">
+ Choose what people fill in, then run to see it as they will.
+ </p>
+ <span class="spacer"></span>
+ <button
+ nz-button
+ class="author-toggle"
+ [nzType]="authoring ? 'primary' : 'default'"
+ (click)="toggleAuthoring()">
+ <i
+ nz-icon
+ nzType="edit"
+ aria-hidden="true"></i>
+ {{ authoring ? "Done" : "Edit" }}
+ </button>
+ </header>
+
<div class="pc-rule"></div>
<div
@@ -70,48 +94,127 @@
</div>
<div [hidden]="loading">
- <!-- The author's one piece of guidance, shown as rendered markdown, collapsible, and only when
- there is instruction text to show. Editing it is part of the authoring PR. -->
+ <!-- The author's one piece of guidance, shown as rendered markdown, collapsible. A reader sees
+ it only when there is text; an author always sees the section, to write it (write/preview). -->
<section
class="card instr"
- *ngIf="hasInstruction"
+ *ngIf="hasInstruction || authoring"
[class.open]="instructionOpen">
- <button
- type="button"
+ <!-- The header is a row, not one big button, because while authoring it holds the title INPUT,
+ and an input inside a button is invalid interactive nesting (assistive tech gets two
+ controls fighting over one click). Reading: the whole row is the toggle. Authoring: the
+ row is icon, title input, and a chevron button that toggles, so the input is the control's
+ sibling rather than its child. Either toggle names the body it opens (aria-controls). -->
+ <div
class="instr-bar"
- [attr.aria-expanded]="instructionOpen"
- (click)="toggleInstruction()">
- <i
- nz-icon
- nzType="info-circle"
- class="lead"
- aria-hidden="true"></i>
- <h2>{{ instructionTitle || "How to use this" }}</h2>
- <i
- nz-icon
- nzType="down"
- class="chev"
- aria-hidden="true"></i>
- </button>
+ [class.authoring]="authoring">
+ <button
+ *ngIf="!authoring"
+ type="button"
+ class="instr-toggle"
+ [attr.aria-expanded]="instructionOpen"
+ aria-controls="instr-body"
+ (click)="toggleInstruction()">
+ <i
+ nz-icon
+ nzType="info-circle"
+ class="lead"
+ aria-hidden="true"></i>
+ <h2>{{ instructionTitle || "How to use this" }}</h2>
+ <i
+ nz-icon
+ nzType="down"
+ class="chev"
+ aria-hidden="true"></i>
+ </button>
+
+ <ng-container *ngIf="authoring">
+ <i
+ nz-icon
+ nzType="info-circle"
+ class="lead"
+ aria-hidden="true"></i>
+ <!-- An author edits the heading in place here, not in a separate Title box. -->
+ <input
+ class="instr-title-input"
+ [(ngModel)]="instructionTitle"
+ (ngModelChange)="onInstructionChange()"
+ placeholder="How to use this"
+ aria-label="Instruction heading" />
+ <button
+ type="button"
+ class="instr-toggle instr-chev"
+ [attr.aria-expanded]="instructionOpen"
+ aria-controls="instr-body"
+ [attr.aria-label]="instructionOpen ? 'Collapse the instruction' : 'Expand the instruction'"
+ (click)="toggleInstruction()">
+ <i
+ nz-icon
+ nzType="down"
+ class="chev"
+ aria-hidden="true"></i>
+ </button>
+ </ng-container>
+ </div>
<div
+ id="instr-body"
class="instr-body"
[hidden]="!instructionOpen">
<div
class="md"
+ *ngIf="!authoring"
[innerHTML]="instructionPreviewHtml"></div>
+
+ <ng-container *ngIf="authoring">
+ <div class="tabs">
+ <button
+ type="button"
+ [attr.aria-current]="instructionMode === 'write'"
+ (click)="setInstructionMode('write')">
+ Write
+ </button>
+ <button
+ type="button"
+ [attr.aria-current]="instructionMode === 'preview'"
+ (click)="setInstructionMode('preview')">
+ Preview
+ </button>
+ </div>
+
+ <ng-container *ngIf="instructionMode === 'write'">
+ <textarea
+ class="md-input"
+ [(ngModel)]="instructionBody"
+ (ngModelChange)="onInstructionChange()"
+ aria-label="Instruction body"
+ placeholder="Explain what this does and what to fill in."></textarea>
+ <p class="hint">Markdown. Add a picture with <code></code>.</p>
+ </ng-container>
+
+ <div
+ class="md"
+ *ngIf="instructionMode === 'preview'"
+ [innerHTML]="instructionPreviewHtml"></div>
+ </ng-container>
</div>
</section>
<!-- The inputs an author exposed, each rendered as its operator's own field. -->
<div class="pc-section-head">
<span class="label">Inputs</span>
+ <span
+ class="hint"
+ *ngIf="authoring"
+ >Click a step in the workflow to add more</span
+ >
</div>
<div
class="empty"
*ngIf="visibleFields.length === 0">
- This workflow has no inputs to fill in.
+ {{ authoring ? "No inputs yet. Open the workflow below and click a step to expose its settings." : "This workflow
+ has no inputs to fill in." }}
</div>
<div class="params">
@@ -207,7 +310,10 @@
aria-hidden="true"></i>
<span class="wf-titles">
<span class="wf-title">Workflow</span>
- <span class="wf-sub">Optional. The steps that will run.</span>
+ <span class="wf-sub"
+ >{{ authoring ? "Click a step to choose which of its settings people fill in." : "Optional. The steps that
+ will run." }}</span
+ >
</span>
<span class="spacer"></span>
</button>
@@ -237,17 +343,24 @@
aria-hidden="true"></i>
</button>
- <!-- A reader can open a step to view its settings, read-only. Read-only is enforced twice,
- at different layers, because neither layer alone is enough:
- [actsAsEditor]="false" stops every write the panel can make (co-editor
- awareness, operator version, operator properties) -- that is the one that matters, since
- merely opening a step makes ajv fill in schema defaults and emit a change; and `inert`
- stops the interaction, blocking pointer, keyboard and focus. Disabling the form is not a
- substitute for `inert`: the preset save/apply/delete buttons, the array add/remove
- buttons and the drag-to-reorder handles are plain controls that a disabled FormGroup
- does not reach. Turning the panel live for choosing what to expose is the authoring PR.
- The panel is the scroll container and takes the focus itself (tabindex, aria-label), so
- a keyboard reader can still scroll a long panel that `inert` content cannot hold focus in.
+ <!-- Reader / inspect: read-only is enforced twice, at different layers, because neither
+ layer alone is enough. [actsAsEditor]="false" stops every write the panel can make
+ (co-editor awareness, operator version, operator properties) -- that is the one that
+ matters, since merely opening a step makes ajv fill in schema defaults and emit a
+ change; and `inert` stops the interaction, blocking pointer, keyboard and focus.
+ Disabling the form is not a substitute for `inert`: the preset save/apply/delete
+ buttons, the array add/remove buttons and the drag-to-reorder handles are plain
+ controls that a disabled FormGroup does not reach. Since `inert` content cannot hold
+ focus, the panel takes the tab stop itself so a keyboard reader can still scroll a long
+ panel.
+ Authoring: the panel goes live -- inert off, acting as an editor, exposeChoosing on
+ (tick boxes to pick what the form exposes), so it edits like the canvas, which edit mode
+ has already enabled workflow modification for. Live follows the lock's own rule
+ (panelLive: edit mode, write access, and no run in flight), not edit mode alone: the
+ frame does not consult the lock before its own writes, so mid-run it stays a read-only,
+ inert mount and turns live when the run ends. The panel's own tab stop goes away with
+ inert, so it does not sit in front of the form it now contains. persistPlacement stays
+ off either way (this is not the docked canvas panel).
[hidden], not *ngIf: the property editor shows its operator by REACTING to the highlight
stream (no initial pull), so it must already be mounted and subscribed when the click
highlights a step. Mounting it on selection (*ngIf) subscribes too late, misses that
@@ -255,14 +368,14 @@
<div
class="panel"
role="group"
- tabindex="0"
- aria-label="Step settings, read-only"
+ [attr.tabindex]="panelLive ? null : 0"
+ [attr.aria-label]="panelLive ? 'Step settings' : 'Step settings, read-only'"
[hidden]="!selectedOperatorId">
<texera-property-editor
- [exposeChoosing]="false"
+ [exposeChoosing]="panelLive"
[persistPlacement]="false"
- [actsAsEditor]="false"
- [attr.inert]="''"></texera-property-editor>
+ [actsAsEditor]="panelLive"
+ [attr.inert]="panelLive ? null : ''"></texera-property-editor>
</div>
</div>
</section>
@@ -336,5 +449,37 @@
</div>
</ng-container>
</section>
+
+ <!-- Which results show after a run. Everyone gets the picker: in edit mode a pill sets the default
+ for all readers (written to the config); otherwise it changes only this viewer's page. A reader
+ with nothing to choose from sees no section; an author always does, with the hint on how to
+ add steps. The pills are tracked by step, not by object: a toggle rebuilds the choices as new
+ objects, and re-created buttons would drop the keyboard focus from the pill just pressed. -->
+ <section
+ class="card respick"
+ *ngIf="authoring || resultChoices.length > 0">
+ <h3>Results shown here</h3>
+ <p>
+ {{ authoring ? "What everyone sees after a run. Until you choose, every final step is on; an earlier step needs
+ its result view on the canvas first." : "Choose which results to show after a run. This changes only your view."
+ }}
+ </p>
+ <div class="opts">
+ <button
+ type="button"
+ class="pill"
+ *ngFor="let choice of resultChoices; trackBy: trackByChoice"
+ [class.on]="choice.shown"
+ [attr.aria-pressed]="choice.shown"
+ (click)="onToggleResult(choice)">
+ {{ choice.label }}
+ </button>
+ <p
+ class="hint"
+ *ngIf="resultChoices.length === 0">
+ No steps to choose from yet. Turn on a step's result view on the canvas to feature it here.
+ </p>
+ </div>
+ </section>
</div>
</div>
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 6202f803a2..54f962672f 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
@@ -195,20 +195,18 @@ $shell: #fafafa;
.instr {
margin-bottom: 26px;
+ /* The header row. Reading, its one child is the full-width toggle button, which carries the row's
+ padding so the whole row stays clickable; authoring, the row itself is padded and holds the
+ icon, the title input and a small chevron toggle. */
.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;
+
+ &.authoring {
+ padding: 13px 16px;
+ }
h2 {
margin: 0;
@@ -228,6 +226,40 @@ $shell: #fafafa;
}
}
+ .instr-toggle {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex: 1;
+ padding: 13px 16px;
+ cursor: pointer;
+ user-select: none;
+ appearance: none;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+
+ &:focus-visible {
+ outline: 2px solid $blue;
+ outline-offset: -2px;
+ border-radius: 8px;
+ }
+ }
+
+ /* The authoring toggle is only the chevron: the row's padding is on the row, and the title input
+ takes the width. */
+ .instr-chev {
+ flex: none;
+ padding: 4px;
+ border-radius: 4px;
+
+ &:focus-visible {
+ outline-offset: 0;
+ }
+ }
+
&.open .instr-bar .chev {
transform: rotate(0deg);
}
@@ -787,3 +819,154 @@ $shell: #fafafa;
}
}
}
+
+/* ============================================================================
+ Author mode: the in-place editing UI (Edit/Done). Everything here shows only
+ while authoring; a reader never renders these elements.
+ ============================================================================ */
+
+.pc-head {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin-top: 18px;
+
+ .lede {
+ margin: 0;
+ color: $text-2;
+ }
+
+ .spacer {
+ flex: 1;
+ }
+}
+
+.hint {
+ font-size: 12px;
+ color: $text-2;
+}
+
+/* Author edits the instruction heading right here: looks like the h2, with a faint dashed
+ box so it reads as editable; firms up on hover, a real input on focus. */
+.instr-title-input {
+ flex: 1;
+ min-width: 0;
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+ color: inherit;
+ background: none;
+ border: 1px dashed #dcdcdc;
+ border-radius: 4px;
+ padding: 2px 6px;
+ cursor: text;
+
+ &:hover {
+ border-style: solid;
+ border-color: #b0b0b0;
+ }
+
+ &:focus {
+ border-style: solid;
+ border-color: $blue;
+ background: #fff;
+ outline: none;
+ }
+}
+
+.tabs {
+ display: flex;
+ gap: 2px;
+ border-bottom: 1px solid $divider;
+ margin: -16px -16px 14px;
+ padding: 0 16px;
+
+ button {
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ padding: 9px 12px;
+ color: $text-2;
+ font-weight: 500;
+ cursor: pointer;
+
+ &[aria-current="true"] {
+ color: $blue;
+ border-bottom-color: $blue;
+ }
+
+ &:focus-visible {
+ outline: 2px solid $blue;
+ outline-offset: -2px;
+ }
+ }
+}
+
+.md-input {
+ width: 100%;
+ min-height: 180px;
+ resize: vertical;
+ border: 1px solid $border;
+ border-radius: 6px;
+ padding: 10px 12px;
+ font-family: "SFMono-Regular", Consolas, Menlo, monospace;
+ font-size: 13px;
+ line-height: 1.65;
+ background: $shell;
+
+ &:focus {
+ background: #fff;
+ border-color: $blue;
+ outline: none;
+ }
+}
+
+.respick {
+ margin-top: 26px;
+ padding: 15px 18px;
+
+ h3 {
+ margin: 0 0 3px;
+ font-size: 14px;
+ font-weight: 600;
+ }
+
+ p {
+ margin: 0 0 11px;
+ font-size: 13px;
+ color: $text-2;
+ }
+
+ .opts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ .pill {
+ padding: 5px 12px;
+ border-radius: 15px;
+ font-size: 13px;
+ border: 1px solid $border;
+ background: #fff;
+ color: $text-2;
+ cursor: pointer;
+
+ &:hover {
+ border-color: $blue;
+ color: $blue;
+ }
+
+ &:focus-visible {
+ outline: 2px solid $blue;
+ outline-offset: 2px;
+ }
+
+ &.on {
+ background: rgba(24, 144, 255, 0.1);
+ border-color: $blue;
+ color: $blue;
+ font-weight: 500;
+ }
+ }
+}
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 ccc125eb9b..e199a2187f 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
@@ -19,7 +19,8 @@
import { FormArray, FormControl, FormGroup, Validators } from "@angular/forms";
import { Router } from "@angular/router";
-import { of, throwError } from "rxjs";
+import { of, Subject, throwError } from "rxjs";
+import { Workflow } from "../../../common/type/workflow";
import { WorkflowFormComponent } from "./workflow-form.component";
import { setupHarness, formViewWorkflow, resolved } from "./workflow-form.spec-harness";
@@ -136,6 +137,90 @@ describe("WorkflowFormComponent", () => {
expect(workflowActionService.reloadWorkflow).toHaveBeenCalled();
});
+ /** The clamp runs a microtask after the unlock emission (observeOn asap); let that microtask run. */
+ const afterTheUnlockingCall = async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ };
+
+ it("puts the lock back whenever something else unlocks the graph while the page is not in edit mode", async () => {
+ // The lock is a root-level flag with writers that know nothing of this page: the execute service
+ // unlocks it when a run ends, the computing-unit selector when it finds no run on the chosen
+ // unit. A writer merely viewing must stay locked, or the preview's view-result command would
+ // write the shared graph without them ever entering edit mode.
+ build(formViewWorkflow).ngOnInit();
+ const before = workflowActionService.disableWorkflowModification.mock.calls.length;
+
+ h.modificationEnabled.next(true);
+
+ // Not inside the unlocking call: enableWorkflowModification enables undo/redo after it emits and
+ // the stream still has other subscribers to reach, so a nested disable would leave them on the
+ // stale "true". The clamp waits until that call has finished, then has the last word.
+ expect(workflowActionService.disableWorkflowModification.mock.calls.length).toBe(before);
+ await afterTheUnlockingCall();
+ expect(workflowActionService.disableWorkflowModification.mock.calls.length).toBe(before + 1);
+ expect(workflowActionService.enableWorkflowModification).not.toHaveBeenCalled();
+ });
+
+ it("ends up unlocked in edit mode once the run has ended, in the execute service's real order", async () => {
+ // In edit mode the canvas rule applies unchanged: the graph is unlocked once the run ends. The
+ // execute service flips the lock BEFORE it emits the new state; by the time the clamp looks, the
+ // state handler has re-applied the rule with the final state, so the unlock stands.
+ build(formViewWorkflow).ngOnInit();
+ component.authoring = true;
+ h.executionStateStream.next({ current: { state: ExecutionState.Running } });
+ workflowActionService.enableWorkflowModification.mockClear();
+ const before = workflowActionService.disableWorkflowModification.mock.calls.length;
+
+ h.modificationEnabled.next(true); // the execute service unlocks first...
+ h.executionStateStream.next({ current: { state: ExecutionState.Completed } }); // ...then emits
+ await afterTheUnlockingCall();
+
+ expect(workflowActionService.enableWorkflowModification).toHaveBeenCalled();
+ expect(workflowActionService.disableWorkflowModification.mock.calls.length).toBe(before);
+ });
+
+ it("keeps the graph locked when edit mode is entered mid-run, until the run ends", async () => {
+ // The canvas locks the graph while a run is in flight; entering edit mode must not undo that
+ // (the live panel and the view-result command would otherwise edit a running workflow). An
+ // unlock arriving mid-run is clamped; once the run ends the page unlocks.
+ build(formViewWorkflow).ngOnInit();
+ h.executionStateStream.next({ current: { state: ExecutionState.Running } });
+
+ component.toggleAuthoring();
+
+ expect(component.authoring).toBe(true);
+ expect(workflowActionService.enableWorkflowModification).not.toHaveBeenCalled();
+ const before = workflowActionService.disableWorkflowModification.mock.calls.length;
+ h.modificationEnabled.next(true); // something unlocks while the run is still in flight
+ await afterTheUnlockingCall();
+ expect(workflowActionService.disableWorkflowModification.mock.calls.length).toBe(before + 1);
+ expect(workflowActionService.enableWorkflowModification).not.toHaveBeenCalled();
+ h.executionStateStream.next({ current: { state: ExecutionState.Completed } });
+ expect(workflowActionService.enableWorkflowModification).toHaveBeenCalledTimes(1);
+ });
+
+ it("closes the step panel on Done while the frame is still an editor, so the editing marker is cleared", () => {
+ // The property editor clears the "currently editing" marker only while it acts as an editor;
+ // remounting it as a viewer clears nothing. So Done dismisses the panel first, then leaves
+ // edit mode, and co-editors stop seeing this session as editing the step.
+ build(formViewWorkflow).ngOnInit();
+ component.toggleAuthoring();
+ component.selectedOperatorId = "op-1";
+ h.highlightedIds.push("op-1");
+ let authoringWhenDismissed: boolean | undefined;
+ workflowActionService.unhighlightOperators.mockImplementationOnce(() => {
+ authoringWhenDismissed = component.authoring;
+ });
+
+ component.toggleAuthoring();
+
+ expect(workflowActionService.unhighlightOperators).toHaveBeenCalledWith("op-1");
+ expect(authoringWhenDismissed).toBe(true);
+ expect(component.selectedOperatorId).toBeUndefined();
+ expect(component.authoring).toBe(false);
+ });
+
it("goes back to the list when the workflow cannot be opened", () => {
build(formViewWorkflow);
workflowPersistService.retrieveWorkflow.mockReturnValue(throwError(() => new Error("denied")));
@@ -332,14 +417,187 @@ describe("WorkflowFormComponent", () => {
vi.useRealTimers();
});
- it("saves before handing over to the operator canvas", () => {
+ it("saves, then hands over to the operator canvas only once the save has completed", () => {
enableSave();
build(formViewWorkflow).ngOnInit();
workflowPersistService.persistWorkflow.mockClear();
+ const navigate = vi.spyOn(component as any, "openCanvasPage").mockImplementation(() => {});
component.openRegularCanvas();
+ // The full-page load aborts a request still in flight, so the navigation waits for the save
+ // to complete (the persist mock completes synchronously here).
expect(workflowPersistService.persistWorkflow).toHaveBeenCalled();
+ expect(navigate).toHaveBeenCalledTimes(1);
+ });
+
+ it("sends the switch's save only after an autosave already in flight, and navigates after both", () => {
+ // Two persists in flight at once can land out of order and the older content would win. The
+ // queue holds the switch's save until the autosave has completed, snapshots the workflow then,
+ // and hands over only once that later save has completed too.
+ vi.useFakeTimers();
+ enableSave();
+ build(formViewWorkflow).ngOnInit();
+ workflowPersistService.persistWorkflow.mockClear();
+ const autosave$ = new Subject<Workflow>();
+ const switchSave$ = new Subject<Workflow>();
+ workflowPersistService.persistWorkflow.mockReturnValueOnce(autosave$).mockReturnValueOnce(switchSave$);
+ const navigate = vi.spyOn(component as any, "openCanvasPage").mockImplementation(() => {});
+
+ h.workflowChangedStream.next(undefined);
+ vi.runAllTimers();
+ expect(workflowPersistService.persistWorkflow).toHaveBeenCalledTimes(1); // the autosave, in flight
+
+ component.openRegularCanvas();
+ expect(workflowPersistService.persistWorkflow).toHaveBeenCalledTimes(1); // the switch's save waits
+ expect(navigate).not.toHaveBeenCalled();
+
+ autosave$.complete();
+ expect(workflowPersistService.persistWorkflow).toHaveBeenCalledTimes(2); // now it goes out
+ expect(navigate).not.toHaveBeenCalled();
+
+ switchSave$.complete();
+ expect(navigate).toHaveBeenCalledTimes(1);
+ vi.useRealTimers();
+ });
+
+ it("does not let an older save's response undo a rename made while it was in flight", () => {
+ // Save A carries the old name. The author renames to B (B's own save is queued behind A). When
+ // A returns, its echoed name must not be written back over B, or an autosave in that window
+ // would carry the old name and the rename would be lost. The server-owned timestamp is kept.
+ vi.useFakeTimers();
+ enableSave();
+ build(formViewWorkflow).ngOnInit();
+ workflowPersistService.persistWorkflow.mockClear();
+ const saveA$ = new Subject<Workflow>();
+ workflowPersistService.persistWorkflow.mockReturnValueOnce(saveA$);
+ h.workflowChangedStream.next(undefined);
+ vi.runAllTimers();
+ expect(workflowPersistService.persistWorkflow).toHaveBeenCalledTimes(1);
+
+ // The rename lands in the shared metadata while A is still out.
+ workflowActionService.getWorkflowMetadata = () => ({ name: "B", lastModifiedTime: 1 });
+ saveA$.next({ ...formViewWorkflow, wid: 7, name: "scGPT", lastModifiedTime: 42 } as any);
+ saveA$.complete();
+
+ expect(workflowActionService.setWorkflowMetadata).toHaveBeenCalledTimes(1);
+ const fedBack = workflowActionService.setWorkflowMetadata.mock.calls[0][0];
+ expect(fedBack.name).toBe("B");
+ expect(fedBack.lastModifiedTime).toBe(42);
+ vi.useRealTimers();
+ });
+
+ it("hands over only once a save queued behind the switch's has completed too", () => {
+ // The page stays interactive while the switch's save is in flight, so an edit made then gets its
+ // own autosave queued behind it. Navigating on the switch's save alone would abort that newer
+ // save with the full-page load; the hand-over waits for the queue to drain.
+ vi.useFakeTimers();
+ enableSave();
+ build(formViewWorkflow).ngOnInit();
+ workflowPersistService.persistWorkflow.mockClear();
+ const switchSave$ = new Subject<Workflow>();
+ const laterSave$ = new Subject<Workflow>();
+ workflowPersistService.persistWorkflow.mockReturnValueOnce(switchSave$).mockReturnValueOnce(laterSave$);
+ const navigate = vi.spyOn(component as any, "openCanvasPage").mockImplementation(() => {});
+
+ component.openRegularCanvas();
+ h.workflowChangedStream.next(undefined); // an edit while the switch's save is in flight
+ vi.runAllTimers();
+ switchSave$.complete();
+ expect(workflowPersistService.persistWorkflow).toHaveBeenCalledTimes(2);
+ expect(navigate).not.toHaveBeenCalled(); // the later save is still out
+
+ laterSave$.complete();
+ expect(navigate).toHaveBeenCalledTimes(1);
+ vi.useRealTimers();
+ });
+
+ it("flushes an edit still waiting in the autosave debounce before handing over", () => {
+ // An edit made after the switch click enters the debounce, not the queue: when the switch's
+ // save completes the queue is empty, and navigating then would kill the debounce with the
+ // full-page load and lose the edit. The drain flushes it as one more save first.
+ vi.useFakeTimers();
+ enableSave();
+ build(formViewWorkflow).ngOnInit();
+ workflowPersistService.persistWorkflow.mockClear();
+ const switchSave$ = new Subject<Workflow>();
+ workflowPersistService.persistWorkflow.mockReturnValueOnce(switchSave$).mockReturnValue(of(formViewWorkflow));
+ const navigate = vi.spyOn(component as any, "openCanvasPage").mockImplementation(() => {});
+
+ component.openRegularCanvas();
+ h.workflowChangedStream.next(undefined); // an edit after the click; its debounce has NOT elapsed
+ switchSave$.complete(); // the queue drains while that edit still sits in the debounce
+
+ // The flush went out at once (no 5-second wait), and only its completion handed over.
+ expect(workflowPersistService.persistWorkflow).toHaveBeenCalledTimes(2);
+ expect(navigate).toHaveBeenCalledTimes(1);
+ vi.useRealTimers();
+ });
+
+ it("stays on the form when a save queued behind the switch's fails", () => {
+ vi.useFakeTimers();
+ enableSave();
+ build(formViewWorkflow).ngOnInit();
+ workflowPersistService.persistWorkflow.mockClear();
+ const laterSave$ = new Subject<Workflow>();
+ workflowPersistService.persistWorkflow.mockReturnValueOnce(of(formViewWorkflow)).mockReturnValueOnce(laterSave$);
+ const navigate = vi.spyOn(component as any, "openCanvasPage").mockImplementation(() => {});
+
+ // The switch's save completes at once, but by then an edit's autosave is already queued: without
+ // the queued edit the switch would have navigated here.
+ h.workflowChangedStream.next(undefined);
+ vi.runAllTimers();
+ component.openRegularCanvas();
+ // (the autosave, first in the queue, was the synchronous one; the switch's save is the later$)
+ laterSave$.error(new Error("nope"));
+
+ expect(navigate).not.toHaveBeenCalled();
+ expect(h.notificationService.error).toHaveBeenCalledWith(
+ "Could not save. Your latest changes are not stored yet."
+ );
+ vi.useRealTimers();
+ });
+
+ it("keeps saving after a failed save: the queue does not stop, and only that save reports", () => {
+ enableSave();
+ build(formViewWorkflow).ngOnInit();
+ workflowPersistService.persistWorkflow.mockClear();
+ workflowPersistService.persistWorkflow
+ .mockReturnValueOnce(throwError(() => new Error("nope")))
+ .mockReturnValueOnce(of(formViewWorkflow));
+ const navigate = vi.spyOn(component as any, "openCanvasPage").mockImplementation(() => {});
+
+ component.onRenameWorkflow(); // a save that fails
+ component.openRegularCanvas(); // the next one still goes out, and completes
+
+ expect(workflowPersistService.persistWorkflow).toHaveBeenCalledTimes(2);
+ expect(h.notificationService.error).toHaveBeenCalledTimes(1);
+ expect(navigate).toHaveBeenCalledTimes(1);
+ });
+
+ it("stays on the form and reports it when the save before the switch fails", () => {
+ enableSave();
+ build(formViewWorkflow).ngOnInit();
+ workflowPersistService.persistWorkflow.mockReturnValue(throwError(() => new Error("nope")));
+ const navigate = vi.spyOn(component as any, "openCanvasPage").mockImplementation(() => {});
+
+ component.openRegularCanvas();
+
+ expect(navigate).not.toHaveBeenCalled();
+ expect(h.notificationService.error).toHaveBeenCalledWith(
+ "Could not save. Your latest changes are not stored yet."
+ );
+ });
+
+ it("hands a reader with nothing to save straight over to the canvas", () => {
+ build({ ...formViewWorkflow, readonly: true }).ngOnInit();
+ workflowPersistService.persistWorkflow.mockClear();
+ const navigate = vi.spyOn(component as any, "openCanvasPage").mockImplementation(() => {});
+
+ component.openRegularCanvas();
+
+ expect(workflowPersistService.persistWorkflow).not.toHaveBeenCalled();
+ expect(navigate).toHaveBeenCalledTimes(1);
});
it("saves once more on the way out", () => {
@@ -352,6 +610,54 @@ describe("WorkflowFormComponent", () => {
expect(workflowPersistService.persistWorkflow).toHaveBeenCalled();
});
+ it("sends the final save behind an autosave still in flight, after the page is gone", () => {
+ // The queue outlives the component: the save on the way out waits for the autosave already on
+ // its way, so the older snapshot can never commit after the final one. The snapshot is taken
+ // when the save is asked for, before ngOnDestroy clears the graph.
+ vi.useFakeTimers();
+ enableSave();
+ build(formViewWorkflow).ngOnInit();
+ workflowPersistService.persistWorkflow.mockClear();
+ const autosave$ = new Subject<Workflow>();
+ workflowPersistService.persistWorkflow.mockReturnValueOnce(autosave$).mockReturnValueOnce(of(formViewWorkflow));
+ h.workflowChangedStream.next(undefined);
+ vi.runAllTimers();
+ expect(workflowPersistService.persistWorkflow).toHaveBeenCalledTimes(1); // the autosave, in flight
+
+ // What the graph holds as the page goes: ngOnDestroy clears it right after asking for the final
+ // save, so the save must carry the snapshot taken before that, not what the graph holds later.
+ const content = { operators: [], operatorPositions: {} };
+ workflowActionService.getWorkflow.mockReturnValue({ wid: 7, name: "as left", content });
+ workflowActionService.clearWorkflow.mockImplementation(() =>
+ workflowActionService.getWorkflow.mockReturnValue({ wid: 7, name: "cleared", content })
+ );
+ component.ngOnDestroy();
+ expect(workflowPersistService.persistWorkflow).toHaveBeenCalledTimes(1); // the final save waits
+ workflowActionService.setWorkflowMetadata.mockClear();
+
+ autosave$.next({ ...formViewWorkflow, wid: 7 } as any);
+ autosave$.complete();
+ expect(workflowPersistService.persistWorkflow).toHaveBeenCalledTimes(2); // now it goes out
+ expect(workflowPersistService.persistWorkflow.mock.calls.at(-1)[0].name).toBe("as left");
+ // Responses landing after the page is gone repaint nothing and do not refill the cleared graph.
+ expect(workflowActionService.setWorkflowMetadata).not.toHaveBeenCalled();
+ vi.useRealTimers();
+ });
+
+ it("reports a failed save on the way out instead of throwing", () => {
+ // The final save goes through the queue like any other, so its failure is reported the same
+ // way: the notification fires, and nothing is thrown out of ngOnDestroy.
+ enableSave();
+ build(formViewWorkflow).ngOnInit();
+ workflowPersistService.persistWorkflow.mockReturnValue(throwError(() => new Error("nope")));
+
+ expect(() => component.ngOnDestroy()).not.toThrow();
+
+ expect(h.notificationService.error).toHaveBeenCalledWith(
+ "Could not save. Your latest changes are not stored yet."
+ );
+ });
+
it("measures the name field after load, and no-ops when it is not in the DOM", () => {
vi.useFakeTimers();
const query = vi.spyOn(h.host.nativeElement, "querySelector");
@@ -963,7 +1269,6 @@ describe("WorkflowFormComponent", () => {
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.
@@ -979,7 +1284,6 @@ describe("WorkflowFormComponent", () => {
formBindingService.getConfig.mockReturnValue({
instruction: { title: "T", body: " " },
fields: [],
- resultOperatorIds: [],
});
build(formViewWorkflow).ngOnInit();
await Promise.resolve();
@@ -1186,15 +1490,69 @@ describe("WorkflowFormComponent", () => {
});
describe("showing the chosen results", () => {
- // The terminal always shows (the engine always materializes it); a chosen intermediate shows only
- // while it still has view-result on the canvas. The form never writes the view-result set
- // (display filter, per the settled design).
- const chosen = (resultOperatorIds: string[]) =>
- formBindingService.getConfig.mockReturnValue({ instruction: undefined, fields: [], resultOperatorIds });
+ // Until the author has chosen, the final steps show (the engine always materializes them); once
+ // there is a saved list, exactly its steps show, kept to those that still have a result on the
+ // canvas. The form never writes the view-result set (display filter, per the settled design).
+ const saved = (shownResultIds?: string[]) =>
+ formBindingService.getConfig.mockReturnValue({
+ instruction: undefined,
+ fields: [],
+ shownResultIds,
+ });
+
+ it("shows every final step until the author has chosen, then exactly the saved list", () => {
+ // No list (an untouched form, or one from before the field existed): every final step is on. A
+ // list: exactly those, so a step that becomes final after the author chose does not appear by
+ // itself, and an empty list means no results at all -- a choice the form can store.
+ build(formViewWorkflow).ngOnInit();
+ h.graphOperators.push({ operatorID: "last-a", operatorType: "Limit" });
+ h.graphOperators.push({ operatorID: "last-b", operatorType: "Limit" });
+ h.terminalIds.add("last-a");
+ h.terminalIds.add("last-b");
+
+ saved();
+ (component as any).readConfig();
+ expect(component.shownResultIds).toEqual(["last-a", "last-b"]);
+
+ saved(["last-a"]);
+ (component as any).readConfig();
+ expect(component.shownResultIds).toEqual(["last-a"]);
+
+ saved([]);
+ (component as any).readConfig();
+ expect(component.shownResultIds).toEqual([]);
+ });
+
+ it("lets a viewer pick their own results for this page, without writing anything", () => {
+ build(formViewWorkflow).ngOnInit();
+ saved();
+ h.graphOperators.push({ operatorID: "mid", operatorType: "Filter" });
+ h.graphOperators.push({ operatorID: "last", operatorType: "Limit" });
+ h.viewResultIds.add("mid");
+ h.terminalIds.add("last");
+ (component as any).readConfig();
+ expect(component.shownResultIds).toEqual(["last"]);
+ const mid = component.resultChoices.find(c => c.operatorID === "mid")!;
+ const last = component.resultChoices.find(c => c.operatorID === "last")!;
+
+ // Not in edit mode: the toggle is the viewer's own, so nothing goes to the shared config.
+ component.onToggleResult(mid);
+ expect(component.shownResultIds).toEqual(["last", "mid"]);
+ component.onToggleResult(last);
+ expect(component.shownResultIds).toEqual(["mid"]);
+ expect(h.formBindingService.toggleShownResult).not.toHaveBeenCalled();
+ // The picker's pills follow the viewer's choice.
+ expect(component.resultChoices.find(c => c.operatorID === "last")?.shown).toBe(false);
+ expect(component.resultChoices.find(c => c.operatorID === "mid")?.shown).toBe(true);
+
+ // Entering edit mode edits the default for everyone, so the viewer's own pick gives way to it.
+ component.toggleAuthoring();
+ expect(component.shownResultIds).toEqual(["last"]);
+ });
it("shows a chosen result only while its operator still has view-result on the canvas", () => {
build(formViewWorkflow).ngOnInit();
- chosen(["a", "b"]);
+ saved(["a", "b"]);
h.viewResultIds.add("a"); // b's eye is off on the canvas
(component as any).readConfig();
@@ -1207,7 +1565,7 @@ describe("WorkflowFormComponent", () => {
// The form must show it, or an author who picks the workflow's final operator -- the most natural
// choice -- would get a card that never appears.
build(formViewWorkflow).ngOnInit();
- chosen(["last"]);
+ saved(["last"]);
h.graphOperators.push({ operatorID: "last", operatorType: "Limit" });
h.terminalIds.add("last"); // no downstream link, and its eye is off
@@ -1220,21 +1578,58 @@ describe("WorkflowFormComponent", () => {
// A reader who never curates still sees the workflow's final result: the engine always
// materializes the terminal operator, so its result is always available to show.
build(formViewWorkflow).ngOnInit();
- chosen([]);
+ saved();
h.graphOperators.push({ operatorID: "last", operatorType: "Limit" });
h.hasOperatorIds.add("last");
- h.terminalIds.add("last"); // terminal, no eye, not chosen
+ h.terminalIds.add("last"); // terminal, no eye, nothing chosen
(component as any).readConfig();
expect(component.shownResultIds).toEqual(["last"]);
});
+ it("drops a shown step the moment the graph's shape makes it unavailable, and takes it off the picker", () => {
+ // A co-editor disables a shown final step (or deletes it, or gives it a downstream link): the
+ // graph reports that on its own streams, without a result update or a config change, and the
+ // card and the pill must go at once rather than wait for the next unrelated event.
+ build(formViewWorkflow).ngOnInit();
+ saved();
+ const last = { operatorID: "last", operatorType: "Limit", isDisabled: false };
+ h.graphOperators.push(last);
+ h.terminalIds.add("last");
+ (component as any).readConfig();
+ expect(component.shownResultIds).toEqual(["last"]);
+ expect(component.resultChoices.map(c => c.operatorID)).toEqual(["last"]);
+
+ last.isDisabled = true;
+ h.graphStructureChanged.next({});
+
+ expect(component.shownResultIds).toEqual([]);
+ expect(component.resultChoices).toEqual([]);
+ });
+
+ it("renames a step's pill the moment its display name changes on the canvas", () => {
+ // A rename in the live panel (or a co-editor's) reaches no other stream: no result update, no
+ // compilation, no config change. The pill must follow it at once.
+ build(formViewWorkflow).ngOnInit();
+ saved();
+ const op = { operatorID: "last", operatorType: "Limit", customDisplayName: "Old name" };
+ h.graphOperators.push(op);
+ h.terminalIds.add("last");
+ (component as any).readConfig();
+ expect(component.resultChoices.map(c => c.label)).toEqual(["Old name"]);
+
+ op.customDisplayName = "New name";
+ h.displayNameChanged.next({});
+
+ expect(component.resultChoices.map(c => c.label)).toEqual(["New name"]);
+ });
+
it("does not show a chosen non-terminal operator whose eye is off", () => {
// A mid-graph step with an enabled downstream link is materialized only when its eye is on;
// without the eye it produces no result, so the form must not show a card that sits forever empty.
build(formViewWorkflow).ngOnInit();
- chosen(["mid"]);
+ saved(["mid"]);
h.graphOperators.push({ operatorID: "mid", operatorType: "Filter" }); // enabled downstream, no eye
(component as any).readConfig();
@@ -1246,7 +1641,7 @@ describe("WorkflowFormComponent", () => {
// The backend's storage rule is out-degree 0 on the ENABLED plan, so an operator whose downstream
// link is disabled is terminal and gets materialized. The form must match, reading enabled links.
build(formViewWorkflow).ngOnInit();
- chosen([]);
+ saved();
h.graphOperators.push({ operatorID: "a", operatorType: "Filter" });
h.graphOperators.push({ operatorID: "b", operatorType: "Limit" });
h.disabledDownstream.add("a"); // a -> b link disabled, so a has no enabled downstream
@@ -1262,7 +1657,7 @@ describe("WorkflowFormComponent", () => {
// A disabled operator is not in the compiled plan, so the engine never materializes it; even with
// no downstream it must not be shown as a terminal result.
build(formViewWorkflow).ngOnInit();
- chosen([]);
+ saved();
h.graphOperators.push({ operatorID: "off", operatorType: "Limit", isDisabled: true });
h.terminalIds.add("off"); // no downstream, but disabled
@@ -1271,9 +1666,24 @@ describe("WorkflowFormComponent", () => {
expect(component.shownResultIds).toEqual([]);
});
+ it("does not show a disabled step's result, even with its eye on and picked", () => {
+ // The eye and the pick survive disabling the step, but the compiled plan leaves the step out, so
+ // there is never a result behind such a card.
+ build(formViewWorkflow).ngOnInit();
+ saved(["off", "last"]);
+ h.graphOperators.push({ operatorID: "off", operatorType: "Filter", isDisabled: true });
+ h.graphOperators.push({ operatorID: "last", operatorType: "Limit" });
+ h.viewResultIds.add("off");
+ h.terminalIds.add("last");
+
+ (component as any).readConfig();
+
+ expect(component.shownResultIds).toEqual(["last"]);
+ });
+
it("drops a card when the canvas view-result set changes, without a result update", () => {
build(formViewWorkflow).ngOnInit();
- chosen(["a", "b"]);
+ saved(["a", "b"]);
h.viewResultIds.add("a");
h.viewResultIds.add("b");
(component as any).readConfig();
@@ -1289,7 +1699,7 @@ describe("WorkflowFormComponent", () => {
it("cards only the chosen, viewed steps that actually produced a result", () => {
build(formViewWorkflow).ngOnInit();
- chosen(["produces", "produces-nothing"]);
+ saved(["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)
@@ -1302,7 +1712,7 @@ describe("WorkflowFormComponent", () => {
it("has no results when nothing chosen has produced anything", () => {
build(formViewWorkflow).ngOnInit();
- chosen(["a"]);
+ saved(["a"]);
h.viewResultIds.add("a");
(component as any).readConfig();
@@ -1590,4 +2000,188 @@ describe("WorkflowFormComponent", () => {
expect(component.selectedOperatorId).toBeUndefined();
});
});
+
+ describe("author mode", () => {
+ it("enters edit mode: opens the workflow, enables modification, re-reads the config", () => {
+ build(formViewWorkflow).ngOnInit();
+ const read = vi.spyOn(component as any, "readConfig");
+
+ component.toggleAuthoring();
+
+ expect(component.authoring).toBe(true);
+ expect(component.workflowOpen).toBe(true);
+ expect(h.workflowActionService.enableWorkflowModification).toHaveBeenCalled();
+ expect(read).toHaveBeenCalled();
+ });
+
+ it("leaves edit mode: collapses the workflow and locks modification back", () => {
+ build(formViewWorkflow).ngOnInit();
+ component.toggleAuthoring();
+ (h.workflowActionService.disableWorkflowModification as any).mockClear();
+
+ component.toggleAuthoring();
+
+ expect(component.authoring).toBe(false);
+ expect(component.workflowOpen).toBe(false);
+ expect(h.workflowActionService.disableWorkflowModification).toHaveBeenCalled();
+ });
+
+ it("refuses to enter edit mode without write access, at the method and not only the button", () => {
+ // The Edit button is not rendered for a reader, but every authoring action writes the shared
+ // config, so the method itself is the boundary: a reader stays a reader whoever calls it.
+ build({ ...formViewWorkflow, readonly: true }).ngOnInit();
+ expect(component.canEdit).toBe(false);
+ const read = vi.spyOn(component as any, "readConfig");
+
+ component.toggleAuthoring();
+
+ expect(component.authoring).toBe(false);
+ expect(read).not.toHaveBeenCalled();
+ expect(h.workflowActionService.enableWorkflowModification).not.toHaveBeenCalled();
+ });
+
+ it("always allows leaving edit mode, even if write access is gone", () => {
+ build(formViewWorkflow).ngOnInit();
+ component.toggleAuthoring();
+ expect(component.authoring).toBe(true);
+ component.canEdit = false;
+
+ component.toggleAuthoring();
+
+ expect(component.authoring).toBe(false);
+ expect(h.workflowActionService.disableWorkflowModification).toHaveBeenCalled();
+ });
+
+ it("lists the final steps and the viewed and chosen intermediate steps, with their shown state", () => {
+ build(formViewWorkflow).ngOnInit();
+ component.authoring = true;
+ h.graphOperators.push({ operatorID: "viewed-mid", operatorType: "Filter" });
+ h.graphOperators.push({ operatorID: "chosen-mid", operatorType: "Filter" });
+ h.graphOperators.push({ operatorID: "stale-pick", operatorType: "Filter" });
+ h.graphOperators.push({ operatorID: "plain-mid", operatorType: "Filter" });
+ h.graphOperators.push({ operatorID: "last", operatorType: "Limit" });
+ h.graphOperators.push({ operatorID: "last-off", operatorType: "Limit" });
+ // Disabled steps keep their eye and can even be in the saved picks, but are left out of the run,
+ // so neither is offered: featuring one could never show anyone anything.
+ h.graphOperators.push({ operatorID: "off-viewed", operatorType: "Filter", isDisabled: true });
+ h.graphOperators.push({ operatorID: "off-chosen", operatorType: "Filter", isDisabled: true });
+ h.viewResultIds.add("viewed-mid");
+ h.viewResultIds.add("chosen-mid");
+ h.viewResultIds.add("off-viewed");
+ h.terminalIds.add("last");
+ h.terminalIds.add("last-off");
+ // The author's saved list: last on, last-off left out, two intermediates and a disabled step.
+ h.formBindingService.getConfig.mockReturnValue({
+ instruction: undefined,
+ fields: [],
+ shownResultIds: ["chosen-mid", "stale-pick", "off-chosen", "last"],
+ });
+
+ (component as any).readConfig();
+
+ const byId = new Map(component.resultChoices.map(c => [c.operatorID, c]));
+ expect(byId.get("viewed-mid")).toMatchObject({ shown: false }); // has the eye, not on the list
+ expect(byId.get("chosen-mid")).toMatchObject({ shown: true }); // eye and on the list
+ // A listed step whose eye was turned off on the canvas stays offered (so it can be taken off the
+ // list) but reads as off: the pill shows what is actually displayed, and nothing is produced for
+ // it any more.
+ expect(byId.get("stale-pick")).toMatchObject({ shown: false });
+ expect(byId.has("plain-mid")).toBe(false); // no eye, not listed -> nothing to show, not offered
+ // The final steps are always offered, under their plain name like any other step.
+ expect(byId.get("last")).toMatchObject({ shown: true, label: "Limit" });
+ expect(byId.get("last-off")).toMatchObject({ shown: false }); // left off the list by the author
+ expect(byId.has("off-viewed")).toBe(false); // disabled: not in the run, eye or no eye
+ expect(byId.has("off-chosen")).toBe(false); // disabled: not in the run, listed or not
+ });
+
+ it("adds a step to the picker the moment its eye is turned on, without a re-read", () => {
+ build(formViewWorkflow).ngOnInit();
+ component.authoring = true;
+ h.graphOperators.push({ operatorID: "mid", operatorType: "Filter" });
+ h.formBindingService.getConfig.mockReturnValue({ instruction: undefined, fields: [] });
+ (component as any).readConfig();
+ expect(component.resultChoices.map(c => c.operatorID)).not.toContain("mid");
+
+ // The author gives "mid" the eye on the canvas: the view-result set changes, emitting no result
+ // update, so the picker must react to that stream directly (or the option would not appear).
+ h.viewResultIds.add("mid");
+ h.viewResultChanged.next({});
+
+ expect(component.resultChoices.map(c => c.operatorID)).toContain("mid");
+ });
+
+ it("builds the picker for a reader too, so they can choose what to see", () => {
+ build({ ...formViewWorkflow, readonly: true }).ngOnInit();
+ h.graphOperators.push({ operatorID: "last", operatorType: "Limit" });
+ h.terminalIds.add("last");
+
+ (component as any).readConfig();
+
+ expect(component.resultChoices.map(c => c.operatorID)).toEqual(["last"]);
+ expect(component.resultChoices[0].shown).toBe(true);
+ });
+
+ it("does not offer a reader a saved pick whose eye is off, since it could never turn on", () => {
+ // The author's stale pick stays listed in edit mode so it can be un-picked (see above); for a
+ // reader nothing is materialised for it, so a pill they can click but never turn on is left out.
+ build({ ...formViewWorkflow, readonly: true }).ngOnInit();
+ h.graphOperators.push({ operatorID: "stale-pick", operatorType: "Filter" });
+ h.graphOperators.push({ operatorID: "chosen-mid", operatorType: "Filter" });
+ h.graphOperators.push({ operatorID: "last", operatorType: "Limit" });
+ h.viewResultIds.add("chosen-mid");
+ h.terminalIds.add("last");
+ h.formBindingService.getConfig.mockReturnValue({
+ instruction: undefined,
+ fields: [],
+ shownResultIds: ["chosen-mid", "stale-pick"],
+ });
+
+ (component as any).readConfig();
+
+ expect(component.resultChoices.map(c => c.operatorID).sort()).toEqual(["chosen-mid", "last"]);
+ });
+
+ it("in edit mode, a toggle sets the default for everyone, starting the saved list from the final steps", () => {
+ // The service materialises the list on the first choice from the default handed in, which must
+ // be the final steps as they are now (the one terminal rule), so what the author saw is kept.
+ build(formViewWorkflow).ngOnInit();
+ component.authoring = true;
+ h.graphOperators.push({ operatorID: "last", operatorType: "Limit" });
+ h.terminalIds.add("last");
+ const read = vi.spyOn(component as any, "readConfig");
+
+ component.onToggleResult({ operatorID: "op-1", label: "Filter", shown: false });
+
+ expect(h.formBindingService.toggleShownResult).toHaveBeenCalledWith("op-1", ["last"]);
+ expect(read).toHaveBeenCalledTimes(1);
+ });
+
+ it("a writer merely viewing changes only their own view, like any reader", () => {
+ build(formViewWorkflow).ngOnInit();
+ expect(component.canEdit).toBe(true);
+ expect(component.authoring).toBe(false);
+ const read = vi.spyOn(component as any, "readConfig");
+
+ component.onToggleResult({ operatorID: "op-1", label: "Filter", shown: false });
+
+ expect(h.formBindingService.toggleShownResult).not.toHaveBeenCalled();
+ expect(read).not.toHaveBeenCalled();
+ });
+
+ it("saves the instruction as the author types, and previews on demand", () => {
+ build(formViewWorkflow).ngOnInit();
+ component.instructionTitle = "T";
+ component.instructionBody = "B";
+
+ component.onInstructionChange();
+ expect(h.formBindingService.updateConfig).toHaveBeenCalledWith({ instruction: { title: "T", body: "B" } });
+
+ const render = vi.spyOn(component as any, "renderInstruction");
+ component.setInstructionMode("write");
+ expect(render).not.toHaveBeenCalled();
+ component.setInstructionMode("preview");
+ expect(component.instructionMode).toBe("preview");
+ expect(render).toHaveBeenCalled();
+ });
+ });
});
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 b6c8f2ea42..aa4e63942a 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
@@ -31,8 +31,8 @@ import { NzTooltipModule } from "ng-zorro-antd/tooltip";
import { UserIconComponent } from "../../../dashboard/component/user/user-icon/user-icon.component";
import { cloneDeep } from "lodash-es";
import { MarkdownService } from "ngx-markdown";
-import { EMPTY, forkJoin, Subject, timer } from "rxjs";
-import { debounceTime, switchMap, takeUntil, tap } from "rxjs/operators";
+import { asapScheduler, EMPTY, forkJoin, merge, Observable, Subject, timer } from "rxjs";
+import { catchError, concatMap, debounceTime, finalize, observeOn, switchMap, takeUntil, tap } from "rxjs/operators";
import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant";
import { FormFieldBinding, Workflow, WorkflowContent } from "../../../common/type/workflow";
@@ -56,7 +56,7 @@ import { WorkflowResultService } from "../../service/workflow-result/workflow-re
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 { OperatorPredicate, 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";
@@ -95,6 +95,13 @@ interface RenderedField {
model: Record<string, unknown>;
}
+/** One row of the "which results to show" picker: a candidate step and whether it is currently shown. */
+interface ResultChoice {
+ operatorID: string;
+ label: string;
+ shown: boolean;
+}
+
/**
* The Form View: a second way to use a workflow. On top of the title-bar frame and the collapsible
* read-only workflow preview, this PR renders the inputs an author exposed -- each as its
@@ -107,9 +114,13 @@ interface RenderedField {
* underneath -- the final step's output plus the author's chosen view-result steps, each a table, a
* visualisation, or a compact "no result yet" -- reading the canvas's view-result set and never
* writing it. A reader can also click a step on the embedded preview to open its property panel
- * read-only: the panel writes nothing to the shared workflow and its content is 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.
+ * read-only: the panel writes nothing to the shared workflow and its content is inert. With write
+ * access, an Edit toggle turns the page into in-place authoring: write the instruction, pick which
+ * extra results to feature, and click a step to open its panel live -- its writes turn on and its
+ * tick boxes choose what the form exposes. Editing the exposed inputs themselves in place (rename,
+ * hide a sub-field, reorder, remove) is the next PR. A view, not a new object: every graph edit goes
+ * through the same shared graph the operator canvas edits, and the form-binding config is local
+ * until #8351 shares it.
*/
@UntilDestroy()
@Component({
@@ -143,6 +154,20 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
public autoSaveState = "";
/** Write access: only then does a filled-in value write back, and only then does the page save. */
public canEdit = false;
+ /** Edit mode: a writer authoring the form in place (write the instruction, pick results, open a
+ * step's panel live to expose settings). Off, the page is the read-only form a reader sees. */
+ public authoring = false;
+ /** While authoring, the instruction is edited as raw markdown ("write") or shown rendered
+ * ("preview"); a reader always sees it rendered. */
+ public instructionMode: "write" | "preview" = "write";
+ /** The "which results to show" picker: every candidate step with its shown flag. Everyone sees it:
+ * in edit mode a toggle sets the default for all readers (saved in the config); otherwise it is
+ * the viewer's own choice for this page (viewerResultIds), never written anywhere. */
+ public resultChoices: ResultChoice[] = [];
+ /** A viewer's own pick of results for this page, once they have toggled anything; undefined means
+ * "the author's default". Kept for the page's lifetime only, and cleared on entering edit mode so
+ * the author edits the real default rather than their own view of it. */
+ private viewerResultIds?: Set<string>;
/** The exposed inputs, resolved against the live graph, and the formly field built for each. */
private parameters: ResolvedField[] = [];
@@ -180,10 +205,11 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
public selectedOperatorId?: string;
/**
- * Which steps' results to show: the terminal (final) steps, whose results the engine always
- * materializes, plus the author's chosen `resultOperatorIds` kept to those that still have
- * view-result ("the eye") on the canvas. The form NEVER writes the canvas's view-result flags --
- * it only decides what it itself shows, so a canvas user's result-viewing is unaffected.
+ * Which steps' results to show: the author's saved list (`shownResultIds` in the config) or, until
+ * the author has chosen, the terminal (final) steps, whose results the engine always materializes;
+ * either way kept to steps that still have a result on the canvas. The form NEVER writes the
+ * canvas's view-result flags -- it only decides 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
@@ -200,6 +226,22 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
/** Set on teardown so deferred callbacks stop touching a view that is gone. */
private destroyed = false;
+ /** Save requests, each the workflow as it was when the save was asked for. Drained one at a time and
+ * in order (see registerAutoPersist), so two saves can never race each other to the backend; the
+ * last one enqueued is the latest content. */
+ private persistQueue = new Subject<Workflow>();
+ /** Saves enqueued and not yet finished; the queue is drained when this returns to zero. */
+ private queuedSaves = 0;
+ /** What to do once the queue has drained (the Canvas switch navigates then): every save enqueued up
+ * to that point, and any enqueued while waiting, has to have completed first. A failed save drops
+ * them, so nobody navigates away from changes that were never stored. */
+ private afterDrain: Array<() => void> = [];
+ /** An edit has happened since the last snapshot was enqueued. The autosave behind workflowChanged
+ * is debounced, so at the moment the queue drains such an edit may not be queued yet -- and the
+ * hand-over waiting on the drain would lose it to the full-page load. Set the moment an edit is
+ * reported (before the debounce), cleared when a snapshot is enqueued (it carries everything up
+ * to then), and checked by the drain, which flushes one more save instead of handing over. */
+ private dirtySinceLastEnqueue = false;
/** A rebuild of the inputs that arrived while the reader was typing, held until the typing ends. */
private rebuildDeferred = false;
@@ -308,15 +350,40 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
this.later(() => this.fitVisualisations(), 300);
});
+ // What has a result, and what the picker offers, also follows the graph's shape and names: a
+ // step deleted, disabled, given a downstream link, or renamed (a co-editor's edit included)
+ // changes what shows or what its pill says. Those edits arrive on their own streams; compilation
+ // re-reads the config after the structural ones too, but debounced and held while the reader is
+ // typing, and a rename reaches no other stream at all, so refresh the two derived collections
+ // from them directly, as for the eye below. Only these collections: nothing here rebuilds the inputs.
+ const graph = this.workflowActionService.getTexeraGraph();
+ merge(
+ graph.getOperatorAddStream(),
+ graph.getOperatorDeleteStream(),
+ graph.getLinkAddStream(),
+ graph.getLinkDeleteStream(),
+ graph.getDisabledOperatorsChangedStream(),
+ graph.getOperatorDisplayNameChangedStream()
+ )
+ .pipe(untilDestroyed(this))
+ .subscribe(() => {
+ this.refreshShownResults();
+ this.rebuildResultChoices();
+ this.cdr.markForCheck();
+ });
+
// 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()
+ graph
.getViewResultOperatorsChangedStream()
.pipe(untilDestroyed(this))
.subscribe(() => {
+ // An eye toggled on the canvas changes both what shows (a newly-viewed step) and what the
+ // author can pick, so rebuild the picker here too -- otherwise a just-eyed step would not
+ // appear as an option (and an un-eyed one would linger) until the next full re-read.
this.refreshShownResults();
+ this.rebuildResultChoices();
this.cdr.markForCheck();
});
@@ -373,6 +440,11 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
.subscribe(({ current }) => {
const wasRunning = this.isRunning;
this.executionState = current.state;
+ // Reconcile the lock with the new state. The execute service flips the lock BEFORE it emits
+ // the state (updateWorkflowActionLock runs first in updateExecutionState), so when a run ends
+ // the clamp below still saw "running" and locked the graph again; this is where edit mode
+ // gets it back. Every other state is a no-op re-application of the same rule.
+ this.applyEditability();
// 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
@@ -490,8 +562,10 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
this.workflowActionService.setNewSharedModel(wid, this.userService.getCurrentUser());
this.workflowActionService.reloadWorkflow(workflow);
// The workflow is shown, not edited, from here: dragging operators around or
- // deleting them belongs to the operator canvas.
+ // deleting them belongs to the operator canvas. Lock now, and keep it locked against
+ // anything else that unlocks the graph (clampEditability).
this.applyEditability();
+ this.clampEditability();
this.refreshSavedState();
this.later(() => this.adjustWorkflowNameWidth(), 0);
this.readConfig();
@@ -510,11 +584,60 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
}
/**
- * Show the workflow rather than edit it: the graph shape and its properties are read-only
- * on this page. A later PR's authoring mode makes properties editable with write access.
+ * Whether this page may hold the graph unlocked: a writer in edit mode, and no run in flight. The
+ * run rule is the canvas's own (a run locks the graph until it ends), kept here too so that entering
+ * edit mode mid-run cannot undo it. Every other state is locked, so a reader, or a writer merely
+ * viewing, cannot change the workflow through this page.
+ */
+ private mayUnlock(): boolean {
+ return this.authoring && this.canEdit && !this.isRunning;
+ }
+
+ /**
+ * Whether the step panel is live (acting as an editor, tick boxes on, not inert): the same rule as
+ * the lock, not edit mode alone. The property frame does not consult the lock before its own
+ * writes -- it syncs the operator version on mount, writes the schema defaults ajv fills in, and
+ * publishes the "currently editing" marker whenever it acts as an editor -- so a step selected
+ * while a run is in flight must mount read-only even in edit mode, and turns live when the run ends.
*/
+ public get panelLive(): boolean {
+ return this.mayUnlock();
+ }
+
+ /** Lock or unlock editing from the current state (see mayUnlock); the one reducer for both directions. */
private applyEditability(): void {
- this.workflowActionService.disableWorkflowModification();
+ if (this.mayUnlock()) {
+ this.workflowActionService.enableWorkflowModification();
+ } else {
+ this.workflowActionService.disableWorkflowModification();
+ }
+ }
+
+ /**
+ * The lock is a root-level flag with writers that know nothing of this page: the execute service
+ * unlocks it whenever a run ends (completed, failed, killed, reset), the computing-unit selector
+ * unlocks it when it finds no run on the chosen unit, and any future caller may too. Rather than
+ * chase each one, clamp at the one place they all report to: whenever the flag turns on while this
+ * page must stay locked, turn it off again. In edit mode the canvas rule then stands unchanged:
+ * locked while a run is in flight, unlocked once it ends -- the execute service flips the lock
+ * before it emits the new state, so that unlock is clamped here (this page still sees "running")
+ * and given back by the execution-state handler, which re-applies the rule with the new state.
+ *
+ * The clamp runs a microtask later (observeOn), never inside the unlocking call: enableWorkflow-
+ * Modification emits the flag and only then enables undo/redo, and the stream still has to reach
+ * its other subscribers. A disable nested inside that emission would leave undo/redo enabled and
+ * later subscribers ending on the stale "true"; run after the call has finished, the disable is the
+ * last word and every consumer sees the same locked state. Nothing a user does fits in that gap.
+ */
+ private clampEditability(): void {
+ this.workflowActionService
+ .getWorkflowModificationEnabledStream()
+ .pipe(observeOn(asapScheduler), untilDestroyed(this))
+ .subscribe(enabled => {
+ if (enabled && !this.mayUnlock()) {
+ this.workflowActionService.disableWorkflowModification();
+ }
+ });
}
// ---------------------------------------------------------------------------
@@ -539,39 +662,89 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
return ["TEXTAREA", "SELECT"].includes(active.tagName) || active.isContentEditable;
}
+ /**
+ * The steps a result can come from. The engine compiles only the enabled operators, so a disabled
+ * step produces nothing however it is flagged: not offered in the picker, not shown as a result.
+ */
+ private enabledOperators(): OperatorPredicate[] {
+ return this.workflowActionService
+ .getTexeraGraph()
+ .getAllOperators()
+ .filter(op => !(op.isDisabled ?? false));
+ }
+
+ /**
+ * The picker's candidates: every final step (the engine always materialises it) and every
+ * intermediate step with view-result ("the eye") on the canvas. In edit mode a step already on the
+ * author's saved list is kept as well, eye or no eye, so the author can take a stale choice off the
+ * list; a reader is not offered it, since with no result materialised its pill could never turn on.
+ * Reuse the one terminal rule (terminalOperatorIds) rather than a second copy. Disabled steps are
+ * not offered at all, listed or not: they are left out of the run, so choosing one could never show
+ * anyone anything. The shown flag mirrors shownResultIds, so it reflects the viewer's own choice when
+ * they have made one and the author's default otherwise.
+ */
+ private rebuildResultChoices(): void {
+ const config = this.formBindingService.getConfig();
+ const viewed = this.workflowActionService.getTexeraGraph().getOperatorsToViewResult();
+ const listed = new Set(this.authoring && this.canEdit ? config.shownResultIds ?? [] : []);
+ const terminals = new Set(this.terminalOperatorIds());
+ const shown = new Set(this.shownResultIds);
+ this.resultChoices = this.enabledOperators()
+ .filter(op => terminals.has(op.operatorID) || viewed.has(op.operatorID) || listed.has(op.operatorID))
+ .map(op => ({
+ operatorID: op.operatorID,
+ label: this.formBindingService.operatorLabel(op),
+ shown: shown.has(op.operatorID),
+ }));
+ }
+
+ /** Re-read the saved form config and rebuild everything derived from it. */
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.rebuildResultChoices();
+ // Readers always see the instruction rendered; an author sees it rendered only while previewing
+ // (otherwise they are editing the raw markdown in the textarea).
+ if (!this.authoring || this.instructionMode === "preview") {
+ void this.renderInstruction();
+ }
this.buildForm();
}
/**
* Decide which operators' result cards to show. The engine materializes a result for every terminal
* operator (no enabled downstream) as well as every view-result operator, so a terminal's result is
- * always available; the form shows terminals by default and layers the author's chosen view-result
- * steps on top. This is a pure display filter that reads the graph and never writes it, so a normal
- * canvas user's result-viewing is unaffected. A step that is neither viewed nor terminal (or was
- * deleted) drops out rather than rendering a stale card.
+ * always available. The author's default is the saved list (shownResultIds: exactly those steps, an
+ * empty list meaning none) or, until the author has chosen, every terminal step. A viewer who has
+ * toggled the picker on this page sees their own set instead (viewerResultIds), which is never
+ * written anywhere. Either way this is a pure display filter that reads the graph and never writes
+ * it, so a normal canvas user's result-viewing is unaffected. A step that is neither viewed nor
+ * terminal (or was deleted) drops out rather than rendering a stale card.
*/
private refreshShownResults(): void {
const graph = this.workflowActionService.getTexeraGraph();
const viewed = graph.getOperatorsToViewResult();
// A result exists for an operator that is view-result (the eye) or terminal (no enabled
// downstream); the engine materializes both (WorkflowCompiler stores terminal operators plus
- // opsToViewResult). A view-result id is always a live operator; a terminal id comes from
- // terminalOperatorIds (live, enabled operators only), so both branches reference real steps.
+ // opsToViewResult) -- but only for enabled operators, since a disabled one is left out of the
+ // compiled plan. A terminal id comes from terminalOperatorIds (enabled operators only) already; a
+ // view-result id keeps its eye while disabled, so it is checked against the enabled set here.
const terminals = new Set(this.terminalOperatorIds());
- const availableOnCanvas = (id: string): boolean => viewed.has(id) || terminals.has(id);
- const chosen = this.formBindingService.getConfig().resultOperatorIds;
- // The terminal (final) operator's result always shows -- the engine always materializes it, so it
- // cannot be turned off. resultOperatorIds adds extra intermediate (view-result) steps on top. The
+ const disabled = new Set(
+ graph
+ .getAllOperators()
+ .filter(op => op.isDisabled ?? false)
+ .map(op => op.operatorID)
+ );
+ const availableOnCanvas = (id: string): boolean => (viewed.has(id) && !disabled.has(id)) || terminals.has(id);
+ // The author's default: the saved list when there is one, otherwise the final steps. The
// downstream hasNonEmptyResult filter drops steps that produced no data.
- this.shownResultIds = [...new Set([...terminals, ...chosen])].filter(availableOnCanvas);
+ const authorsDefault = this.formBindingService.getConfig().shownResultIds ?? [...terminals];
+ const wanted = this.viewerResultIds ? [...this.viewerResultIds] : authorsDefault;
+ this.shownResultIds = [...new Set(wanted)].filter(availableOnCanvas);
}
/** The workflow's terminal operators: enabled operators with no enabled downstream link. Matches the
@@ -822,7 +995,7 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
/**
* The inputs a reader is offered. Broken bindings (the operator was deleted, or the property key
* no longer exists) are left out, since filling one in could not affect a run; the author's view
- * of them, to repair them, is added by the authoring PR.
+ * of them, to remove them, comes with the input-authoring PR.
*/
public get visibleFields(): ResolvedField[] {
return this.parameters.filter(field => !field.brokenReason);
@@ -832,6 +1005,12 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
return rendered.resolved.binding.id;
}
+ /** A pill per step: a toggle rebuilds the choices as new objects, and the pressed pill must stay the
+ * same element or the keyboard focus on it is lost. */
+ public trackByChoice(_: number, choice: ResultChoice): string {
+ return choice.operatorID;
+ }
+
// ---------------------------------------------------------------------------
// Results: the final step's output plus the chosen steps', shown under the workflow that produced it
// ---------------------------------------------------------------------------
@@ -983,6 +1162,85 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
this.instructionOpen = !this.instructionOpen;
}
+ // ---------------------------------------------------------------------------
+ // Author mode: editing the form in place (write access only). Graph edits go through the same
+ // shared graph the operator canvas edits; form-binding edits go through the form-binding config
+ // (local until #8351 shares it). Each edit then re-reads the config.
+ // ---------------------------------------------------------------------------
+
+ public toggleAuthoring(): void {
+ // Entering edit mode needs write access. The Edit button is only rendered for a writer, but the
+ // guard belongs here, at the method, so no other caller can put a reader into a mode whose every
+ // action writes the shared config. Leaving edit mode is always allowed.
+ if (!this.authoring && !this.canEdit) {
+ return;
+ }
+ if (this.authoring) {
+ // Leaving: dismiss the step panel BEFORE the frame stops being an editor. The property editor
+ // clears the "currently editing" marker it published only while it acts as one (the same gate as
+ // the publish), and a remount as a viewer clears nothing -- co-editors would keep seeing this
+ // session as editing the step after Done.
+ this.closeOperatorPanel();
+ }
+ this.authoring = !this.authoring;
+ if (this.authoring) {
+ // An author picks fields off the workflow, so show it.
+ this.showWorkflow();
+ // In edit mode the picker sets the default for everyone, so the author's own view of the
+ // results (if they toggled any as a viewer) gives way to that default.
+ this.viewerResultIds = undefined;
+ } else {
+ this.workflowOpen = false;
+ }
+ // Edit mode is what makes operator properties (and the embedded canvas) editable here.
+ this.applyEditability();
+ this.readConfig();
+ }
+
+ public onInstructionChange(): void {
+ this.formBindingService.updateConfig({
+ instruction: { title: this.instructionTitle, body: this.instructionBody },
+ });
+ }
+
+ public setInstructionMode(mode: "write" | "preview"): void {
+ this.instructionMode = mode;
+ if (mode === "preview") {
+ void this.renderInstruction();
+ }
+ }
+
+ /**
+ * A pill in the picker. In edit mode this sets the default everyone sees: the step joins or leaves
+ * the saved list (which the first choice starts from the final steps, the default until then), and
+ * the config is re-read. Anyone else, a writer merely viewing included, only changes their own view
+ * of this page: nothing is written, so a reader without write access can choose too.
+ */
+ public onToggleResult(choice: ResultChoice): void {
+ if (this.authoring && this.canEdit) {
+ this.formBindingService.toggleShownResult(choice.operatorID, this.terminalOperatorIds());
+ this.readConfig();
+ return;
+ }
+ const next = new Set(this.shownResultIds);
+ if (next.has(choice.operatorID)) {
+ next.delete(choice.operatorID);
+ } else {
+ next.add(choice.operatorID);
+ }
+ this.viewerResultIds = next;
+ this.refreshShownResults();
+ this.rebuildResultChoices();
+ this.cdr.markForCheck();
+ }
+
+ private showWorkflow(): void {
+ if (!this.workflowOpen) {
+ this.workflowOpen = true;
+ this.openWorkflowStrip();
+ }
+ }
+
// ---------------------------------------------------------------------------
// 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
@@ -1278,21 +1536,85 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
* of yourself, broken runs. A fresh document is the reliable handover.
*/
public openRegularCanvas(): void {
- this.save();
- /* v8 ignore start -- full-document navigation; jsdom cannot navigate */
+ // Save first and hand over only once the save has completed: the full-page load unloads this
+ // document, and a request still in flight at that moment is aborted, so navigating right after
+ // firing the save could lose the very edit the switch is meant to carry across. A save that
+ // fails keeps the author here with the error shown, rather than leaving with changes that were
+ // never stored. A reader, who has nothing to save, goes straight over.
+ this.save(() => this.openCanvasPage());
+ }
+
+ /**
+ * The full-page handover to the operator canvas, apart from the save so the order is testable.
+ * Excluded from coverage as a whole: jsdom cannot navigate, so the specs stub this method and
+ * assert when it is called rather than what it does.
+ */
+ /* v8 ignore start */
+ private openCanvasPage(): void {
window.location.href = `${USER_WORKSPACE}/${this.wid}`;
- /* v8 ignore stop */
}
+ /* v8 ignore stop */
/**
* Save the same way the operator canvas does. Both views edit one workflow, so the
* form has to write through the same debounced persist -- otherwise an author's
* setup, or a value someone filled in, would be gone on the next visit.
+ *
+ * Saves go out one at a time, in order (persistQueue). Two persists in flight at once can reach
+ * the backend out of order, and then the older content wins; with the queue, the save behind the
+ * Canvas switch is sent only after an autosave already on its way has completed, and the last
+ * one enqueued carries the latest content. The drain is deliberately NOT tied to this component's
+ * lifetime: the final save on the way out (ngOnDestroy) has to line up behind an autosave still in
+ * flight too, or the older snapshot could commit after it. ngOnDestroy completes the queue, so the
+ * drain ends by itself once the last save has gone out; every request is a one-shot HTTP call.
*/
private registerAutoPersist(): void {
+ this.persistQueue
+ .pipe(
+ concatMap(workflow =>
+ this.persistNow(workflow).pipe(
+ // A failed save has reported itself; it must not let anyone navigate away from changes
+ // that were never stored, so whatever was waiting for the drain is dropped.
+ tap({ error: () => (this.afterDrain = []) }),
+ // A failed save must not take the queue down with it: the next one still goes out.
+ catchError(() => EMPTY),
+ // Complete or failed, this save is done. Once nothing is left in the queue, run what was
+ // waiting for the drain: by then every save asked for so far, this one and any enqueued
+ // behind it while it was in flight, has gone out and come back.
+ finalize(() => {
+ this.queuedSaves--;
+ if (this.queuedSaves === 0) {
+ // Hand-over is waiting, but an edit arrived after the last snapshot and its debounced
+ // autosave has not fired yet: the full-page load would kill that edit. Flush it into
+ // the queue first; this drain check runs again once the flush has gone out. (When the
+ // flush cannot be enqueued -- save()'s own guards -- fall through as save() itself
+ // would: there is nothing left this page can store.)
+ if (this.afterDrain.length > 0 && this.dirtySinceLastEnqueue) {
+ this.save();
+ }
+ if (this.queuedSaves > 0) {
+ return;
+ }
+ const waiting = this.afterDrain;
+ this.afterDrain = [];
+ waiting.forEach(run => run());
+ }
+ })
+ )
+ )
+ )
+ // Deliberately no untilDestroyed: the drain must outlive the component (see above) and ends
+ // when ngOnDestroy completes the queue.
+ // eslint-disable-next-line rxjs-angular/prefer-takeuntil
+ .subscribe();
this.workflowActionService
.workflowChanged()
- .pipe(debounceTime(SAVE_DEBOUNCE_TIME_IN_MS), untilDestroyed(this))
+ .pipe(
+ // Mark the edit before the debounce, so the drain can tell an edit is still waiting in it.
+ tap(() => (this.dirtySinceLastEnqueue = true)),
+ debounceTime(SAVE_DEBOUNCE_TIME_IN_MS),
+ untilDestroyed(this)
+ )
.subscribe(() => this.save());
}
@@ -1301,40 +1623,67 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
* workflow when the payload has no id, so saving whatever the graph holds would spawn
* stray "Untitled workflow" rows when the page is left before its workflow loaded.
*/
- private save(): void {
+ private save(afterwards?: () => void): void {
// A read-only viewer can open and run the form (execution is gated on computing-unit access,
// not workflow access) but must never persist: every such save is a guaranteed 403 that would
// spam "Could not save" on each debounce. Their inputs are non-editable, so nothing is lost.
+ // `afterwards` runs once every queued save has completed -- this one and any asked for while it
+ // was in flight -- or at once when there is nothing to save; it does not run when a save fails,
+ // so a caller that navigates on it stays put instead.
if (!this.canEdit) {
+ afterwards?.();
return;
}
if (!this.userService.isLogin() || !this.workflowPersistService.isWorkflowPersistEnabled()) {
+ afterwards?.();
return;
}
const workflow = this.workflowActionService.getWorkflow();
if (workflow.wid === undefined || workflow.wid !== this.wid) {
+ afterwards?.();
return;
}
+ // Snapshot now, not when the request's turn comes: on the way out ngOnDestroy clears the graph
+ // right after asking for this save, and the queue may only reach it after that.
const preserved: Workflow = {
...workflow,
content: { ...workflow.content, operatorPositions: this.positionsToSave(workflow.content) },
};
- // On the way out the subscription must NOT be tied to this component: ngOnDestroy
- // calls save(), and untilDestroyed would tear the subscription down as part of the
- // very same destroy sequence, aborting the request that was the point of the call.
- const persist = this.workflowPersistService.persistWorkflow(preserved);
- // The `destroyed` branch deliberately omits untilDestroyed (see above); the persist call
- // is a one-shot HTTP request that completes on its own, so it needs no teardown operator.
- // eslint-disable-next-line rxjs-angular/prefer-takeuntil
- (this.destroyed ? persist : persist.pipe(untilDestroyed(this))).subscribe({
- // Feed the saved workflow back, exactly as the operator canvas does: this advances
- // lastModifiedTime (and the normalised name), and the metadata subscription then repaints
- // the title bar -- so "Saved at ..." moves past the moment the page opened.
- next: updatedWorkflow => this.workflowActionService.setWorkflowMetadata(updatedWorkflow),
- // A save that fails silently is the worst thing this page can do: the author walks
- // away believing the form they just built is stored.
- error: () => this.notificationService.error("Could not save. Your latest changes are not stored yet."),
- });
+ if (afterwards) {
+ this.afterDrain.push(afterwards);
+ }
+ // The snapshot above carries everything reported up to now, the debounce included.
+ this.dirtySinceLastEnqueue = false;
+ this.queuedSaves++;
+ this.persistQueue.next(preserved);
+ }
+
+ /** One persist of the given snapshot, reporting its outcome; the queue orders them. */
+ private persistNow(preserved: Workflow): Observable<Workflow> {
+ return this.workflowPersistService.persistWorkflow(preserved).pipe(
+ tap({
+ // Feed the saved workflow back, exactly as the operator canvas does: this advances
+ // lastModifiedTime (and the normalised name), and the metadata subscription then repaints
+ // the title bar -- so "Saved at ..." moves past the moment the page opened.
+ next: updatedWorkflow => {
+ // The page may be gone by the time a queued save returns (the drain outlives it): the graph
+ // has been cleared, so there is nothing to repaint and the singleton must not be refilled.
+ if (this.destroyed) {
+ return;
+ }
+ // The response reflects the snapshot that was sent. A rename made since must not be undone
+ // by it (its own save is already queued behind this one); what this feedback is for is the
+ // server-owned part, the timestamp above all, and the normalised name when nothing changed.
+ const current = this.workflowActionService.getWorkflowMetadata();
+ this.workflowActionService.setWorkflowMetadata(
+ current.name !== preserved.name ? { ...updatedWorkflow, name: current.name } : updatedWorkflow
+ );
+ },
+ // A save that fails silently is the worst thing this page can do: the author walks
+ // away believing the form they just built is stored.
+ error: () => this.notificationService.error("Could not save. Your latest changes are not stored yet."),
+ })
+ );
}
/**
@@ -1379,7 +1728,10 @@ export class WorkflowFormComponent implements OnInit, OnDestroy {
@HostListener("window:beforeunload")
ngOnDestroy(): void {
this.destroyed = true;
+ // The final save joins the queue behind anything still in flight, then the queue is closed: the
+ // drain (not tied to this component) sends what is left in order and ends by itself.
this.save();
+ this.persistQueue.complete();
this.workflowActionService.clearWorkflow();
this.computingUnitStatusService.disconnect();
this.executeWorkflowService.resetExecutionAndWorkers();
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 ee31478736..06e5605c3d 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,7 +18,6 @@
*/
import { DatePipe } from "@angular/common";
-import { Component, Input } from "@angular/core";
import { FormGroup } from "@angular/forms";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
@@ -78,20 +77,6 @@ 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
-// inputs the template binds and nothing else -- which also lets a test read back the three that make
-// the mount a viewer rather than an editor. 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() actsAsEditor = true;
-}
-
describe("WorkflowFormComponent (rendered template)", () => {
let fixture: ComponentFixture<WorkflowFormComponent>;
let workflow$: Subject<any>;
@@ -124,15 +109,18 @@ describe("WorkflowFormComponent (rendered template)", () => {
// results markup -- the section, the card, the head, the zoom controls -- rendered and covered.
TestBed.overrideComponent(ResultTableFrameComponent, { set: { template: "" } });
TestBed.overrideComponent(VisualizationFrameContentComponent, { set: { template: "" } });
+ // Blank the always-mounted property panel the same way, and switch off its lifecycle hooks: its
+ // ngOnInit subscribes to the highlight streams and the panel service, its ngOnChanges remounts
+ // the frame, and it has its own spec for all of that. This page only needs the panel present
+ // (it lives behind [hidden], not *ngIf) with the three inputs the template binds readable. The
+ // real class stays in the page's imports on purpose: swapping it for a stub means overriding the
+ // page's imports, which JIT-recompiles the page and drops every line of its template from the
+ // coverage report (the .component.html then reads 0%, as it did while a stub was used here).
+ TestBed.overrideComponent(PropertyEditorComponent, { 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] },
- });
+ for (const hook of ["ngOnInit", "ngOnChanges", "ngOnDestroy"] as const) {
+ vi.spyOn(PropertyEditorComponent.prototype, hook).mockImplementation(() => {});
+ }
await TestBed.configureTestingModule({
// forRoot registers the FormlyConfig the form builder needs: the page imports FormlyModule
@@ -154,6 +142,7 @@ describe("WorkflowFormComponent (rendered template)", () => {
setNewSharedModel: vi.fn(),
reloadWorkflow: vi.fn(),
disableWorkflowModification: vi.fn(),
+ getWorkflowModificationEnabledStream: () => EMPTY,
clearWorkflow: vi.fn(),
getWorkflowMetadata: () => ({ name: "scGPT", lastModifiedTime: undefined }),
getWorkflow: () => ({ wid: 7, content: { operators: [], operatorPositions: {} } }),
@@ -171,6 +160,12 @@ describe("WorkflowFormComponent (rendered template)", () => {
getAllEnabledLinks: () => [],
getOperatorsToViewResult: () => new Set<string>(),
getViewResultOperatorsChangedStream: () => EMPTY,
+ getOperatorAddStream: () => EMPTY,
+ getOperatorDeleteStream: () => EMPTY,
+ getLinkAddStream: () => EMPTY,
+ getLinkDeleteStream: () => EMPTY,
+ getDisabledOperatorsChangedStream: () => EMPTY,
+ getOperatorDisplayNameChangedStream: () => EMPTY,
updateSharedModelAwareness: vi.fn(),
}),
getJointGraphWrapper: () => ({
@@ -197,11 +192,13 @@ describe("WorkflowFormComponent (rendered template)", () => {
getConfig: () => ({
instruction: { title: "How to use this", body: "Fill in the inputs." },
fields: [],
- resultOperatorIds: [],
}),
resolveFields: () => [],
readValue: () => undefined,
writeValue: vi.fn(),
+ // Author-mode writes the rendered controls reach.
+ updateConfig: vi.fn(),
+ toggleShownResult: vi.fn(),
},
},
{ provide: FormlyJsonschema, useValue: { toFieldConfig: () => ({ fieldGroup: [] }) } },
@@ -288,6 +285,7 @@ describe("WorkflowFormComponent (rendered template)", () => {
};
beforeEach(configure);
+ afterEach(() => vi.restoreAllMocks());
it("renders the workflow's avatar and name in the title row", async () => {
fixture.detectChanges(); // ngOnInit -> load()
@@ -405,8 +403,159 @@ describe("WorkflowFormComponent (rendered template)", () => {
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();
+ // Reading, the whole header row is one toggle button, wired to the body it opens.
+ const toggle = el(".instr-toggle") as HTMLButtonElement;
+ expect(toggle.getAttribute("aria-controls")).toBe("instr-body");
+ expect(el("#instr-body")).not.toBeNull();
+ toggle.click();
expect(fixture.componentInstance.instructionOpen).toBe(false);
+ fixture.detectChanges();
+ expect(toggle.getAttribute("aria-expanded")).toBe("false");
+ });
+
+ it("keeps the author's title input outside the toggle button", async () => {
+ fixture.detectChanges();
+ finishLoad();
+ const c = fixture.componentInstance;
+ c.canEdit = true;
+ c.authoring = true;
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ // An input nested in a button is invalid interactive content; the author's header is a row with
+ // the input as a sibling of a chevron button that does the toggling.
+ const input = el(".instr-title-input") as HTMLInputElement;
+ expect(input).not.toBeNull();
+ expect(input.closest("button")).toBeNull();
+ const chevron = el(".instr-chev") as HTMLButtonElement;
+ expect(chevron.getAttribute("aria-controls")).toBe("instr-body");
+ expect(chevron.getAttribute("aria-expanded")).toBe("true");
+ chevron.click();
+ expect(c.instructionOpen).toBe(false);
+ });
+
+ it("offers Edit to a writer only, and flips it to Done with the lede while authoring", () => {
+ fixture.detectChanges();
+ // A reader (read-only workflow) has no Edit control at all, not a disabled one.
+ finishLoad({ name: "scGPT", content: {}, readonly: true });
+ const c = fixture.componentInstance;
+ expect(c.canEdit).toBe(false);
+ expect(el(".author-toggle")).toBeNull();
+
+ c.canEdit = true;
+ fixture.detectChanges();
+ // Stubbed: the real toggle opens the workflow strip, whose JointJS paper needs layout jsdom
+ // lacks. The wiring from the button is what this test is about.
+ const toggle = vi.spyOn(c, "toggleAuthoring").mockImplementation(() => {});
+ const button = el(".author-toggle") as HTMLButtonElement;
+ expect(button.textContent?.trim()).toBe("Edit");
+ expect(el(".lede")).toBeNull();
+ button.click();
+ expect(toggle).toHaveBeenCalledTimes(1);
+
+ c.authoring = true;
+ fixture.detectChanges();
+ expect((el(".author-toggle") as HTMLButtonElement).textContent?.trim()).toBe("Done");
+ expect(el(".lede")).not.toBeNull();
+ });
+
+ it("edits the instruction in place while authoring: Write / Preview tabs, heading and body", async () => {
+ fixture.detectChanges();
+ finishLoad();
+ const c = fixture.componentInstance;
+ c.canEdit = true;
+ c.authoring = true;
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ const changed = vi.spyOn(c, "onInstructionChange");
+ const mode = vi.spyOn(c, "setInstructionMode");
+
+ // Write is the default: the markdown box is up and the rendered preview is not.
+ expect(el("textarea.md-input")).not.toBeNull();
+ expect(el("#instr-body .md")).toBeNull();
+ const tabs = Array.from(fixture.nativeElement.querySelectorAll(".tabs button")) as HTMLButtonElement[];
+ expect(tabs.map(t => t.textContent?.trim())).toEqual(["Write", "Preview"]);
+ expect(tabs[0].getAttribute("aria-current")).toBe("true");
+
+ // Typing into the heading or the body saves through onInstructionChange. Both carry a name of
+ // their own: a placeholder is gone as soon as there is text, so it is not one.
+ const title = el(".instr-title-input") as HTMLInputElement;
+ expect(title.getAttribute("aria-label")).toBe("Instruction heading");
+ title.value = "Start here";
+ title.dispatchEvent(new Event("input"));
+ expect(c.instructionTitle).toBe("Start here");
+ const body = el("textarea.md-input") as HTMLTextAreaElement;
+ expect(body.getAttribute("aria-label")).toBe("Instruction body");
+ body.value = "Pick a file.";
+ body.dispatchEvent(new Event("input"));
+ expect(c.instructionBody).toBe("Pick a file.");
+ expect(changed).toHaveBeenCalledTimes(2);
+
+ // Preview swaps the box for the rendered markdown; Write brings it back.
+ tabs[1].click();
+ expect(mode).toHaveBeenCalledWith("preview");
+ await fixture.whenStable();
+ fixture.detectChanges();
+ expect(el("textarea.md-input")).toBeNull();
+ expect(el("#instr-body .md")).not.toBeNull();
+ expect(tabs[1].getAttribute("aria-current")).toBe("true");
+ tabs[0].click();
+ expect(mode).toHaveBeenCalledWith("write");
+ fixture.detectChanges();
+ expect(el("textarea.md-input")).not.toBeNull();
+ });
+
+ it("shows the result picker to everyone with something to choose, and to an author always", () => {
+ fixture.detectChanges();
+ finishLoad();
+ const c = fixture.componentInstance;
+ // A reader with nothing to choose from gets no section.
+ expect(el(".respick")).toBeNull();
+
+ // A reader with candidates gets the picker, worded as their own view.
+ c.resultChoices = [{ operatorID: "last", label: "Limit", shown: true }];
+ fixture.detectChanges();
+ expect(el(".respick")).not.toBeNull();
+ expect(el(".respick p")?.textContent).toContain("only your view");
+ expect(el(".respick .pill")?.textContent?.trim()).toBe("Limit");
+
+ // An author always gets it, with the hint on how to add steps, worded as the default for all.
+ c.authoring = true;
+ c.resultChoices = [];
+ fixture.detectChanges();
+ expect(el(".respick")).not.toBeNull();
+ expect(el(".respick p")?.textContent).toContain("What everyone sees");
+ expect(el(".respick .hint")?.textContent).toContain("No steps to choose from yet");
+
+ c.resultChoices = [
+ { operatorID: "mid", label: "Filter", shown: false },
+ { operatorID: "viz", label: "Chart", shown: true },
+ ];
+ fixture.detectChanges();
+ const toggle = vi.spyOn(c, "onToggleResult").mockImplementation(() => {});
+ const pills = Array.from(fixture.nativeElement.querySelectorAll(".respick .pill")) as HTMLButtonElement[];
+ expect(pills.map(p => p.textContent?.trim())).toEqual(["Filter", "Chart"]);
+ // The pressed state is exposed to assistive tech as well as styled.
+ expect(pills.map(p => p.getAttribute("aria-pressed"))).toEqual(["false", "true"]);
+ expect(pills[1].classList.contains("on")).toBe(true);
+ expect(el(".respick .hint")).toBeNull();
+ pills[0].click();
+ expect(toggle).toHaveBeenCalledWith(c.resultChoices[0]);
+
+ // A toggle re-reads the config and rebuilds the choices as new objects. The pill just pressed
+ // must be the same element afterwards (tracked by step), or the keyboard focus on it is lost.
+ pills[0].focus();
+ c.resultChoices = [
+ { operatorID: "mid", label: "Filter", shown: true },
+ { operatorID: "viz", label: "Chart", shown: true },
+ ];
+ fixture.detectChanges();
+ const after = Array.from(fixture.nativeElement.querySelectorAll(".respick .pill")) as HTMLButtonElement[];
+ expect(after[0]).toBe(pills[0]);
+ expect(document.activeElement).toBe(pills[0]);
+ expect(after[0].getAttribute("aria-pressed")).toBe("true");
});
it("renders the run bar with the run button and the computing-unit selector", () => {
@@ -501,8 +650,8 @@ describe("WorkflowFormComponent (rendered template)", () => {
fixture.componentInstance.selectedOperatorId = "op-1";
fixture.detectChanges();
- const panel = fixture.debugElement.query(By.directive(MockPropertyEditorComponent))
- .componentInstance as MockPropertyEditorComponent;
+ const panel = fixture.debugElement.query(By.directive(PropertyEditorComponent))
+ .componentInstance as PropertyEditorComponent;
// The three bindings that make this an inspect rather than an editor: no writes to the shared
// workflow, no tick boxes for choosing what to expose, and no claim on the canvas panel's
// saved geometry.
@@ -518,6 +667,55 @@ describe("WorkflowFormComponent (rendered template)", () => {
expect(el(".panel")!.getAttribute("aria-label")).toBe("Step settings, read-only");
});
+ it("turns that same panel live in edit mode: writes on, tick boxes on, inert off", () => {
+ fixture.detectChanges();
+ finishLoad();
+ const c = fixture.componentInstance;
+ c.selectedOperatorId = "op-1";
+ c.authoring = true;
+ fixture.detectChanges();
+
+ const panel = fixture.debugElement.query(By.directive(PropertyEditorComponent))
+ .componentInstance as PropertyEditorComponent;
+ // Authoring is a real edit of the shared graph, the same edit the canvas makes, so the panel
+ // acts as an editor; and its tick boxes are how the author picks what the form exposes.
+ expect(panel.actsAsEditor).toBe(true);
+ expect(panel.exposeChoosing).toBe(true);
+ // Still not the docked canvas panel, so it still makes no claim on that panel's geometry.
+ expect(panel.persistPlacement).toBe(false);
+ expect(el("texera-property-editor")!.hasAttribute("inert")).toBe(false);
+ // The container's tab stop existed only because inert content cannot hold focus. With the form
+ // focusable again it would just sit in front of it, so it goes away with inert.
+ expect(el(".panel")!.hasAttribute("tabindex")).toBe(false);
+ expect(el(".panel")!.getAttribute("aria-label")).toBe("Step settings");
+ });
+
+ it("keeps that panel read-only while a run is in flight, even in edit mode, and turns it live after", () => {
+ // The frame does not consult the lock before its own writes (version sync on mount, the schema
+ // defaults ajv fills in, the editing marker), so a step selected mid-run must mount as a viewer
+ // although edit mode is on; the run ending is what makes it live.
+ fixture.detectChanges();
+ finishLoad();
+ const c = fixture.componentInstance;
+ c.selectedOperatorId = "op-1";
+ c.authoring = true;
+ c.executionState = ExecutionState.Running;
+ fixture.detectChanges();
+
+ const panel = fixture.debugElement.query(By.directive(PropertyEditorComponent))
+ .componentInstance as PropertyEditorComponent;
+ expect(panel.actsAsEditor).toBe(false);
+ expect(panel.exposeChoosing).toBe(false);
+ expect(el("texera-property-editor")!.hasAttribute("inert")).toBe(true);
+ expect(el(".panel")!.getAttribute("aria-label")).toBe("Step settings, read-only");
+
+ c.executionState = ExecutionState.Completed;
+ fixture.detectChanges();
+ expect(panel.actsAsEditor).toBe(true);
+ expect(panel.exposeChoosing).toBe(true);
+ expect(el("texera-property-editor")!.hasAttribute("inert")).toBe(false);
+ });
+
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 7acede2318..cfa68e37b8 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
@@ -53,6 +53,9 @@ export const resolved = (id: string, displayName: string, extra: Partial<Resolve
export function setupHarness() {
const router = { navigate: vi.fn() };
const workflowChangedStream = new Subject<unknown>();
+ // The root-level modification lock as other writers flip it (the execute service after a run, the
+ // computing-unit selector); tests emit `true` to stand in for one of them unlocking the graph.
+ const modificationEnabled = new Subject<boolean>();
const workflowMetaDataChangedStream = new Subject<unknown>();
// Compilation reports column names late; the form rebuilds its inputs off this stream.
const compilationChanged = new Subject<unknown>();
@@ -69,6 +72,11 @@ export function setupHarness() {
const resultUpdateStream = new Subject<Record<string, unknown>>();
// Fires when the canvas's view-result set changes (a co-editor's eye toggle included).
const viewResultChanged = new Subject<unknown>();
+ // Fires for a change of the graph's shape (operator added/deleted/disabled, link added/deleted); one
+ // subject stands in for all five streams, since the form treats them alike.
+ const graphStructureChanged = new Subject<unknown>();
+ // Fires when a step's display name changes; its own subject, so a test can tell it from the shape.
+ const displayNameChanged = new Subject<unknown>();
// 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<string>();
@@ -115,6 +123,7 @@ export function setupHarness() {
reloadWorkflow: vi.fn(),
enableWorkflowModification: vi.fn(),
disableWorkflowModification: vi.fn(),
+ getWorkflowModificationEnabledStream: () => modificationEnabled.asObservable(),
clearWorkflow: vi.fn(),
workflowChanged: () => workflowChangedStream.asObservable(),
workflowMetaDataChanged: () => workflowMetaDataChangedStream.asObservable(),
@@ -131,6 +140,12 @@ export function setupHarness() {
getAllOperators: () => graphOperators,
getOperatorsToViewResult: () => new Set(viewResultIds),
getViewResultOperatorsChangedStream: () => viewResultChanged.asObservable(),
+ getOperatorAddStream: () => graphStructureChanged.asObservable(),
+ getOperatorDeleteStream: () => graphStructureChanged.asObservable(),
+ getLinkAddStream: () => graphStructureChanged.asObservable(),
+ getLinkDeleteStream: () => graphStructureChanged.asObservable(),
+ getDisabledOperatorsChangedStream: () => graphStructureChanged.asObservable(),
+ getOperatorDisplayNameChangedStream: () => displayNameChanged.asObservable(),
// Enabled links only: a non-terminal operator emits one dummy outgoing link; a terminal operator
// (in terminalIds, or whose downstream is disabled via disabledDownstream) emits none. Drives the
// component's terminal detection (terminalOperatorIds).
@@ -158,12 +173,16 @@ export function setupHarness() {
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: [] }),
+ getConfig: vi.fn().mockReturnValue({ instruction: undefined, fields: [] }),
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,
+ // Author-mode writes: the component calls these then re-reads config. Spied so a test can
+ // assert the edit was made without needing a real binding store.
+ toggleShownResult: vi.fn(),
+ updateConfig: vi.fn(),
};
// 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
@@ -317,6 +336,7 @@ export function setupHarness() {
workflowMetaDataChangedStream,
compilationChanged,
executionStateStream,
+ modificationEnabled,
durationEvents,
statusStream,
selectedUnitStream,
@@ -324,6 +344,8 @@ export function setupHarness() {
connectionStream,
resultUpdateStream,
viewResultChanged,
+ graphStructureChanged,
+ displayNameChanged,
hasOperatorIds,
graphOperators,
viewResultIds,
diff --git a/frontend/src/app/workspace/service/form-binding/form-binding.service.spec.ts b/frontend/src/app/workspace/service/form-binding/form-binding.service.spec.ts
index c87115afc1..cd32c27795 100644
--- a/frontend/src/app/workspace/service/form-binding/form-binding.service.spec.ts
+++ b/frontend/src/app/workspace/service/form-binding/form-binding.service.spec.ts
@@ -51,9 +51,9 @@ describe("FormBindingService", () => {
const scanId = mockScanPredicate.operatorID;
- it("starts with nothing exposed", () => {
+ it("starts with nothing exposed and no result list, so the final steps show", () => {
expect(service.getConfig().fields).toEqual([]);
- expect(service.getConfig().resultOperatorIds).toEqual([]);
+ expect(service.getConfig().shownResultIds).toBeUndefined();
});
describe("exposing a property", () => {
@@ -303,19 +303,40 @@ describe("FormBindingService", () => {
describe("choosing which results to show", () => {
// The form records only its own selection. It never writes the canvas's view-result flags:
- // the picker offers exactly the operators the workflow already views, so those results are
- // already materialised, and a canvas user's own view-result choices are left untouched.
- it("toggles an operator in and out of the shown set, without touching the canvas", () => {
+ // the picker offers exactly the operators the workflow already views or ends with, so those
+ // results are already materialised, and a canvas user's own view-result choices are left untouched.
+ it("starts the saved list from the default handed in on the first choice, without touching the canvas", () => {
+ // Until the author chooses, the list is absent (the final steps show). The first toggle
+ // materialises that default and flips the one step, so nothing the author saw disappears.
const setView = vi.spyOn(workflowActionService, "setViewOperatorResults");
+ expect(service.getConfig().shownResultIds).toBeUndefined();
- service.toggleResultOperator(scanId);
- expect(service.getConfig().resultOperatorIds).toEqual([scanId]);
+ service.toggleShownResult(scanId, ["final-1", "final-2"]);
+ expect(service.getConfig().shownResultIds).toEqual(["final-1", "final-2", scanId]);
- service.toggleResultOperator(scanId);
- expect(service.getConfig().resultOperatorIds).toEqual([]);
+ service.toggleShownResult("final-1", ["final-1", "final-2"]);
+ expect(service.getConfig().shownResultIds).toEqual(["final-2", scanId]);
expect(setView).not.toHaveBeenCalled();
});
+
+ it("flips within the saved list once there is one, ignoring the default", () => {
+ // After the first choice the list is exhaustive: a step that became final later is not in it and
+ // does not sneak in through the default argument.
+ service.updateConfig({ shownResultIds: [scanId] });
+
+ service.toggleShownResult("new-final", ["new-final", scanId]);
+ expect(service.getConfig().shownResultIds).toEqual([scanId, "new-final"]);
+
+ service.toggleShownResult(scanId, ["new-final", scanId]);
+ expect(service.getConfig().shownResultIds).toEqual(["new-final"]);
+ });
+
+ it("can store 'no results': turning off the only shown step leaves an empty list, not an absent one", () => {
+ // [] is a real authoring choice (no results section); absent would fall back to the final steps.
+ service.toggleShownResult(scanId, [scanId]);
+ expect(service.getConfig().shownResultIds).toEqual([]);
+ });
});
describe("reading, labeling and schema lookup", () => {
diff --git a/frontend/src/app/workspace/service/form-binding/form-binding.service.ts b/frontend/src/app/workspace/service/form-binding/form-binding.service.ts
index 8990c62a95..2b55ab7804 100644
--- a/frontend/src/app/workspace/service/form-binding/form-binding.service.ts
+++ b/frontend/src/app/workspace/service/form-binding/form-binding.service.ts
@@ -179,15 +179,20 @@ export class FormBindingService {
}
/**
- * Choose whether an operator's output is shown on the form after a run. This records the
- * form's own selection only and never changes the canvas's view-result flags: the form
- * offers exactly the operators the workflow already views, so their results are already
- * materialised and nothing here needs to touch the graph. Read-only, one direction.
+ * Choose which steps' results the form shows after a run, as the default for everyone. The saved
+ * list (shownResultIds) is exhaustive: absent, the final steps show, as on the canvas; once the
+ * author has chosen, exactly the listed steps show, and an empty list means none. So the first
+ * choice starts from the default handed in (the final steps at that moment) and flips the one step;
+ * later choices flip within the saved list. One list means nothing can contradict it and "no
+ * results" is a state it can store; the accepted cost is that a step which becomes final after the
+ * author has chosen does not appear by itself. This records the form's own selection only and never
+ * changes the canvas's view-result flags: the picker offers steps whose results are already
+ * materialised, so nothing here needs to touch the graph.
*/
- public toggleResultOperator(operatorID: string): void {
- const shown = this.getConfig().resultOperatorIds;
+ public toggleShownResult(operatorID: string, defaultIds: readonly string[]): void {
+ const shown = this.getConfig().shownResultIds ?? [...defaultIds];
const next = shown.includes(operatorID) ? shown.filter(id => id !== operatorID) : [...shown, operatorID];
- this.updateConfig({ resultOperatorIds: next });
+ this.updateConfig({ shownResultIds: next });
}
// ---------------------------------------------------------------------------
diff --git a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts
index 39e294b27d..288846346d 100644
--- a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts
+++ b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts
@@ -425,6 +425,19 @@ describe("JointUIService", () => {
expect(payload[".delete-button"].visibility).toBe("visible");
expect(payload[".chat-button"].visibility).toBe("visible");
});
+ it("unfolds the state and metrics without the action buttons when asked (a structure-locked preview)", () => {
+ // The buttons start hidden and stay so: none of delete, chat, add/remove port can act on a
+ // locked preview, and a button that does nothing would suggest the preview can be edited.
+ const { paper, attrSpy } = makePaperWithModel();
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.unfoldOperatorDetails(paper, "op-1", false);
+ const [payload] = attrSpy.mock.calls[0];
+ expect(payload[`.${operatorStateClass}`].visibility).toBe("visible");
+ expect(payload[`.${operatorPortMetricsClass}`].visibility).toBe("visible");
+ expect(Object.keys(payload)).not.toContain(".delete-button");
+ expect(Object.keys(payload)).not.toContain(".chat-button");
+ expect(Object.keys(payload).some(key => key.endsWith("-port-button"))).toBe(false);
+ });
});
describe("showAgentActionLabel / hideAgentActionLabel", () => {
diff --git a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts
index 3afbccde87..29ddb1f54a 100644
--- a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts
+++ b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts
@@ -426,16 +426,27 @@ export class JointUIService {
});
}
- public unfoldOperatorDetails(jointPaper: joint.dia.Paper, operatorID: string): void {
+ /**
+ * Show a selected operator's details: its state and port counts, and (by default) its action
+ * buttons: delete, chat with an agent, add/remove a port. A view whose structure is locked (the Form
+ * View's preview) passes `withButtons = false`: none of those actions can happen there, and buttons
+ * that do nothing would only suggest the preview can be edited. The buttons start hidden and
+ * foldOperatorDetails hides them again, so leaving them out here is all it takes.
+ */
+ public unfoldOperatorDetails(jointPaper: joint.dia.Paper, operatorID: string, withButtons = true): void {
jointPaper.getModelById(operatorID).attr({
[`.${operatorStateClass}`]: { visibility: "visible" },
[`.${operatorPortMetricsClass}`]: { visibility: "visible" },
- ".delete-button": { visibility: "visible" },
- ".chat-button": { visibility: "visible" },
- ".add-input-port-button": { visibility: "visible" },
- ".add-output-port-button": { visibility: "visible" },
- ".remove-input-port-button": { visibility: "visible" },
- ".remove-output-port-button": { visibility: "visible" },
+ ...(withButtons
+ ? {
+ ".delete-button": { visibility: "visible" },
+ ".chat-button": { visibility: "visible" },
+ ".add-input-port-button": { visibility: "visible" },
+ ".add-output-port-button": { visibility: "visible" },
+ ".remove-input-port-button": { visibility: "visible" },
+ ".remove-output-port-button": { visibility: "visible" },
+ }
+ : {}),
});
}
diff --git a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts
index 52908fdb36..87e002a86a 100644
--- a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts
+++ b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts
@@ -806,11 +806,11 @@ describe("WorkflowActionService", () => {
helpText: "Which table to read.",
},
],
- resultOperatorIds: [mockResultPredicate.operatorID],
+ shownResultIds: [mockResultPredicate.operatorID],
};
it("should start empty for a workflow that was never set up", () => {
- expect(service.getFormBinding()).toEqual({ fields: [], resultOperatorIds: [] });
+ expect(service.getFormBinding()).toEqual({ fields: [] });
});
it("should round-trip through workflow content", () => {
@@ -858,7 +858,7 @@ describe("WorkflowActionService", () => {
false
);
- expect(service.getFormBinding()).toEqual({ fields: [], resultOperatorIds: [] });
+ expect(service.getFormBinding()).toEqual({ fields: [] });
});
// Starting a blank workflow must not carry the previous one's definition, or it would
@@ -869,7 +869,7 @@ describe("WorkflowActionService", () => {
service.reloadWorkflow(undefined, false, false);
- expect(service.getFormBinding()).toEqual({ fields: [], resultOperatorIds: [] });
+ expect(service.getFormBinding()).toEqual({ fields: [] });
});
// A plain workflow must not stamp an empty formBinding into its content, or every existing
@@ -880,6 +880,16 @@ describe("WorkflowActionService", () => {
expect("formBinding" in service.getWorkflowContent()).toBe(false);
});
+ it("should carry a definition whose only content is an empty result list", () => {
+ // "Show no results" is a real choice: fields and instruction stay empty and the list is [], so
+ // the definition must still be saved, or the choice would be lost on reload and every final step
+ // would show again.
+ service.reloadWorkflow(undefined, false, false);
+ service.setFormBinding({ fields: [], shownResultIds: [] });
+
+ expect(service.getWorkflowContent().formBinding).toEqual({ fields: [], shownResultIds: [] });
+ });
+
// Editing the form has to reach the same autosave that canvas edits use.
it("should report an edit through workflowChanged", () => {
const seen: unknown[] = [];
diff --git a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts
index f531336593..d13e6cf10a 100644
--- a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts
+++ b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts
@@ -768,10 +768,11 @@ export class WorkflowActionService {
this.formBinding = formBinding ?? getDefaultFormBinding();
}
- /** A form binding worth persisting: an author populated it (fields, results, or an
- * instruction), as opposed to the empty default a plain workflow carries. */
+ /** A form binding worth persisting: an author populated it (fields, a chosen result list -- an
+ * empty one included, it means "none" -- or an instruction), as opposed to the empty default a
+ * plain workflow carries. */
private isFormBindingNonEmpty(fb: FormBindingConfig): boolean {
- return fb.fields.length > 0 || fb.resultOperatorIds.length > 0 || fb.instruction !== undefined;
+ return fb.fields.length > 0 || fb.shownResultIds !== undefined || fb.instruction !== undefined;
}
/**