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-8438-357d9013e14bac3bcfc7a8bf6ddfdb13937bd55a
in repository https://gitbox.apache.org/repos/asf/texera.git

commit d0ab10d7d63b4e40bbea15a0ea1a285c886fd9ef
Author: yangzhang75 <[email protected]>
AuthorDate: Sun Sep 6 23:42:08 2026 +0000

    feat(workflow-form): render nested and array sub-field inputs (#8438)
    
    ### What changes were proposed in this PR?
    
    Closes #8022. Part of the Form View stack (parent issue #8011), stacked
    on #8437 (PR10) and #8436 (PR9).
    
    The render PR (#8437) shows the top-level exposed inputs. This one
    handles a property that carries sub-fields: an object (a nested group)
    or a repeated section (an array). It renders each sub-field and applies
    the author's per-sub-field setup.
    
    - A walk over the built formly field and its sub-fields drops the
    operator schema's own per-field descriptions (author notes about the
    operator, not guidance to a form reader, and shown once per field by
    formly), and applies the author's stored overrides -- rename and hide --
    keyed by field path, with array indices dropped so one entry covers
    every row.
    - A repeated section builds its row template on demand, so the walk
    wraps the builder rather than the single object it returns, decorating
    every row formly ever creates.
    - Two small statics, `childPath` (the override path for a child, indices
    dropped) and `arrayItemOf` (a repeated section's row template, whether
    formly gives it as a value or a builder), back the keying and are unit
    tested directly.
    
    Writing a filled-in value back to its operator is already handled by
    #8437: formly's model carries the nested value, so a sub-field edit
    persists through the same write-back. This slice is the sub-field
    rendering and overrides only.
    
    ### Any related issues, documentation, discussions?
    
    Closes #8022. Part of the Form View feature (parent issue #8011).
    
    ### How was this PR tested?
    
    Unit tests (vitest). `sub-fields.spec.ts` covers the two statics
    directly (path joining, index dropping, non-name keys; the
    value/builder/throwing/none row-template cases).
    `workflow-form.component.spec.ts` covers the walk against the component:
    renaming and hiding an overridden sub-field of both an object property
    and a repeated section (per row, through the wrapped builder), dropping
    the schema descriptions, leaving an un-overridden sub-field untouched,
    and a scalar array's leaf row template. 100% statement and function
    coverage on the changed source; the remaining uncovered branches are
    defensive `??`/`||` fallbacks. `ng build gui`, eslint and prettier are
    clean, and the full workflow-form suite (73 tests) is green in both the
    single-user and collaboration paths.
    
    The visible effect (with the flag on) is that an exposed nested or array
    property renders its sub-fields, each renamed or hidden as the author
    set up. A screenshot can be added on request.
    
    #### Screenshot
    
    An exposed nested or array property rendered with its **sub-fields**,
    each renamed or hidden as the author set up.
    
    <img width="1271" height="785" alt="Screenshot 2026-09-05 at 11 42
    53 AM"
    
src="https://github.com/user-attachments/assets/62233d72-c64b-421d-b637-2dce4e742405";
    />
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Yes. Co-authored with Claude (Anthropic), reviewed line by line by the
    author before submission.
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../component/workflow-form/sub-fields.spec.ts     |  65 +++++++++++
 .../workflow-form/workflow-form.component.spec.ts  | 120 ++++++++++++++++++++
 .../workflow-form/workflow-form.component.ts       | 124 +++++++++++++++++++--
 .../workflow-form/workflow-form.spec-harness.ts    |  42 +++++++
 4 files changed, 344 insertions(+), 7 deletions(-)

diff --git 
a/frontend/src/app/workspace/component/workflow-form/sub-fields.spec.ts 
b/frontend/src/app/workspace/component/workflow-form/sub-fields.spec.ts
new file mode 100644
index 0000000000..f318ad94df
--- /dev/null
+++ b/frontend/src/app/workspace/component/workflow-form/sub-fields.spec.ts
@@ -0,0 +1,65 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { WorkflowFormComponent } from "./workflow-form.component";
+
+// The two statics that back per-sub-field rename/hide: locating a sub-field 
means walking formly's
+// output, which is a plain box, an object with a fieldGroup, or a repeated 
section whose row
+// template is either a value or a builder function.
+describe("sub-fields of an input", () => {
+  describe("override paths", () => {
+    it("joins nested keys", () => {
+      expect(WorkflowFormComponent.childPath("pairs", 
"key")).toBe("pairs.key");
+      expect(WorkflowFormComponent.childPath("", "value")).toBe("value");
+    });
+
+    it("drops array indices so every row shares one override", () => {
+      expect(WorkflowFormComponent.childPath("pairs", "0")).toBe("pairs");
+      expect(WorkflowFormComponent.childPath("pairs", "12")).toBe("pairs");
+    });
+
+    it("ignores keys that are not names", () => {
+      expect(WorkflowFormComponent.childPath("pairs", 
undefined)).toBe("pairs");
+      expect(WorkflowFormComponent.childPath("", "key")).toBe("key");
+    });
+  });
+
+  describe("finding the row template of a repeated section", () => {
+    it("takes it directly when it is a value", () => {
+      const item = { key: "row" };
+      expect(WorkflowFormComponent.arrayItemOf({ fieldArray: item 
})).toBe(item);
+    });
+
+    it("calls it when formly supplies a builder", () => {
+      const item = { key: "row" };
+      expect(WorkflowFormComponent.arrayItemOf({ fieldArray: () => item 
})).toBe(item);
+    });
+
+    it("gives up quietly on a builder it cannot call", () => {
+      const throwing = () => {
+        throw new Error("needs a real field");
+      };
+      expect(WorkflowFormComponent.arrayItemOf({ fieldArray: throwing as never 
})).toBeUndefined();
+    });
+
+    it("has nothing to offer when there is no array", () => {
+      expect(WorkflowFormComponent.arrayItemOf({ key: "plain" 
})).toBeUndefined();
+    });
+  });
+});
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 3c747ae58b..543eb94005 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
@@ -685,6 +685,126 @@ describe("WorkflowFormComponent", () => {
     });
   });
 
