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-8437-27f21a9ba2d215767aec385a77d82d426deaf77c
in repository https://gitbox.apache.org/repos/asf/texera.git

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

Reply via email to