This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 357d9013e1 feat(workflow-form): render the exposed inputs and write
values back (#8437)
357d9013e1 is described below
commit 357d9013e14bac3bcfc7a8bf6ddfdb13937bd55a
Author: yangzhang75 <[email protected]>
AuthorDate: Sun Sep 6 08:47:16 2026 +0000
feat(workflow-form): render the exposed inputs and write values back (#8437)
### Purpose
Closes #8370. Part of the Form View stack (#8011), stacked on #8436
(PR9).
The Form View page already has the title bar and the collapsible
read-only workflow preview. This PR fills in the middle: it renders the
inputs an author exposed and writes filled-in values straight back to
their operators.
### Changes
- Each exposed binding is turned into its operator's own ngx-formly
field, built from the operator's JSON schema (not guessed from the
value), so a file property gets the real file picker and an attribute
property a column dropdown instead of degrading to a plain text box.
- The widget is decided by the shared `customFormlyFieldType` (extracted
in #8436). Two widgets that only work on the operator canvas, the code
editor and the drag-reorder list, are collected in a new
`CANVAS_ONLY_FORMLY_TYPES` and fall back to formly's default editable
control, since they cannot function on a form.
- A changed value writes straight back to its operator, the same edit
the canvas makes, guarded so formly's build-time empty default never
silently wipes a real value. Only a viewer with write access can edit; a
read-only viewer sees the values disabled and the page never persists
for them.
- The inputs rebuild on the compilation stream (attribute boxes become
dropdowns once upstream columns are known) and on `formBindingChanged$`
(exposing or un-exposing a property, a co-editor's change included, is
reflected at once), both skipped while the cursor is in a field so an
in-progress edit is not thrown away.
- Broken bindings (the operator was deleted, or the key no longer
exists) are left out of what a reader sees.
Nested and array sub-field overrides are added in the next PR (#8022);
running the workflow and showing results follow after that.
### Tests
- `workflow-form.component.spec.ts`: direct-construction unit tests for
rendering, widget selection, the write-back guards, read-only locking,
the compilation/binding rebuild triggers, and the typing guard, for both
the single-user and the collaboration paths.
- `workflow-form.rendered.spec.ts`: TestBed test standing up the real
template so the inputs markup (section head, empty state, input card and
form wrapper) is covered.
- `custom-formly-type.spec.ts`: covers the new
`CANVAS_ONLY_FORMLY_TYPES`.
- 100% statement and function coverage on the changed source. `ng test`
(71 tests here), `ng build gui`, eslint and prettier all pass.
### Was AI used?
Yes, co-authored with Claude (Claude Code).
#### Screenshot
The **Inputs** area: each exposed property rendered as its operator's
own control (the workflow preview and mini-map below it are the earlier
PR's).
<img width="1267" height="391" alt="Screenshot 2026-09-05 at 11 41
41 AM"
src="https://github.com/user-attachments/assets/4572fa60-3124-4140-8f7c-ddd7f22133fd"
/>
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
.../workflow-form/workflow-form.component.html | 42 ++-
.../workflow-form/workflow-form.component.scss | 123 ++++++-
.../workflow-form/workflow-form.component.spec.ts | 362 ++++++++++++++++++++-
.../workflow-form/workflow-form.component.ts | 250 +++++++++++++-
.../workflow-form/workflow-form.rendered.spec.ts | 80 ++++-
.../workflow-form/workflow-form.spec-harness.ts | 69 +++-
.../app/workspace/util/custom-formly-type.spec.ts | 12 +-
.../src/app/workspace/util/custom-formly-type.ts | 9 +
8 files changed, 929 insertions(+), 18 deletions(-)
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 523e32b5d9..c924532d05 100644
---
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
+++
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
@@ -70,8 +70,46 @@
</div>
<div [hidden]="loading">
- <!-- The workflow, out of the way unless the reader goes looking. The
inputs, running and
- results are added on top of this by the following PRs. -->
+ <!-- The inputs an author exposed, each rendered as its operator's own
field. -->
+ <div class="pc-section-head">
+ <span class="label">Inputs</span>
+ </div>
+
+ <div
+ class="empty"
+ *ngIf="visibleFields.length === 0">
+ This workflow has no inputs to fill in.
+ </div>
+
+ <div class="params">
+ <section
+ class="card param"
+ [class.read-only]="!canEdit"
+ *ngFor="let r of rendered; trackBy: trackByRendered">
+ <!-- The operator's own field, so a file property gets the real file
picker and an
+ attribute property a column dropdown. -->
+ <form
+ [formGroup]="r.form"
+ class="param-form">
+ <formly-form
+ [model]="r.model"
+ [fields]="r.fields"
+ [form]="r.form"></formly-form>
+ </form>
+
+ <!-- The one line of guidance a reader gets, when the author wrote
one. The operator
+ schema's own field descriptions are dropped (author notes, not
reader guidance), so
+ this is the only help text under an input. -->
+ <p
+ *ngIf="r.resolved.binding.helpText"
+ class="param-help-text">
+ {{ r.resolved.binding.helpText }}
+ </p>
+ </section>
+ </div>
+
+ <!-- The workflow, out of the way unless the reader goes looking. Running
and results are added
+ on top of this by the following PRs. -->
<section
class="card wf"
[class.open]="workflowOpen">
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 6110a0f2f7..54e7ffd7bc 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
@@ -183,13 +183,134 @@ $shell: #fafafa;
padding: 40px 0;
}
-/* A section of the page: the workflow preview here, the inputs and results in
later PRs. */
+/* A section of the page: the inputs and the workflow preview here, results in
later PRs. */
.card {
border: 1px solid $border;
border-radius: 8px;
background: #fff;
}
+/* ---------- inputs ---------- */
+
+.pc-section-head {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.label {
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: $text-2;
+}
+
+.empty {
+ padding: 28px;
+ text-align: center;
+ color: $text-2;
+ border: 1px dashed $border;
+ border-radius: 8px;
+}
+
+.params {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.param {
+ padding: 16px 18px;
+
+ /* A read-only viewer sees the values but cannot change them. The bound
controls are already
+ disabled through props.disabled; this blocks every other interactive
element a custom widget
+ draws of its own -- a file picker's Browse button, an uploader, a
picker's dropdown -- which
+ do not consult the form's disabled state. */
+ &.read-only {
+ pointer-events: none;
+ }
+
+ /* The author's one line of guidance under an input, when they wrote one. */
+ .param-help-text {
+ margin: 8px 0 0;
+ color: $text-2;
+ font-size: 13px;
+ }
+
+ /* The operator's property editor lays its fields out for a narrow docked
panel: a fixed label
+ column on the left, controls squeezed into what is left. Dropped into a
full-width card that
+ reads as a third of a form floating in an empty page. Here the label sits
above its control
+ and both run the width of the card. formly's own structural elements are
inline by default,
+ so each field shrank to its content until they are told to fill first. */
+ .param-form,
+ ::ng-deep formly-form,
+ ::ng-deep formly-field,
+ ::ng-deep formly-group,
+ ::ng-deep formly-wrapper-nz-form-field,
+ ::ng-deep formly-field-nz-input,
+ ::ng-deep nz-form-item,
+ ::ng-deep nz-form-control {
+ display: block;
+ width: 100%;
+ max-width: none;
+ }
+
+ ::ng-deep .ant-form-item {
+ margin-bottom: 0;
+ width: 100%;
+ }
+
+ ::ng-deep .ant-form-item-row,
+ ::ng-deep nz-form-item > .ant-row {
+ display: block;
+ }
+
+ ::ng-deep .ant-form-item-label {
+ width: auto !important;
+ max-width: none !important;
+ flex: none !important;
+ text-align: left !important;
+ padding: 0 0 2px !important;
+ line-height: 1.5;
+
+ > label {
+ height: auto;
+ font-size: 13px;
+ color: $text;
+
+ &::after {
+ display: none;
+ }
+ }
+ }
+
+ ::ng-deep .ant-form-item-control {
+ width: 100% !important;
+ max-width: none !important;
+ flex: 1 1 auto !important;
+ }
+
+ /* Inputs, selects and the file picker all stretch instead of sitting at
their default widths
+ against a wall of white. */
+ ::ng-deep .ant-form-item-control-input-content > input,
+ ::ng-deep .ant-form-item-control-input-content > textarea,
+ ::ng-deep .ant-form-item-control-input-content > nz-select,
+ ::ng-deep .ant-input,
+ ::ng-deep .ant-input-number,
+ ::ng-deep .ant-select {
+ width: 100%;
+ }
+
+ /* The file picker's button sits under its box; give them the same left edge
and a little air,
+ rather than the button hugging the input. */
+ ::ng-deep texera-dataset-file-selector button,
+ ::ng-deep .ant-form-item-control-input-content > button {
+ margin-top: 6px;
+ }
+}
+
/* ---------- workflow preview ---------- */
.wf {
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 f081b4b946..3c747ae58b 100644
---
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
+++
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
@@ -17,13 +17,15 @@
* under the License.
*/
+import { FormControl } from "@angular/forms";
import { Router } from "@angular/router";
import { of, throwError } from "rxjs";
import { WorkflowFormComponent } from "./workflow-form.component";
-import { setupHarness, formViewWorkflow } from "./workflow-form.spec-harness";
+import { setupHarness, formViewWorkflow, resolved } from
"./workflow-form.spec-harness";
import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant";
import { DefaultView } from
"../../../dashboard/type/workflow-metadata.interface";
+import { FORM_DEBOUNCE_TIME_MS } from
"../../service/execute-workflow/execute-workflow.service";
/**
* These exercise the page's own decisions -- what a reader is shown, where an
ordinary
@@ -38,6 +40,7 @@ describe("WorkflowFormComponent", () => {
let router: { navigate: ReturnType<typeof vi.fn> };
let workflowActionService: any;
let workflowPersistService: any;
+ let formBindingService: any;
const build = (workflow: any) => {
h.useWorkflow(workflow);
@@ -48,11 +51,15 @@ describe("WorkflowFormComponent", () => {
h.workflowActionService as any,
h.workflowPersistService as any,
h.operatorMetadataService as any,
+ h.formBindingService as any,
h.executeWorkflowService as any,
h.workflowResultService as any,
h.notificationService as any,
h.userService as any,
+ h.formlyJsonschema as any,
h.cdr as any,
+ h.dynamicSchemaService as any,
+ h.workflowCompilingService as any,
h.computingUnitStatusService as any,
h.workflowConsoleService as any,
h.host as any,
@@ -67,6 +74,7 @@ describe("WorkflowFormComponent", () => {
router = h.router;
workflowActionService = h.workflowActionService;
workflowPersistService = h.workflowPersistService;
+ formBindingService = h.formBindingService;
});
describe("who this page is for", () => {
@@ -131,6 +139,15 @@ describe("WorkflowFormComponent", () => {
expect(h.notificationService.error).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([USER_WORKFLOW]);
});
+
+ // Write access decides whether a filled-in value writes back and whether
the page saves.
+ it("has write access for a writable workflow and none for a read-only
one", () => {
+ build(formViewWorkflow).ngOnInit();
+ expect(component.canEdit).toBe(true);
+
+ build({ ...formViewWorkflow, readonly: true }).ngOnInit();
+ expect(component.canEdit).toBe(false);
+ });
});
describe("leaving the page", () => {
@@ -263,6 +280,17 @@ describe("WorkflowFormComponent", () => {
expect(workflowPersistService.persistWorkflow).not.toHaveBeenCalled();
});
+ it("does not save when the viewer only has read access", () => {
+ h.userService.isLogin.mockReturnValue(true);
+ h.workflowPersistService.isWorkflowPersistEnabled.mockReturnValue(true);
+ build({ ...formViewWorkflow, readonly: true }).ngOnInit();
+ workflowPersistService.persistWorkflow.mockClear();
+
+ (component as any).save();
+
+ expect(workflowPersistService.persistWorkflow).not.toHaveBeenCalled();
+ });
+
it("does not save a workflow that is not the one this page opened", () => {
enableSave();
workflowActionService.getWorkflow.mockReturnValue({ wid: 99, content: {
operators: [], operatorPositions: {} } });
@@ -402,4 +430,336 @@ describe("WorkflowFormComponent", () => {
expect(component.workflowEverOpened).toBe(false);
});
});
+
+ // The heart of this slice: turn each exposed binding into its operator's
own formly field, and
+ // write a filled-in value straight back to the operator.
+ describe("the exposed inputs", () => {
+ // Put op-1 on the graph and expose one of its properties, then read the
config.
+ const renderOne = (id: string, extra: any = {}) => {
+ h.hasOperatorIds.add("op-1");
+ formBindingService.resolveFields.mockReturnValue([resolved(id, id,
extra)]);
+ (component as any).readConfig();
+ };
+
+ it("renders a healthy input as a real formly field keyed by its binding
id", () => {
+ build(formViewWorkflow).ngOnInit();
+
+ renderOne("n_hvg");
+
+ expect(component.rendered).toHaveLength(1);
+
expect(component.rendered[0].fields[0].key).toBe(component.rendered[0].resolved.binding.id);
+ });
+
+ it("renders nothing for an input whose operator is no longer on the
graph", () => {
+ build(formViewWorkflow).ngOnInit();
+ // op-1 deliberately not added to the graph.
+ formBindingService.resolveFields.mockReturnValue([resolved("n_hvg",
"Genes")]);
+
+ (component as any).readConfig();
+
+ expect(component.rendered).toHaveLength(0);
+ });
+
+ it("skips an exposed property that has no matching schema field", () => {
+ build(formViewWorkflow).ngOnInit();
+
+ renderOne("nonesuch");
+
+ expect(component.rendered).toHaveLength(0);
+ });
+
+ it("leaves broken inputs out of what a reader sees", () => {
+ build(formViewWorkflow).ngOnInit();
+ h.hasOperatorIds.add("op-1");
+ formBindingService.resolveFields.mockReturnValue([
+ resolved("n_hvg", "Genes"),
+ resolved("gone", "Gone", { brokenReason: "the step it belonged to was
removed" }),
+ ]);
+
+ (component as any).readConfig();
+
+ expect(component.visibleFields).toHaveLength(1);
+ expect(component.rendered).toHaveLength(1);
+ });
+
+ it("gives an exposed property its custom widget instead of a text box", ()
=> {
+ build(formViewWorkflow).ngOnInit();
+
+ renderOne("datasetVersionPath");
+
+
expect(component.rendered[0].fields[0].type).toBe("datasetversionselector");
+ });
+
+ it("uses the operator type to pick a widget (the HuggingFace model
picker)", () => {
+ build(formViewWorkflow).ngOnInit();
+ h.graphOperators.push({ operatorID: "op-1", operatorType: "HuggingFace"
});
+
+ renderOne("modelId");
+
+ expect(component.rendered[0].fields[0].type).toBe("huggingface");
+ });
+
+ it("renders a file property through its own picker type", () => {
+ build(formViewWorkflow).ngOnInit();
+
+ renderOne("fileName");
+
+ expect(component.rendered[0].fields[0].type).toBe("inputautocomplete");
+ });
+
+ it("seeds the field model with the operator's other properties as
read-only context", () => {
+ build(formViewWorkflow).ngOnInit();
+ h.hasOperatorIds.add("op-1");
+ // A HuggingFace operator whose model picker (modelId) needs the sibling
`task` to work.
+ h.graphOperators.push({
+ operatorID: "op-1",
+ operatorType: "HuggingFace",
+ operatorProperties: { task: "image-classification", modelId: "seed" },
+ });
+ formBindingService.resolveFields.mockReturnValue([resolved("modelId",
"Model")]);
+
+ (component as any).readConfig();
+
+ const card = component.rendered[0];
+ // The sibling context is present (so the widget reads the right task)
...
+ expect(card.model.task).toBe("image-classification");
+ // ... alongside this input's own value, keyed by the binding id, which
is what writes back.
+ expect(card.model[card.resolved.binding.id]).toBe("seed");
+ });
+
+ it("prefers the per-instance schema, falling back to the static one when
it is unavailable", () => {
+ build(formViewWorkflow).ngOnInit();
+ h.graphOperators.push({ operatorID: "op-1", operatorType: "X" });
+ (component as any).dynamicSchemaService = {
+ getDynamicSchema: () => {
+ throw new Error("no dynamic schema");
+ },
+ };
+ (component as any).operatorMetadataService = {
+ getOperatorSchema: () => ({ jsonSchema: { properties: { n_hvg: {} } }
}),
+ };
+
+ renderOne("n_hvg");
+
+ expect(component.rendered).toHaveLength(1);
+ });
+
+ it("renders nothing when neither the per-instance nor the static schema is
available", () => {
+ build(formViewWorkflow).ngOnInit();
+ h.graphOperators.push({ operatorID: "op-1", operatorType: "X" });
+ (component as any).dynamicSchemaService = {
+ getDynamicSchema: () => {
+ throw new Error("no dynamic schema");
+ },
+ };
+ (component as any).operatorMetadataService = {
+ getOperatorSchema: () => {
+ throw new Error("no static schema");
+ },
+ };
+
+ renderOne("n_hvg");
+
+ expect(component.rendered).toHaveLength(0);
+ });
+
+ it("identifies a rendered card by its binding id", () => {
+ build(formViewWorkflow);
+
+ const key = component.trackByRendered(0, { resolved: { binding: { id:
"b-1" } } } as any);
+
+ expect(key).toBe("b-1");
+ });
+
+ it("locks the inputs for a read-only viewer", () => {
+ build({ ...formViewWorkflow, readonly: true }).ngOnInit();
+
+ renderOne("n_hvg");
+
+ expect(component.canEdit).toBe(false);
+ // The field carries props.disabled, which is what actually disables the
control formly builds
+ // (a form.disable() on the still-empty group does not, and does not
persist). It cascades to
+ // a nested property's sub-fields.
+ expect((component.rendered[0].fields[0].props as
any).disabled).toBe(true);
+ });
+
+ it("writes a dirtied value back to the operator", () => {
+ build(formViewWorkflow).ngOnInit();
+ renderOne("n_hvg");
+ const card = component.rendered[0];
+ const key = card.resolved.binding.id;
+ vi.useFakeTimers();
+
+ card.model[key] = "typed";
+ card.form.addControl(key, new FormControl("typed"));
+ card.form.markAsDirty();
+ vi.advanceTimersByTime(FORM_DEBOUNCE_TIME_MS + 50);
+ vi.useRealTimers();
+
+ expect(formBindingService.writeValue).toHaveBeenCalled();
+ });
+
+ it("ignores an unchanged form emission", () => {
+ build(formViewWorkflow).ngOnInit();
+ formBindingService.readValue.mockReturnValue("seed");
+ renderOne("n_hvg");
+ const card = component.rendered[0];
+ const key = card.resolved.binding.id;
+ vi.useFakeTimers();
+
+ card.model[key] = "seed";
+ card.form.addControl(key, new FormControl("seed"));
+ vi.advanceTimersByTime(FORM_DEBOUNCE_TIME_MS + 50);
+ vi.useRealTimers();
+
+ expect(formBindingService.writeValue).not.toHaveBeenCalled();
+ });
+
+ it("keeps a still-set value when formly emits a blank before an edit", ()
=> {
+ build(formViewWorkflow).ngOnInit();
+ formBindingService.readValue.mockReturnValue("seed");
+ renderOne("n_hvg");
+ const card = component.rendered[0];
+ const key = card.resolved.binding.id;
+ vi.useFakeTimers();
+
+ card.model[key] = "";
+ card.form.addControl(key, new FormControl(""));
+ vi.advanceTimersByTime(FORM_DEBOUNCE_TIME_MS + 50);
+ vi.useRealTimers();
+
+ expect(formBindingService.writeValue).not.toHaveBeenCalled();
+ });
+
+ it("refreshes the card's snapshot after a write-back", () => {
+ build(formViewWorkflow).ngOnInit();
+ renderOne("n_hvg");
+ const card = component.rendered[0];
+ const key = card.resolved.binding.id;
+ // The re-read after a write returns the new value on the same binding.
+ formBindingService.resolveFields.mockReturnValue([resolved("n_hvg",
"n_hvg", { value: "typed" })]);
+ vi.useFakeTimers();
+
+ card.model[key] = "typed";
+ card.form.addControl(key, new FormControl("typed"));
+ card.form.markAsDirty();
+ vi.advanceTimersByTime(FORM_DEBOUNCE_TIME_MS + 50);
+ vi.useRealTimers();
+
+ expect(component.rendered[0].resolved.value).toBe("typed");
+ });
+
+ it("leaves the card unchanged when the re-read no longer carries the
binding", () => {
+ build(formViewWorkflow).ngOnInit();
+ renderOne("n_hvg");
+ const card = component.rendered[0];
+ const before = card.resolved;
+ const key = card.resolved.binding.id;
+ // The write succeeds, but the following resolve returns nothing for
this binding.
+ formBindingService.resolveFields.mockReturnValue([]);
+ vi.useFakeTimers();
+
+ card.model[key] = "typed";
+ card.form.addControl(key, new FormControl("typed"));
+ card.form.markAsDirty();
+ vi.advanceTimersByTime(FORM_DEBOUNCE_TIME_MS + 50);
+ vi.useRealTimers();
+
+ expect(formBindingService.writeValue).toHaveBeenCalled();
+ expect(component.rendered[0].resolved).toBe(before);
+ });
+
+ it("labels an unnamed input by its schema title, not the raw key", () => {
+ build(formViewWorkflow).ngOnInit();
+ h.hasOperatorIds.add("op-1");
+ formBindingService.resolveFields.mockReturnValue([
+ resolved("n_hvg", "", {
+ binding: { id: "b", operatorID: "op-1", propertyKey: "n_hvg",
displayName: "" } as any,
+ }),
+ ]);
+
+ (component as any).readConfig();
+
+ // The schema's own title ("N"), not "n_hvg".
+ expect((component.rendered[0].fields[0].props as any).label).toBe("N");
+ });
+ });
+
+ describe("keeping the inputs in step with the workflow", () => {
+ it("rebuilds the inputs when compilation reports a new state", async () =>
{
+ build(formViewWorkflow).ngOnInit();
+ const rebuild = vi.spyOn(component as any, "readConfig");
+
+ h.compilationChanged.next("Succeeded");
+ await new Promise(r => setTimeout(r, FORM_DEBOUNCE_TIME_MS + 50));
+
+ expect(rebuild).toHaveBeenCalled();
+ });
+
+ it("does not rebuild under the cursor of someone typing", async () => {
+ build(formViewWorkflow).ngOnInit();
+ vi.spyOn(component as any, "isTypingInTheForm").mockReturnValue(true);
+ const rebuild = vi.spyOn(component as any, "readConfig");
+
+ h.compilationChanged.next("Succeeded");
+ await new Promise(r => setTimeout(r, FORM_DEBOUNCE_TIME_MS + 50));
+
+ expect(rebuild).not.toHaveBeenCalled();
+ });
+
+ it("re-reads the config when a property is exposed or un-exposed", () => {
+ build(formViewWorkflow).ngOnInit();
+ const before = formBindingService.resolveFields.mock.calls.length;
+
+ workflowActionService.formBindingChanged$.next(undefined);
+
+
expect(formBindingService.resolveFields.mock.calls.length).toBeGreaterThan(before);
+ });
+
+ // Once #8351 makes this stream fire for a co-editor's change, a rebuild
under the cursor would
+ // discard a half-entered value -- so the binding path skips typing, like
the compilation path.
+ it("does not re-read the config on a binding change while the reader is
typing", () => {
+ build(formViewWorkflow).ngOnInit();
+ vi.spyOn(component as any, "isTypingInTheForm").mockReturnValue(true);
+ const rebuild = vi.spyOn(component as any, "readConfig");
+
+ workflowActionService.formBindingChanged$.next(undefined);
+
+ expect(rebuild).not.toHaveBeenCalled();
+ });
+
+ it("reports typing when a form field inside the page is focused", () => {
+ build(formViewWorkflow).ngOnInit();
+ const input = document.createElement("input");
+ document.body.appendChild(input);
+ (component as any).host = { nativeElement: { contains: () => true,
querySelector: () => null } };
+ input.focus();
+
+ expect((component as any).isTypingInTheForm()).toBe(true);
+
+ document.body.removeChild(input);
+ });
+
+ it("reports no typing when the focus is outside the page", () => {
+ build(formViewWorkflow).ngOnInit();
+ (component as any).host = { nativeElement: { contains: () => false,
querySelector: () => null } };
+
+ expect((component as any).isTypingInTheForm()).toBe(false);
+ });
+
+ it("reports typing when a content-editable element inside the page is
focused", () => {
+ build(formViewWorkflow).ngOnInit();
+ const editable = document.createElement("div");
+ editable.tabIndex = 0;
+ // jsdom does not derive isContentEditable from the attribute; set it
directly.
+ Object.defineProperty(editable, "isContentEditable", { value: true });
+ document.body.appendChild(editable);
+ (component as any).host = { nativeElement: { contains: () => true,
querySelector: () => null } };
+ editable.focus();
+
+ expect((component as any).isTypingInTheForm()).toBe(true);
+
+ document.body.removeChild(editable);
+ });
+ });
});
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 d438572133..02eed5be3f 100644
---
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
+++
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
@@ -19,14 +19,17 @@
import { ChangeDetectorRef, Component, ElementRef, HostListener, OnDestroy,
OnInit } from "@angular/core";
import { CommonModule, DatePipe } from "@angular/common";
-import { FormsModule } from "@angular/forms";
+import { FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms";
+import { FormlyFieldConfig, FormlyModule } from "@ngx-formly/core";
+import { FormlyJsonschema } from "@ngx-formly/core/json-schema";
import { ActivatedRoute, Router } from "@angular/router";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { NzAvatarModule } from "ng-zorro-antd/avatar";
import { NzIconModule } from "ng-zorro-antd/icon";
import { UserIconComponent } from
"../../../dashboard/component/user/user-icon/user-icon.component";
-import { forkJoin } from "rxjs";
-import { debounceTime } from "rxjs/operators";
+import { cloneDeep } from "lodash-es";
+import { forkJoin, Subject } from "rxjs";
+import { debounceTime, takeUntil } from "rxjs/operators";
import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant";
import { Workflow, WorkflowContent } from "../../../common/type/workflow";
@@ -34,8 +37,12 @@ import { ComputingUnitStatusService } from
"../../../common/service/computing-un
import { WorkflowPersistService } from
"../../../common/service/workflow-persist/workflow-persist.service";
import { NotificationService } from
"../../../common/service/notification/notification.service";
import { UserService } from "../../../common/service/user/user.service";
-import { ExecuteWorkflowService } from
"../../service/execute-workflow/execute-workflow.service";
+import { DynamicSchemaService } from
"../../service/dynamic-schema/dynamic-schema.service";
+import { customFormlyFieldType, CANVAS_ONLY_FORMLY_TYPES } from
"../../util/custom-formly-type";
+import { WorkflowCompilingService } from
"../../service/compile-workflow/workflow-compiling.service";
+import { ExecuteWorkflowService, FORM_DEBOUNCE_TIME_MS } from
"../../service/execute-workflow/execute-workflow.service";
import { OperatorMetadataService } from
"../../service/operator-metadata/operator-metadata.service";
+import { FormBindingService, ResolvedField } from
"../../service/form-binding/form-binding.service";
import { WorkflowActionService } from
"../../service/workflow-graph/model/workflow-action.service";
import { GuiConfigService } from "../../../common/service/gui-config.service";
import { WorkflowConsoleService } from
"../../service/workflow-console/workflow-console.service";
@@ -48,11 +55,24 @@ import { CoeditorPresenceService } from
"../../service/workflow-graph/model/coed
import { SAVE_DEBOUNCE_TIME_IN_MS } from "../workspace.component";
/**
- * The Form View: a second way to use a workflow. On top of the title-bar
frame, this PR adds
- * the collapsible read-only workflow preview -- the same workflow editor and
mini-map the canvas
- * uses, embedded here with the graph shape locked (its own
`structureLocked`), built the first
- * time the reader opens the strip. The inputs, running and results are added
by later PRs. A
- * view, not a new object: it opens the same workflow the canvas does.
+ * One rendered input: the resolved binding plus the operator's own formly
field for that property.
+ * Building the field from the operator's JSON schema (not guessing from the
value) is what gives a
+ * file its picker and an attribute its column dropdown.
+ */
+interface RenderedField {
+ resolved: ResolvedField;
+ fields: FormlyFieldConfig[];
+ form: FormGroup;
+ model: Record<string, unknown>;
+}
+
+/**
+ * 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
+ * operator's own formly field, so a file property gets the real picker and an
attribute a column
+ * dropdown -- and writes a filled-in value straight back to its operator, the
same edit the canvas
+ * makes. Nested sub-field overrides, running and results are added by later
PRs. A view, not a new
+ * object: it opens the same workflow the canvas does.
*/
@UntilDestroy()
@Component({
@@ -62,6 +82,8 @@ import { SAVE_DEBOUNCE_TIME_IN_MS } from
"../workspace.component";
imports: [
CommonModule,
FormsModule,
+ ReactiveFormsModule,
+ FormlyModule,
NzAvatarModule,
NzIconModule,
UserIconComponent,
@@ -76,6 +98,14 @@ export class WorkflowFormComponent implements OnInit,
OnDestroy {
public loading = true;
/** "Saved at …", worded and formatted exactly as on the operator canvas. */
public autoSaveState = "";
+ /** Write access: only then does a filled-in value write back, and only then
does the page save. */
+ public canEdit = false;
+
+ /** The exposed inputs, resolved against the live graph, and the formly
field built for each. */
+ private parameters: ResolvedField[] = [];
+ public rendered: RenderedField[] = [];
+ /** Torn down and replaced whenever the form is rebuilt, so an old field's
write-back stops. */
+ private formsRebuilt = new Subject<void>();
/** The collapsible workflow preview: closed until the reader opens it. */
public workflowOpen = false;
@@ -101,11 +131,22 @@ export class WorkflowFormComponent implements OnInit,
OnDestroy {
private workflowActionService: WorkflowActionService,
private workflowPersistService: WorkflowPersistService,
private operatorMetadataService: OperatorMetadataService,
+ private formBindingService: FormBindingService,
private executeWorkflowService: ExecuteWorkflowService,
private workflowResultService: WorkflowResultService,
private notificationService: NotificationService,
private userService: UserService,
+ private formlyJsonschema: FormlyJsonschema,
private cdr: ChangeDetectorRef,
+ // Injected for its side effect: it fills its map from the operator-add
stream, so it has to
+ // exist before the workflow loads or every operator arrives unregistered
and anything asking
+ // for a schema later throws. It also carries the per-instance schema
(upstream column names)
+ // that turns an attribute box into a dropdown.
+ private dynamicSchemaService: DynamicSchemaService,
+ // Injected for its side effect: it compiles on graph changes and writes
column names into each
+ // operator's dynamic schema. Nothing else on this page injects it, so
without this line it
+ // never runs and an attribute input stays a plain text box.
+ private workflowCompilingService: WorkflowCompilingService,
private computingUnitStatusService: ComputingUnitStatusService,
private workflowConsoleService: WorkflowConsoleService,
private host: ElementRef<HTMLElement>,
@@ -121,6 +162,34 @@ export class WorkflowFormComponent implements OnInit,
OnDestroy {
}
this.wid = wid;
this.load(wid);
+
+ // Attribute boxes become dropdowns only after compilation writes the
column enums into each
+ // operator's dynamic schema -- which lands after these cards were built.
Rebuild on the
+ // compilation-state stream, a ReplaySubject(1) so a late subscriber (this
page reloads fresh
+ // on every Canvas<->Form switch) gets the current state at once. Skip it
while someone is
+ // typing, so a rebuild does not throw away a half-entered value under the
cursor.
+ this.workflowCompilingService
+ .getCompilationStateInfoChangedStream()
+ .pipe(debounceTime(FORM_DEBOUNCE_TIME_MS), untilDestroyed(this))
+ .subscribe(() => {
+ if (this.isTypingInTheForm()) {
+ return;
+ }
+ this.readConfig();
+ });
+
+ // Exposing or un-exposing a property in the panel changes the definition;
the inputs above have
+ // to follow at once, which is the whole point of editing them side by
side. Today this fires for
+ // this client's own edits; once #8351 moves formBinding into the shared
model it also fires for
+ // a co-editor's -- so, like the compilation path, skip the rebuild while
the reader is typing, or
+ // a remote change would throw away a half-entered value under the cursor.
+
this.workflowActionService.formBindingChanged$.pipe(untilDestroyed(this)).subscribe(()
=> {
+ if (this.isTypingInTheForm()) {
+ return;
+ }
+ this.readConfig();
+ this.cdr.detectChanges();
+ });
}
private load(wid: number): void {
@@ -146,6 +215,7 @@ export class WorkflowFormComponent implements OnInit,
OnDestroy {
// back for any canvas-default workflow.
this.workflowName = workflow.name;
this.storedPositions = { ...(workflow.content?.operatorPositions ??
{}) };
+ this.canEdit = !workflow.readonly;
this.workflowActionService.setNewSharedModel(wid,
this.userService.getCurrentUser());
this.workflowActionService.reloadWorkflow(workflow);
// The workflow is shown, not edited, from here: dragging operators
around or
@@ -153,6 +223,7 @@ export class WorkflowFormComponent implements OnInit,
OnDestroy {
this.applyEditability();
this.refreshSavedState();
this.later(() => this.adjustWorkflowNameWidth(), 0);
+ this.readConfig();
this.registerMetadataRefresh();
this.registerAutoPersist();
this.loading = false;
@@ -175,6 +246,161 @@ export class WorkflowFormComponent implements OnInit,
OnDestroy {
this.workflowActionService.disableWorkflowModification();
}
+ //
---------------------------------------------------------------------------
+ // Inputs: the exposed properties, rendered as their operators' own fields
+ //
---------------------------------------------------------------------------
+
+ /** Whether the cursor is currently inside one of this page's inputs. */
+ private isTypingInTheForm(): boolean {
+ const active = document.activeElement as HTMLElement | null;
+ if (!active || !this.host.nativeElement.contains(active)) {
+ return false;
+ }
+ return ["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName) ||
active.isContentEditable;
+ }
+
+ private readConfig(): void {
+ this.parameters = this.formBindingService.resolveFields();
+ this.buildForm();
+ }
+
+ /**
+ * Build the form from the operators' JSON schemas (FormlyJsonschema),
keeping the one field per
+ * exposed property. Each input gets its own form keyed by binding id.
+ */
+ private buildForm(): void {
+ this.formsRebuilt.next();
+ this.rendered = this.visibleFields
+ .map(field => this.renderField(field))
+ .filter((r): r is RenderedField => r !== undefined);
+ }
+
+ private renderField(resolved: ResolvedField): RenderedField | undefined {
+ const { binding } = resolved;
+ const schema = this.operatorSchemaFor(binding.operatorID);
+ if (!schema) {
+ return undefined;
+ }
+ const operator =
this.workflowActionService.getTexeraGraph().getOperator(binding.operatorID);
+ const operatorType = operator?.operatorType;
+ const full = this.formlyJsonschema.toFieldConfig(cloneDeep(schema) as
never, {
+ map: (mapped, source) => {
+ // Render the exact custom widget the operator property panel would
(file/model/dataset
+ // pickers, image/audio uploaders, ...), shared via
customFormlyFieldType so an exposed
+ // property shows its real control instead of degrading to a text box.
+ const customType = customFormlyFieldType({
+ key: mapped.key,
+ operatorType,
+ description: (source as { description?: string })?.description,
+ currentType: mapped.type,
+ });
+ // Canvas-only widgets (code editor, drag-reorder) do not work here;
an older workflow may
+ // already carry one, so leave it to formly's default editable control
rather than a widget
+ // that cannot function on a form.
+ if (customType && !CANVAS_ONLY_FORMLY_TYPES.has(customType)) {
+ mapped.type = customType;
+ }
+ return mapped;
+ },
+ });
+ const source = (full.fieldGroup ?? []).find(child => child.key ===
binding.propertyKey);
+ if (!source) {
+ return undefined;
+ }
+
+ const field = cloneDeep(source);
+ // The schema's own title ("Attributes", "Limit", "File") -- the reader's
title when unnamed.
+ // Falls back to this, not the lower-camel key ("fileName"), which would
read inconsistently.
+ const schemaLabel = (source.props?.label as string) || binding.propertyKey;
+ field.key = binding.id;
+ field.props = {
+ ...(field.props ?? {}),
+ label: binding.displayName || schemaLabel,
+ // The schema's own description is the operator author's note to whoever
wired the operator
+ // up; it is not guidance to a form reader, and formly shows it once per
scalar field. Drop it
+ // here so it does not appear unbidden under the input.
+ description: "",
+ };
+
+ const form = new FormGroup({});
+ // Seed the model with the operator's other properties as read-only
context, not just this
+ // input's own value: some custom widgets read a sibling to decide what to
show -- the
+ // HuggingFace model picker reads `task` to load the right models and
label the field. Only
+ // this binding's value is ever written back (see below); the context is
never persisted, and
+ // it is cloned so a widget that mutates it cannot reach through to the
real operator.
+ const model: Record<string, unknown> = {
+ ...cloneDeep(operator?.operatorProperties ?? {}),
+ [binding.id]: cloneDeep(resolved.value),
+ };
+ if (this.canEdit) {
+ form.valueChanges
+ .pipe(debounceTime(FORM_DEBOUNCE_TIME_MS),
takeUntil(this.formsRebuilt), untilDestroyed(this))
+ .subscribe(() => {
+ // Formly emits the schema's empty default while building the
control, before any edit;
+ // writing that back silently wiped the operator's real value (both
views edit one
+ // workflow). So only accept a dirtied form, or a value that differs
from the operator's
+ // without being emptier (some controls set values without marking
dirty).
+ const next = model[binding.id];
+ const current =
this.formBindingService.readValue(binding.operatorID, binding.propertyKey);
+ const isEmpty = (v: unknown) => v === undefined || v === null || v
=== "";
+ const unchanged = JSON.stringify(next ?? null) ===
JSON.stringify(current ?? null);
+ if (unchanged || (!form.dirty && isEmpty(next) &&
!isEmpty(current))) {
+ return;
+ }
+ // Write straight onto the operator (the same edit the canvas makes)
and refresh this
+ // card's snapshot, which the template reads.
+ this.formBindingService.writeValue(binding, next);
+ this.parameters = this.formBindingService.resolveFields();
+ const refreshed = this.parameters.find(p => p.binding.id ===
binding.id);
+ const card = this.rendered.find(r => r.resolved.binding.id ===
binding.id);
+ if (refreshed && card) {
+ card.resolved = refreshed;
+ }
+ this.cdr.detectChanges();
+ });
+ } else {
+ // A read-only viewer sees the author's values and can run with them,
but cannot change them.
+ // Disable at the field level, not with form.disable(): formly builds
its controls into the
+ // form after this, and a FormGroup disabled while still empty does not
disable controls added
+ // later (it re-enables itself), so the input stayed editable.
props.disabled is what formly
+ // honours, and it cascades to a nested property's sub-fields. No
write-back is wired either.
+ field.props = { ...(field.props ?? {}), disabled: true };
+ }
+
+ return { resolved, fields: [field], form, model };
+ }
+
+ private operatorSchemaFor(operatorID: string): object | undefined {
+ const graph = this.workflowActionService.getTexeraGraph();
+ if (!graph.hasOperator(operatorID)) {
+ return undefined;
+ }
+ try {
+ // Prefer the per-instance schema: it carries the upstream column names,
so an attribute
+ // picker renders as a dropdown of real columns rather than a text box.
+ return this.dynamicSchemaService.getDynamicSchema(operatorID).jsonSchema;
+ } catch {
+ try {
+ return
this.operatorMetadataService.getOperatorSchema(graph.getOperator(operatorID).operatorType).jsonSchema;
+ } catch {
+ return undefined;
+ }
+ }
+ }
+
+ /**
+ * 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.
+ */
+ public get visibleFields(): ResolvedField[] {
+ return this.parameters.filter(field => !field.brokenReason);
+ }
+
+ public trackByRendered(_: number, rendered: RenderedField): string {
+ return rendered.resolved.binding.id;
+ }
+
/** Open or close the workflow preview; opening it builds the canvas the
first time. */
public toggleWorkflow(): void {
this.workflowOpen = !this.workflowOpen;
@@ -304,6 +530,12 @@ export class WorkflowFormComponent implements OnInit,
OnDestroy {
* stray "Untitled workflow" rows when the page is left before its workflow
loaded.
*/
private save(): 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.
+ if (!this.canEdit) {
+ return;
+ }
if (!this.userService.isLogin() ||
!this.workflowPersistService.isWorkflowPersistEnabled()) {
return;
}
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 fc50486634..d6e6a4cb93 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,8 +18,11 @@
*/
import { DatePipe } from "@angular/common";
+import { FormGroup } from "@angular/forms";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, Router } from "@angular/router";
+import { FormlyForm, FormlyModule } from "@ngx-formly/core";
+import { FormlyJsonschema } from "@ngx-formly/core/json-schema";
import { EMPTY, of, Subject } from "rxjs";
import { WorkflowFormComponent } from "./workflow-form.component";
@@ -29,6 +32,9 @@ import { CoeditorPresenceService } from
"../../service/workflow-graph/model/coed
import { WorkflowActionService } from
"../../service/workflow-graph/model/workflow-action.service";
import { WorkflowPersistService } from
"../../../common/service/workflow-persist/workflow-persist.service";
import { OperatorMetadataService } from
"../../service/operator-metadata/operator-metadata.service";
+import { FormBindingService } from
"../../service/form-binding/form-binding.service";
+import { DynamicSchemaService } from
"../../service/dynamic-schema/dynamic-schema.service";
+import { WorkflowCompilingService } from
"../../service/compile-workflow/workflow-compiling.service";
import { ExecuteWorkflowService } from
"../../service/execute-workflow/execute-workflow.service";
import { WorkflowResultService } from
"../../service/workflow-result/workflow-result.service";
import { NotificationService } from
"../../../common/service/notification/notification.service";
@@ -60,10 +66,18 @@ describe("WorkflowFormComponent (rendered template)", () =>
{
/* eslint-disable no-restricted-syntax */
TestBed.overrideComponent(UserIconComponent, { set: { template: "" } });
TestBed.overrideComponent(CoeditorUserIconComponent, { set: { template: ""
} });
+ // Blank the formly-form child too: rendering real fields needs the
ng-zorro type registry the
+ // property panel sets up, which is out of scope here. Blanking the child
(not the page) keeps
+ // the page's own inputs markup -- the section head, the empty state, the
card and the form
+ // wrapper -- rendered and covered.
+ TestBed.overrideComponent(FormlyForm, { set: { template: "" } });
/* eslint-enable no-restricted-syntax */
await TestBed.configureTestingModule({
- imports: [WorkflowFormComponent],
+ // forRoot registers the FormlyConfig the form builder needs: the page
imports FormlyModule
+ // (standalone) but the root config lives with the app; supply it here
so the blanked
+ // formly-form still builds instead of throwing "missing forRoot()".
+ imports: [WorkflowFormComponent, FormlyModule.forRoot()],
providers: [
// One co-editor so the collaborator row (the *ngFor) renders and is
covered.
{
@@ -85,6 +99,12 @@ describe("WorkflowFormComponent (rendered template)", () => {
setWorkflowName: vi.fn(),
workflowChanged: () => EMPTY,
workflowMetaDataChanged: () => EMPTY,
+ formBindingChanged$: EMPTY,
+ getTexeraGraph: () => ({
+ triggerCenterEvent: vi.fn(),
+ hasOperator: () => false,
+ getOperator: () => undefined,
+ }),
},
},
{
@@ -96,6 +116,16 @@ describe("WorkflowFormComponent (rendered template)", () =>
{
},
},
{ provide: OperatorMetadataService, useValue: { getOperatorMetadata:
() => of({}) } },
+ {
+ provide: FormBindingService,
+ useValue: { resolveFields: () => [], readValue: () => undefined,
writeValue: vi.fn() },
+ },
+ { provide: FormlyJsonschema, useValue: { toFieldConfig: () => ({
fieldGroup: [] }) } },
+ { provide: DynamicSchemaService, useValue: { getDynamicSchema: () =>
({ jsonSchema: {} }) } },
+ {
+ provide: WorkflowCompilingService,
+ useValue: { getCompilationStateInfoChangedStream: () => EMPTY },
+ },
{ provide: ExecuteWorkflowService, useValue: {
resetExecutionAndWorkers: vi.fn() } },
{ provide: WorkflowResultService, useValue: { clearResults: vi.fn() }
},
{ provide: NotificationService, useValue: { error: vi.fn() } },
@@ -175,6 +205,54 @@ describe("WorkflowFormComponent (rendered template)", ()
=> {
expect(spy).toHaveBeenCalled();
});
+ it("shows the empty state when there are no inputs to fill in", () => {
+ fixture.detectChanges();
+ finishLoad();
+
+ expect(el(".pc-section-head .label")?.textContent?.trim()).toBe("Inputs");
+ expect(el(".empty")).not.toBeNull();
+ expect(el(".params .param")).toBeNull();
+ });
+
+ it("renders an exposed input as a card holding its formly field", () => {
+ fixture.detectChanges();
+ finishLoad();
+ const c = fixture.componentInstance;
+ // One resolved input with a field; the formly-form child is blanked, so
this covers the page's
+ // own card + form wrapper markup without standing up the field registry.
`parameters` is
+ // internal (drives the empty-state getter), reached here through a cast.
+ (c as any).parameters = [{ binding: { id: "b1" } }];
+ c.rendered = [
+ { resolved: { binding: { id: "b1" } }, fields: [{ key: "b1" }], form:
new FormGroup({}), model: {} },
+ ] as any;
+ fixture.detectChanges();
+
+ expect(el(".empty")).toBeNull();
+ expect(el(".params .param")).not.toBeNull();
+ expect(el(".param .param-form formly-form")).not.toBeNull();
+ });
+
+ it("shows the author's help text under an input and locks a read-only
viewer's card", () => {
+ fixture.detectChanges();
+ finishLoad();
+ const c = fixture.componentInstance;
+ c.canEdit = false;
+ (c as any).parameters = [{ binding: { id: "b1" } }];
+ c.rendered = [
+ {
+ resolved: { binding: { id: "b1", helpText: "Pick a small model." } },
+ fields: [{ key: "b1" }],
+ form: new FormGroup({}),
+ model: {},
+ },
+ ] as any;
+ fixture.detectChanges();
+
+ expect(el(".param .param-help-text")?.textContent?.trim()).toBe("Pick a
small model.");
+ // A read-only viewer's card blocks pointer interaction (covers the extra
widget buttons too).
+ expect(el(".param.read-only")).not.toBeNull();
+ });
+
it("tears the workflow down when the browser unloads (the beforeunload host
binding)", () => {
fixture.detectChanges();
finishLoad();
diff --git
a/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
b/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
index ed0a1c977b..b3a210ab54 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
@@ -21,10 +21,28 @@ import { of, Subject } from "rxjs";
import { vi } from "vitest";
import { DefaultView } from
"../../../dashboard/type/workflow-metadata.interface";
+import { ResolvedField } from
"../../service/form-binding/form-binding.service";
/** The workflow every test opens by default: a form-default workflow,
writable, empty content. */
export const formViewWorkflow = { name: "scGPT", defaultView:
DefaultView.FORM, readonly: false, content: {} };
+/** A binding for one operator property, keyed by id (operator "op-1"). */
+export const binding = (id: string, displayName: string) => ({
+ id,
+ operatorID: "op-1",
+ propertyKey: id,
+ displayName,
+});
+
+/** A resolved (non-broken) input, ready to render. Override
`binding`/`brokenReason` per test. */
+export const resolved = (id: string, displayName: string, extra:
Partial<ResolvedField> = {}): ResolvedField => ({
+ binding: binding(id, displayName),
+ value: "seed",
+ operatorLabel: "Source: Scan",
+ schema: { type: "string" } as any,
+ ...extra,
+});
+
/**
* Mocks shared by every workflow-form spec, plus the component factory. Only
what the current
* slices exercise is mocked; later slices add the dependencies (and streams)
they introduce, so
@@ -36,6 +54,12 @@ export function setupHarness() {
const router = { navigate: vi.fn() };
const workflowChangedStream = new Subject<unknown>();
const workflowMetaDataChangedStream = new Subject<unknown>();
+ // Compilation reports column names late; the form rebuilds its inputs off
this stream.
+ const compilationChanged = 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>();
+ const graphOperators: any[] = [];
// The preview centres the embedded graph once it is built; tests assert
this fired.
const triggerCenterEvent = vi.fn();
@@ -52,7 +76,38 @@ export function setupHarness() {
getWorkflowMetadata: () => ({ name: "scGPT", lastModifiedTime:
1767225600000 }),
setWorkflowName: vi.fn(),
setWorkflowMetadata: vi.fn(),
- getTexeraGraph: () => ({ triggerCenterEvent }),
+ getTexeraGraph: () => ({
+ triggerCenterEvent,
+ hasOperator: (id: string) => hasOperatorIds.has(id),
+ getOperator: (id: string) => graphOperators.find(o => o.operatorID ===
id),
+ }),
+ // Exposing or un-exposing a property announces on this stream; the form
re-reads its config.
+ formBindingChanged$: new Subject<unknown>(),
+ };
+ // Resolves the exposed inputs and reads/writes their values. Tests point
`resolveFields` at the
+ // inputs they want rendered; `readValue` seeds the write-back guard.
+ const formBindingService = {
+ resolveFields: vi.fn().mockReturnValue([]),
+ readValue: vi.fn().mockReturnValue(undefined),
+ writeValue: 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
+ // own decisions (which field, which widget, the write-back), and drives the
`map` callback.
+ const formlyJsonschema = {
+ toFieldConfig: (_schema: any, opts: any) => {
+ const fields = [
+ { key: "n_hvg", props: { label: "N" } },
+ { key: "fileName", props: { label: "File" } },
+ { key: "modelId", props: { label: "Model" } },
+ { key: "datasetVersionPath", props: { label: "Dataset" } },
+ ];
+ return { fieldGroup: opts?.map ? fields.map(opts.map) : fields };
+ },
+ };
+ const dynamicSchemaService = { getDynamicSchema: () => ({ jsonSchema: {} })
};
+ const workflowCompilingService = {
+ getCompilationStateInfoChangedStream: () =>
compilationChanged.asObservable(),
};
const workflowPersistService = {
retrieveWorkflow: vi.fn().mockReturnValue(of(formViewWorkflow)),
@@ -72,8 +127,9 @@ export function setupHarness() {
const computingUnitStatusService = { disconnect: vi.fn() };
const workflowConsoleService = { clearConsoleMessages: vi.fn() };
// The name field is measured off the host; querySelector returns null so
the measuring
- // (DOM-layout, jsdom has none) short-circuits.
- const host = { nativeElement: { querySelector: () => null } };
+ // (DOM-layout, jsdom has none) short-circuits. `contains` drives
isTypingInTheForm; false by
+ // default so a rebuild is never suppressed, and overridden by the tests
that probe typing.
+ const host = { nativeElement: { querySelector: () => null, contains: () =>
false } };
const datePipe = { transform: () => "01/01/2026 00:00:00" };
const config = { env: { formViewEnabled: true } };
@@ -92,11 +148,15 @@ export function setupHarness() {
workflowActionService,
workflowPersistService,
operatorMetadataService,
+ formBindingService,
executeWorkflowService,
workflowResultService,
notificationService,
userService,
+ formlyJsonschema,
cdr,
+ dynamicSchemaService,
+ workflowCompilingService,
computingUnitStatusService,
workflowConsoleService,
host,
@@ -104,6 +164,9 @@ export function setupHarness() {
config,
workflowChangedStream,
workflowMetaDataChangedStream,
+ compilationChanged,
+ hasOperatorIds,
+ graphOperators,
triggerCenterEvent,
};
}
diff --git a/frontend/src/app/workspace/util/custom-formly-type.spec.ts
b/frontend/src/app/workspace/util/custom-formly-type.spec.ts
index 4417ca3f44..85ff479733 100644
--- a/frontend/src/app/workspace/util/custom-formly-type.spec.ts
+++ b/frontend/src/app/workspace/util/custom-formly-type.spec.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { customFormlyFieldType, NON_FORM_FIELD_TYPES } from
"./custom-formly-type";
+import { customFormlyFieldType, NON_FORM_FIELD_TYPES, CANVAS_ONLY_FORMLY_TYPES
} from "./custom-formly-type";
describe("NON_FORM_FIELD_TYPES", () => {
it("blocks only the code editor from being a form field, not the
drag-reorder list", () => {
@@ -27,6 +27,16 @@ describe("NON_FORM_FIELD_TYPES", () => {
});
});
+describe("CANVAS_ONLY_FORMLY_TYPES", () => {
+ it("holds the widgets the form falls back from: the code editor and the
drag-reorder list", () => {
+ expect(CANVAS_ONLY_FORMLY_TYPES.has("codearea")).toBe(true);
+ // the drag has nowhere to attach on a form, so an exposed one degrades to
the default control
+ expect(CANVAS_ONLY_FORMLY_TYPES.has("repeat-section-dnd")).toBe(true);
+ // an ordinary custom widget (a picker/uploader) is rendered as itself,
not fallen back from
+ expect(CANVAS_ONLY_FORMLY_TYPES.has("datasetversionselector")).toBe(false);
+ });
+});
+
describe("customFormlyFieldType", () => {
it("maps a fileName property to the autocomplete input", () => {
expect(customFormlyFieldType({ key: "fileName", operatorType:
"CSVFileScan" })).toBe("inputautocomplete");
diff --git a/frontend/src/app/workspace/util/custom-formly-type.ts
b/frontend/src/app/workspace/util/custom-formly-type.ts
index 6b6e666be9..0ce9086450 100644
--- a/frontend/src/app/workspace/util/custom-formly-type.ts
+++ b/frontend/src/app/workspace/util/custom-formly-type.ts
@@ -25,6 +25,15 @@
*/
export const NON_FORM_FIELD_TYPES: ReadonlySet<string> = new Set(["codearea"]);
+/**
+ * Widgets that only work on the operator canvas, so the Form View does not
render them: it falls
+ * back to formly's default control instead. The code editor (also blocked
from exposure by
+ * {@link NON_FORM_FIELD_TYPES}) and the drag-reorder list, whose drag has
nowhere to attach on a
+ * form -- a workflow may still carry an exposed drag-reorder property from
before, and it degrades
+ * to a plain editable list rather than a control that cannot function here.
+ */
+export const CANVAS_ONLY_FORMLY_TYPES: ReadonlySet<string> = new
Set(["codearea", "repeat-section-dnd"]);
+
/**
* The custom formly widget an operator-schema property renders as, decided
from the property key
* and its operator. A single source of truth extracted from the operator
property panel so that a