+  // A nested (object) or repeated (array) property carries sub-fields; the 
author can rename and
+  // hide each one, and the schema's own per-field notes are dropped so only 
the author's help text
+  // guides a reader. Overrides are keyed by field path, array indices dropped.
+  describe("nested and array sub-fields", () => {
+    // Expose one property of op-1 with the given binding, then read the 
config.
+    const expose = (bindingExtra: any) => {
+      h.hasOperatorIds.add("op-1");
+      formBindingService.resolveFields.mockReturnValue([resolved("x", "x", { 
binding: bindingExtra })]);
+      (component as any).readConfig();
+      return component.rendered[0].fields[0] as any;
+    };
+
+    it("renames and hides an overridden sub-field of an object property", () 
=> {
+      build(formViewWorkflow).ngOnInit();
+
+      const field = expose({
+        id: "n",
+        operatorID: "op-1",
+        propertyKey: "nested",
+        displayName: "Nested",
+        overrides: { sub: { displayName: "Renamed sub", hidden: true } },
+      });
+
+      const sub = field.fieldGroup[0];
+      expect(sub.key).toBe("sub");
+      expect(sub.props.label).toBe("Renamed sub");
+      expect(sub.hide).toBe(true);
+      // Hidden must not strip the value: formly's resetFieldOnHide default 
would otherwise clear it
+      // from the model on render, and the card writes the whole nested object 
back -- deleting the
+      // author's pinned value. resetOnHide=false keeps it.
+      expect(sub.resetOnHide).toBe(false);
+    });
+
+    it("renames and hides an overridden sub-field of a repeated section, per 
row", () => {
+      build(formViewWorkflow).ngOnInit();
+
+      const field = expose({
+        id: "p",
+        operatorID: "op-1",
+        propertyKey: "predicates",
+        displayName: "Predicates",
+        overrides: { alias: { displayName: "Renamed", hidden: true } },
+      });
+
+      // Formly builds a repeated section's rows on demand; invoke the wrapped 
builder so the walk
+      // decorates the row's sub-fields (every row formly ever makes comes out 
decorated).
+      const row = field.fieldArray({});
+      const alias = row.fieldGroup[0];
+      expect(alias.key).toBe("alias");
+      expect(alias.props.label).toBe("Renamed");
+      expect(alias.hide).toBe(true);
+      expect(alias.resetOnHide).toBe(false);
+    });
+
+    it("drops the schema's own descriptions on the field and its sub-fields", 
() => {
+      build(formViewWorkflow).ngOnInit();
+
+      const field = expose({ id: "n", operatorID: "op-1", propertyKey: 
"nested", displayName: "Nested" });
+
+      expect(field.props.description).toBe("");
+      expect(field.fieldGroup[0].props.description).toBe("");
+    });
+
+    it("leaves a sub-field untouched when the author set no override for it", 
() => {
+      build(formViewWorkflow).ngOnInit();
+
+      const field = expose({ id: "n", operatorID: "op-1", propertyKey: 
"nested", displayName: "Nested" });
+
+      const sub = field.fieldGroup[0];
+      // No override: keeps the schema label and stays visible.
+      expect(sub.props.label).toBe("Sub");
+      expect(sub.hide).toBeUndefined();
+      // A visible field is never opted out of reset-on-hide -- the switch 
rides with the hide.
+      expect(sub.resetOnHide).toBeUndefined();
+    });
+
+    it("drops the description on a scalar array's row template", () => {
+      build(formViewWorkflow).ngOnInit();
+
+      const field = expose({ id: "t", operatorID: "op-1", propertyKey: "tags", 
displayName: "Tags" });
+
+      // The row template is a leaf (no sub-fields); its schema description is 
dropped like the rest.
+      expect(field.fieldArray.props.description).toBe("");
+    });
+
+    it("drops the description on a builder-backed scalar array's leaf row", () 
=> {
+      build(formViewWorkflow).ngOnInit();
+
+      const field = expose({ id: "tf", operatorID: "op-1", propertyKey: 
"tagsFn", displayName: "Tags" });
+      // Invoke the wrapped builder: it returns a leaf row (no fieldGroup), 
which the walk decorates.
+      const row = field.fieldArray({});
+
+      expect(row.props.description).toBe("");
+    });
+
+    it("drops the description on a builder-backed object row without 
reprinting its title", () => {
+      build(formViewWorkflow).ngOnInit();
+
+      const field = expose({ id: "p", operatorID: "op-1", propertyKey: 
"predicates", displayName: "Predicates" });
+      // An object row (fieldGroup): its container is not walked as a root 
(that would reprint the
+      // array's title), but its own items.description would still render once 
per row, so it is
+      // dropped; the row's sub-field is walked as before.
+      const row = field.fieldArray({});
+
+      expect(row.props.description).toBe("");
+      expect(row.fieldGroup[0].props.description).toBe("");
+    });
+
+    it("drops the description on a static object-array's row template", () => {
+      build(formViewWorkflow).ngOnInit();
+
+      const field = expose({ id: "r", operatorID: "op-1", propertyKey: 
"rules", displayName: "Rules" });
+
+      // The template container (fieldArray with a fieldGroup) carries 
items.description; it is
+      // dropped, and its sub-fields are still walked (their descriptions 
dropped too).
+      expect(field.fieldArray.props.description).toBe("");
+      expect(field.fieldArray.fieldGroup[0].props.description).toBe("");
+    });
+  });
+
   describe("keeping the inputs in step with the workflow", () => {
     it("rebuilds the inputs when compilation reports a new state", async () => 
{
       build(formViewWorkflow).ngOnInit();
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 02eed5be3f..260f9c2303 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
@@ -32,7 +32,7 @@ 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";
+import { FormFieldBinding, Workflow, WorkflowContent } from 
"../../../common/type/workflow";
 import { ComputingUnitStatusService } from 
"../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
 import { WorkflowPersistService } from 
"../../../common/service/workflow-persist/workflow-persist.service";
 import { NotificationService } from 
"../../../common/service/notification/notification.service";
@@ -71,8 +71,9 @@ interface RenderedField {
  * 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.
+ * makes, with each sub-field of a nested or repeated property renamed and 
hidden as the author set
+ * it up. Running the workflow and showing results are added by later PRs. A 
view, not a new object:
+ * it opens the same workflow the canvas does.
  */
 @UntilDestroy()
 @Component({
@@ -316,10 +317,6 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
     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({});
@@ -367,9 +364,122 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
       field.props = { ...(field.props ?? {}), disabled: true };
     }
 
+    this.applyFieldOverrides(field, binding);
     return { resolved, fields: [field], form, model };
   }
 
+  /**
+   * The template for one row of a repeated section. formly's `fieldArray` may 
be the template
+   * object or a function that builds one per row; resolve both so an array 
property's sub-fields
+   * are reachable (treating the function case as a leaf hid them). @internal, 
exported for tests.
+   */
+  public static arrayItemOf(node: FormlyFieldConfig): FormlyFieldConfig | 
undefined {
+    const fa = node.fieldArray;
+    if (!fa) {
+      return undefined;
+    }
+    if (typeof fa !== "function") {
+      return fa;
+    }
+    try {
+      return fa(node);
+    } catch {
+      // A builder that needs more context than we can give it tells us 
nothing about the row's
+      // shape; better to list no sub-fields than to guess at them.
+      return undefined;
+    }
+  }
+
+  /**
+   * The override path for a child field: the parent path joined with the 
child's key, but array
+   * indices are dropped so one override entry covers every row of a repeated 
section. @internal,
+   * exported for tests.
+   */
+  public static childPath(parent: string, key: unknown): string {
+    if (typeof key !== "string" || key === "" || /^\d+$/.test(key)) {
+      return parent;
+    }
+    return parent ? parent + "." + key : key;
+  }
+
+  /**
+   * Walk the field and its sub-fields, dropping the operator schema's own 
per-field descriptions
+   * (author notes about the operator, not guidance to a form reader) and 
applying the author's
+   * stored per-sub-field overrides (rename, hide), keyed by field path. A 
repeated section builds
+   * its row template on demand, so its builder is wrapped to decorate every 
row formly ever makes.
+   */
+  private applyFieldOverrides(field: FormlyFieldConfig, binding: 
FormFieldBinding): void {
+    const walk = (node: FormlyFieldConfig, path: string): void => {
+      // Drop the schema's own description on every field, nested ones 
included: on this page the
+      // one piece of guidance is the help text the form's author writes, 
rendered once by the card.
+      node.props = { ...(node.props ?? {}), description: "" };
+      // Apply the author's stored overrides so a reader sees each sub-field 
renamed and hidden as
+      // set up. The root (path "") carries the binding's own displayName, set 
in renderField.
+      if (path) {
+        const override = binding.overrides?.[path] ?? {};
+        if (override.displayName) {
+          node.props = { ...(node.props ?? {}), label: override.displayName };
+        }
+        if (override.hidden) {
+          node.hide = true;
+          // Hidden means "not shown", not "cleared". Formly 7's 
resetFieldOnHide extra defaults to
+          // true, so a field that renders hidden has its value stripped from 
the model -- and this
+          // card writes the whole nested object back, so that strip would 
delete the author's pinned
+          // value for the hidden sub-field the moment a writer opens the 
form. Opt this field out so
+          // its value survives, matching FormFieldOverride.hidden's contract 
(the value still
+          // applies; it is only hidden).
+          node.resetOnHide = false;
+        }
+      }
+      // A repeated section may build its row template on demand, once per 
row. Decorating the
+      // object it returns is pointless -- the next row gets a fresh one. Wrap 
the builder instead,
+      // so every row formly ever creates comes out decorated.
+      if (typeof node.fieldArray === "function") {
+        const build = node.fieldArray;
+        node.fieldArray = (f: FormlyFieldConfig) => {
+          const row = build(f);
+          // Walk what is INSIDE each row, never the row container itself: the 
container carries the
+          // array property's own name, so decorating it as a root (path "") 
printed the group title
+          // a second time above the rows. Its sub-fields keep their own key 
paths, the same ones
+          // their overrides are stored under.
+          const children = row.fieldGroup ?? [];
+          if (children.length === 0) {
+            // A scalar array (a list of strings): the builder returns a leaf 
row with no sub-fields,
+            // so decorate the row itself, mirroring the leaf case of the 
non-function branch below.
+            walk(row, path);
+          } else {
+            // An object row: not walked as a root (that reprints the array's 
group title), but its
+            // own schema description (the items.description) still renders 
once per row via the
+            // field wrapper's nzExtra, so drop just that -- the 
description-removal the walk does for
+            // every other field, minus the title-reprinting root treatment.
+            row.props = { ...(row.props ?? {}), description: "" };
+          }
+          for (const child of children) {
+            walk(child, WorkflowFormComponent.childPath(path, child.key));
+          }
+          return row;
+        };
+        return;
+      }
+      const arrayItem = WorkflowFormComponent.arrayItemOf(node);
+      const children = node.fieldGroup ?? arrayItem?.fieldGroup ?? [];
+      for (const child of children) {
+        walk(child, WorkflowFormComponent.childPath(path, child.key));
+      }
+      // A scalar array (e.g. a list of strings) has a row template with no 
sub-fields of its own;
+      // decorate it directly so its schema description is dropped like every 
other field's.
+      if (arrayItem && !arrayItem.fieldGroup) {
+        walk(arrayItem, path);
+      } else if (arrayItem) {
+        // A static object-array template: its sub-fields are walked above, 
but the template
+        // container's own items.description still renders once per row, so 
drop just that (not
+        // walking it as a root, which would reprint the array's group title).
+        arrayItem.props = { ...(arrayItem.props ?? {}), description: "" };
+      }
+    };
+    walk(field, "");
+  }
+
   private operatorSchemaFor(operatorID: string): object | undefined {
     const graph = this.workflowActionService.getTexeraGraph();
     if (!graph.hasOperator(operatorID)) {
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 b3a210ab54..842cbea78d 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
@@ -101,6 +101,48 @@ export function setupHarness() {
         { key: "fileName", props: { label: "File" } },
         { key: "modelId", props: { label: "Model" } },
         { key: "datasetVersionPath", props: { label: "Dataset" } },
+        // An object property with sub-fields (drives the override walk over a 
fieldGroup). The
+        // schema descriptions are here so a test can assert the walk drops 
them.
+        {
+          key: "nested",
+          props: { label: "Nested", description: "obj note" },
+          fieldGroup: [{ key: "sub", props: { label: "Sub", description: "sub 
note" } }],
+        },
+        // A repeated section whose row template is a builder (drives the 
fieldArray-wrapping path).
+        // The returned row carries its own description (the schema's 
items.description), so a test
+        // can assert the walk drops it -- it would otherwise render once per 
row.
+        {
+          key: "predicates",
+          props: { label: "Predicates" },
+          fieldArray: () => ({
+            props: { description: "row note" },
+            fieldGroup: [{ key: "alias", props: { label: "Alias" } }],
+          }),
+        },
+        // A scalar array: its row template is a leaf (no sub-fields), drives 
the leaf-item branch.
+        {
+          key: "tags",
+          props: { label: "Tags" },
+          fieldArray: { key: "item", props: { label: "Tag", description: "item 
note" } },
+        },
+        // A static object-array template (fieldArray is an object WITH 
sub-fields, not a builder):
+        // drives the object-array-template branch, where the container's own 
items.description must
+        // be dropped even though the container itself is not walked as a root.
+        {
+          key: "rules",
+          props: { label: "Rules" },
+          fieldArray: {
+            props: { description: "rules note" },
+            fieldGroup: [{ key: "field", props: { label: "Field", description: 
"field note" } }],
+          },
+        },
+        // A scalar array whose row template is a BUILDER returning a leaf (no 
fieldGroup): drives
+        // the leaf case inside the fieldArray-function wrapper.
+        {
+          key: "tagsFn",
+          props: { label: "Tags (fn)" },
+          fieldArray: () => ({ key: "item", props: { label: "Tag", 
description: "fn note" } }),
+        },
       ];
       return { fieldGroup: opts?.map ? fields.map(opts.map) : fields };
     },

Reply via email to