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 d0ab10d7d6 feat(workflow-form): render nested and array sub-field
inputs (#8438)
d0ab10d7d6 is described below
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 };
},