You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/09/02 07:12:23 UTC
(texera) branch gh-readonly-queue/main/pr-8304-fe76eec2dc01522ab41af1a915e87aabb2954ed8 created (now 7b5ee1461f)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-8304-fe76eec2dc01522ab41af1a915e87aabb2954ed8
in repository https://gitbox.apache.org/repos/asf/texera.git
at 7b5ee1461f feat(workflow): form-binding service for Form View (#8304)
This branch includes the following new commits:
new 7b5ee1461f feat(workflow): form-binding service for Form View (#8304)
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(texera) 01/01: feat(workflow): form-binding service for Form View (#8304)
Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-8304-fe76eec2dc01522ab41af1a915e87aabb2954ed8
in repository https://gitbox.apache.org/repos/asf/texera.git
commit 7b5ee1461fce5f3c1b7ab9801222771ad7388c4b
Author: yangzhang75 <ya...@uci.edu>
AuthorDate: Wed Sep 2 07:12:00 2026 +0000
feat(workflow): form-binding service for Form View (#8304)
### What changes were proposed in this PR?
Part of the Form View feature stack (parent issue #8011). This slice
adds `FormBindingService`, the single reader and writer of a workflow's
Form View definition. It builds on the shared types and collaboration
model from #8275 (now merged), so the diff is just the two form-binding
files.
The service:
* Resolves each exposed operator property against the live graph,
pairing a binding with its current value and schema, and flagging
bindings whose operator was deleted or whose property no longer exists
so the author can repair them while readers never see them.
* Reads and writes filled in values through the same
`setOperatorProperty` edit the operator canvas makes, so both views stay
in sync and a run started from either behaves identically.
* Manages which properties are exposed (add, remove, reorder) and per
sub field overrides (rename, hide), and records which operators' results
the form shows as the form's own selection. This is a display filter
that follows the canvas's view-result set and never changes it, so a
normal canvas user's result viewing is unaffected.
The definition is presentation only and never affects execution: a
workflow still runs the same even when its form is empty or broken.
### Any related issues, documentation, discussions?
Closes #8016.
Part of the Form View feature (parent issue #8011); builds on the shared
types (`FormFieldBinding`, `FormBindingConfig`) and collaboration model
from #8275.
### How was this PR tested?
35 unit tests (vitest) covering resolution against the live graph
(healthy, deleted operator, missing property), value read and write
through the operator, exposing and reordering, sub field overrides,
result selection (which never touches the canvas), and the dynamic then
static schema fallback. Coverage is 100% of statements, branches, and
functions for the service.
### Was this PR authored or co-authored using generative AI tooling?
Yes. Co-authored with Claude
Co-authored-by: Claude Opus 4.8 <no...@anthropic.com>
---
.../form-binding/form-binding.service.spec.ts | 393 +++++++++++++++++++++
.../service/form-binding/form-binding.service.ts | 298 ++++++++++++++++
2 files changed, 691 insertions(+)
diff --git a/frontend/src/app/workspace/service/form-binding/form-binding.service.spec.ts b/frontend/src/app/workspace/service/form-binding/form-binding.service.spec.ts
new file mode 100644
index 0000000000..c87115afc1
--- /dev/null
+++ b/frontend/src/app/workspace/service/form-binding/form-binding.service.spec.ts
@@ -0,0 +1,393 @@
+/**
+ * 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 { TestBed } from "@angular/core/testing";
+import { FormBindingService } from "./form-binding.service";
+import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service";
+import { WorkflowUtilService } from "../workflow-graph/util/workflow-util.service";
+import { JointUIService } from "../joint-ui/joint-ui.service";
+import { UndoRedoService } from "../undo-redo/undo-redo.service";
+import { OperatorMetadataService } from "../operator-metadata/operator-metadata.service";
+import { StubOperatorMetadataService } from "../operator-metadata/stub-operator-metadata.service";
+import { commonTestProviders } from "../../../common/testing/test-utils";
+import { mockPoint, mockScanPredicate, mockSentimentPredicate } from "../workflow-graph/model/mock-workflow-data";
+
+describe("FormBindingService", () => {
+ let service: FormBindingService;
+ let workflowActionService: WorkflowActionService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [
+ FormBindingService,
+ WorkflowActionService,
+ WorkflowUtilService,
+ JointUIService,
+ UndoRedoService,
+ { provide: OperatorMetadataService, useClass: StubOperatorMetadataService },
+ ...commonTestProviders,
+ ],
+ });
+ service = TestBed.inject(FormBindingService);
+ workflowActionService = TestBed.inject(WorkflowActionService);
+ workflowActionService.addOperator(mockScanPredicate, mockPoint);
+ });
+
+ const scanId = mockScanPredicate.operatorID;
+
+ it("starts with nothing exposed", () => {
+ expect(service.getConfig().fields).toEqual([]);
+ expect(service.getConfig().resultOperatorIds).toEqual([]);
+ });
+
+ describe("exposing a property", () => {
+ it("seeds the input from the operator's schema and current value", () => {
+ workflowActionService.setOperatorProperty(scanId, { tableName: "twitter" });
+
+ service.addBinding(scanId, "tableName");
+
+ const [binding] = service.getConfig().fields;
+ expect(binding.operatorID).toBe(scanId);
+ expect(binding.propertyKey).toBe("tableName");
+ // taken from the schema, so the author starts from a readable label
+ expect(binding.displayName).toBe("table name");
+ // Not seeded from the schema: that description is written for whoever wired the
+ // operator up, and shown to a reader it reads as advice the author never gave.
+ expect(binding.helpText).toBeUndefined();
+ });
+
+ it("does not expose the same property twice", () => {
+ service.addBinding(scanId, "tableName");
+ service.addBinding(scanId, "tableName");
+
+ expect(service.getConfig().fields.length).toBe(1);
+ });
+ });
+
+ // Filling in an input is the same edit as changing the property on the regular
+ // canvas. That equivalence is what lets both views run the very same workflow.
+ describe("filling in a value", () => {
+ it("writes through to the operator's property", () => {
+ service.addBinding(scanId, "tableName");
+ const [binding] = service.getConfig().fields;
+
+ service.writeValue(binding, "reddit");
+
+ expect(workflowActionService.getTexeraGraph().getOperator(scanId).operatorProperties["tableName"]).toBe("reddit");
+ });
+
+ it("leaves the operator's other properties alone", () => {
+ workflowActionService.setOperatorProperty(scanId, { tableName: "twitter", keep: "me" });
+ service.addBinding(scanId, "tableName");
+ const [binding] = service.getConfig().fields;
+
+ service.writeValue(binding, "reddit");
+
+ expect(workflowActionService.getTexeraGraph().getOperator(scanId).operatorProperties["keep"]).toBe("me");
+ });
+ });
+
+ describe("resolving against the live graph", () => {
+ it("pairs a healthy binding with its value and schema", () => {
+ workflowActionService.setOperatorProperty(scanId, { tableName: "twitter" });
+ service.addBinding(scanId, "tableName");
+
+ const [resolved] = service.resolveFields();
+
+ expect(resolved.value).toBe("twitter");
+ expect(resolved.schema?.title).toBe("table name");
+ expect(resolved.operatorLabel).toBe("Source: Scan");
+ expect(resolved.brokenReason).toBeUndefined();
+ });
+
+ // Deleting the operator on the regular canvas must not leave the form offering an
+ // input that can no longer do anything.
+ it("flags a binding whose operator is gone", () => {
+ service.addBinding(scanId, "tableName");
+ workflowActionService.deleteOperator(scanId);
+
+ const [resolved] = service.resolveFields();
+
+ expect(resolved.brokenReason).toContain("removed from the workflow");
+ });
+
+ it("flags a raw key that is not a property of its operator", () => {
+ service.addBinding(scanId, "tableName");
+ const [binding] = service.getConfig().fields;
+
+ service.updateBinding(binding.id, { propertyKey: "nonsense" });
+
+ expect(service.resolveFields()[0].brokenReason).toContain("not a setting of");
+ });
+ });
+
+ describe("arranging the form", () => {
+ beforeEach(() => {
+ workflowActionService.addOperator(mockSentimentPredicate, mockPoint);
+ service.addBinding(scanId, "tableName");
+ service.addBinding(mockSentimentPredicate.operatorID, "attribute");
+ });
+
+ it("reorders by moving an input to a new position", () => {
+ service.reorder(1, 0);
+
+ expect(service.getConfig().fields.map(p => p.propertyKey)).toEqual(["attribute", "tableName"]);
+ });
+
+ it("ignores a move that goes nowhere or out of range", () => {
+ service.reorder(0, 0);
+ service.reorder(0, 5);
+ service.reorder(-1, 0);
+
+ expect(service.getConfig().fields.map(p => p.propertyKey)).toEqual(["tableName", "attribute"]);
+ });
+
+ // The id is what reordering and removal key on, so renaming must not disturb them.
+ it("keeps identity when the display name or raw key changes", () => {
+ const [first] = service.getConfig().fields;
+
+ service.updateBinding(first.id, { displayName: "Input table", propertyKey: "tableName" });
+
+ expect(service.getConfig().fields[0].id).toBe(first.id);
+ expect(service.getConfig().fields[0].displayName).toBe("Input table");
+ });
+
+ it("removes an input", () => {
+ const [first] = service.getConfig().fields;
+
+ service.removeBinding(first.id);
+
+ expect(service.getConfig().fields.map(p => p.propertyKey)).toEqual(["attribute"]);
+ });
+ });
+
+ // The tick box in the property editor drives these two, so they have to be exact
+ // inverses of each other for a double-click to be a no-op.
+ describe("the property editor's expose tick box", () => {
+ it("reports whether a property is already on the form", () => {
+ expect(service.isExposed(scanId, "tableName")).toBe(false);
+
+ service.addBinding(scanId, "tableName");
+
+ expect(service.isExposed(scanId, "tableName")).toBe(true);
+ expect(service.isExposed(scanId, "somethingElse")).toBe(false);
+ });
+
+ it("adds the property when ticked and removes it when unticked", () => {
+ service.setExposed(scanId, "tableName", true);
+ expect(service.getConfig().fields.map(p => p.propertyKey)).toEqual(["tableName"]);
+
+ service.setExposed(scanId, "tableName", false);
+ expect(service.getConfig().fields).toEqual([]);
+ });
+
+ it("is idempotent in both directions", () => {
+ service.setExposed(scanId, "tableName", true);
+ service.setExposed(scanId, "tableName", true);
+ expect(service.getConfig().fields.length).toBe(1);
+
+ service.setExposed(scanId, "tableName", false);
+ service.setExposed(scanId, "tableName", false);
+ expect(service.getConfig().fields.length).toBe(0);
+ });
+ });
+
+ // Choosing what a form offers means visiting several operators, so the mode has to
+ // survive each tick rather than closing itself.
+ describe("the choose-fields mode", () => {
+ it("starts off", () => {
+ expect(service.isChoosing()).toBe(false);
+ });
+
+ it("stays on across ticking properties", () => {
+ service.setChoosing(true);
+
+ service.setExposed(scanId, "tableName", true);
+ service.setExposed(scanId, "tableName", false);
+
+ expect(service.isChoosing()).toBe(true);
+ });
+
+ it("only ends when it is switched off", () => {
+ service.setChoosing(true);
+ service.setChoosing(false);
+
+ expect(service.isChoosing()).toBe(false);
+ });
+
+ it("announces changes, and only real ones", () => {
+ const seen: boolean[] = [];
+ const sub = service.choosing$.subscribe(v => seen.push(v));
+
+ service.setChoosing(true);
+ service.setChoosing(true);
+ service.setChoosing(false);
+
+ expect(seen).toEqual([false, true, false]);
+ sub.unsubscribe();
+ });
+ });
+
+ // A property is rarely one box. The author names what the reader sees and hides what
+ // is none of their business; the schema's own labels are only the default.
+ describe("naming and hiding the fields inside an input", () => {
+ let bindingId: string;
+
+ beforeEach(() => {
+ service.addBinding(scanId, "tableName");
+ bindingId = service.getConfig().fields[0].id;
+ });
+
+ it("says nothing until the author decides something", () => {
+ expect(service.getConfig().fields[0].overrides).toBeUndefined();
+ expect(service.getConfig().fields[0].overrides?.["alias"]).toBeUndefined();
+ });
+
+ it("renames one field without touching the others", () => {
+ service.setFieldOverride(bindingId, "alias", { displayName: "Rename to" });
+
+ const b = service.getConfig().fields[0];
+ expect(b.overrides?.["alias"]?.displayName).toBe("Rename to");
+ expect(b.overrides?.["attribute"]).toBeUndefined();
+ });
+
+ it("hides a field and can bring it back", () => {
+ service.setFieldOverride(bindingId, "alias", { hidden: true });
+ expect(service.getConfig().fields[0].overrides?.["alias"]?.hidden).toBe(true);
+
+ service.setFieldOverride(bindingId, "alias", { hidden: false });
+ expect(service.getConfig().fields[0].overrides?.["alias"]?.hidden).toBeUndefined();
+ });
+
+ // Otherwise the definition slowly fills up with every field the author looked at.
+ it("keeps no entry for a field back at its defaults", () => {
+ service.setFieldOverride(bindingId, "alias", { displayName: "x" });
+ service.setFieldOverride(bindingId, "alias", { displayName: "" });
+
+ expect(service.getConfig().fields[0].overrides).toBeUndefined();
+ });
+
+ // An explicit `undefined` in the patch clears the value, exactly like an empty one; it
+ // must not leave a hollow override that would serialize to `{}`.
+ it("keeps no entry when a patch value is explicitly undefined", () => {
+ service.setFieldOverride(bindingId, "alias", { displayName: undefined });
+ expect(service.getConfig().fields[0].overrides).toBeUndefined();
+
+ service.setFieldOverride(bindingId, "alias", { displayName: "Renamed" });
+ service.setFieldOverride(bindingId, "alias", { displayName: undefined });
+ expect(service.getConfig().fields[0].overrides).toBeUndefined();
+ });
+
+ it("leaves a field alone when the binding does not exist", () => {
+ service.setFieldOverride("no-such-binding", "alias", { hidden: true });
+
+ expect(service.getConfig().fields[0].overrides).toBeUndefined();
+ });
+ });
+
+ describe("choosing which results to show", () => {
+ // The form records only its own selection. It never writes the canvas's view-result flags:
+ // the picker offers exactly the operators the workflow already views, so those results are
+ // already materialised, and a canvas user's own view-result choices are left untouched.
+ it("toggles an operator in and out of the shown set, without touching the canvas", () => {
+ const setView = vi.spyOn(workflowActionService, "setViewOperatorResults");
+
+ service.toggleResultOperator(scanId);
+ expect(service.getConfig().resultOperatorIds).toEqual([scanId]);
+
+ service.toggleResultOperator(scanId);
+ expect(service.getConfig().resultOperatorIds).toEqual([]);
+
+ expect(setView).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("reading, labeling and schema lookup", () => {
+ it("reads a value off the operator, undefined once the operator is gone", () => {
+ workflowActionService.setOperatorProperty(scanId, { tableName: "twitter" });
+ expect(service.readValue(scanId, "tableName")).toBe("twitter");
+ expect(service.readValue("missing", "tableName")).toBeUndefined();
+ });
+
+ it("writing to a missing operator is a no-op", () => {
+ expect(() =>
+ service.writeValue({ id: "b", operatorID: "gone", propertyKey: "x", displayName: "" }, 1)
+ ).not.toThrow();
+ });
+
+ it("labels by custom name (trimmed), else falls back to the operator type", () => {
+ expect(service.operatorLabel({ customDisplayName: " My Step ", operatorType: "X" } as any)).toBe("My Step");
+ expect(service.operatorLabel({ operatorType: "UnknownType" } as any)).toBe("UnknownType");
+ });
+
+ it("returns empty properties for a missing operator", () => {
+ expect((service as any).operatorSchemaProperties("gone")).toEqual({});
+ });
+
+ it("falls back to the static schema when there is no dynamic one", () => {
+ vi.spyOn((service as any).dynamicSchemaService, "getDynamicSchema").mockImplementation(() => {
+ throw new Error("none");
+ });
+ expect((service as any).operatorSchemaProperties(scanId)).toBeDefined();
+ });
+
+ it("returns empty properties when neither dynamic nor static resolves", () => {
+ vi.spyOn((service as any).dynamicSchemaService, "getDynamicSchema").mockImplementation(() => {
+ throw new Error("none");
+ });
+ vi.spyOn((service as any).operatorMetadataService, "getOperatorSchema").mockImplementation(() => {
+ throw new Error("none");
+ });
+ expect((service as any).operatorSchemaProperties(scanId)).toEqual({});
+ });
+
+ it("treats a dynamic schema that carries no properties as empty", () => {
+ vi.spyOn((service as any).dynamicSchemaService, "getDynamicSchema").mockReturnValue({ jsonSchema: {} } as any);
+ expect((service as any).operatorSchemaProperties(scanId)).toEqual({});
+ });
+
+ it("treats a static schema that carries no properties as empty", () => {
+ vi.spyOn((service as any).dynamicSchemaService, "getDynamicSchema").mockImplementation(() => {
+ throw new Error("none");
+ });
+ vi.spyOn((service as any).operatorMetadataService, "getOperatorSchema").mockReturnValue({
+ jsonSchema: {},
+ } as any);
+ expect((service as any).operatorSchemaProperties(scanId)).toEqual({});
+ });
+
+ it("labels an exposed property by its raw key when the schema has no title", () => {
+ service.addBinding(scanId, "no-such-setting");
+ const [binding] = service.getConfig().fields;
+ expect(binding.displayName).toBe("no-such-setting");
+ });
+ });
+
+ // The definition is presentation only; a run reads operator properties and the
+ // graph. This is what guarantees a workflow still runs when its form is broken.
+ it("keeps the definition out of the executable graph", () => {
+ service.addBinding(scanId, "tableName");
+
+ const content = workflowActionService.getWorkflowContent();
+
+ expect(content.formBinding?.fields.length).toBe(1);
+ expect(Object.keys(content.operators[0])).not.toContain("formBinding");
+ expect(Object.keys(content.operators[0].operatorProperties)).not.toContain("displayName");
+ });
+});
diff --git a/frontend/src/app/workspace/service/form-binding/form-binding.service.ts b/frontend/src/app/workspace/service/form-binding/form-binding.service.ts
new file mode 100644
index 0000000000..8990c62a95
--- /dev/null
+++ b/frontend/src/app/workspace/service/form-binding/form-binding.service.ts
@@ -0,0 +1,298 @@
+/**
+ * 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 { Injectable } from "@angular/core";
+import { BehaviorSubject, Observable } from "rxjs";
+import { v4 as uuid } from "uuid";
+import { CustomJSONSchema7 } from "../../types/custom-json-schema.interface";
+import { OperatorPredicate } from "../../types/workflow-common.interface";
+import { FormFieldBinding, FormFieldOverride, FormBindingConfig } from "../../../common/type/workflow";
+import { OperatorMetadataService } from "../operator-metadata/operator-metadata.service";
+import { DynamicSchemaService } from "../dynamic-schema/dynamic-schema.service";
+import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service";
+
+/** A binding paired with the operator field that renders it. */
+export interface ResolvedField {
+ binding: FormFieldBinding;
+ /** The operator's current value -- what the form shows and what a run uses. */
+ value: unknown;
+ /** Label for the operator this input belongs to, for the author's reference. */
+ operatorLabel: string;
+ schema?: CustomJSONSchema7;
+ /**
+ * Set when the binding no longer points at a real property, because the operator
+ * was deleted on the regular canvas or the raw key was mistyped. Broken inputs are
+ * shown to the author with the reason and hidden from everyone else, since filling
+ * one in could not do anything.
+ */
+ brokenReason?: string;
+}
+
+/**
+ * Reads and writes the Form View definition. The one rule it enforces: a definition never
+ * affects a run -- filling an input is the same `setOperatorProperty` edit the canvas makes,
+ * and everything else (names, help text, ordering, instruction) is presentation.
+ */
+@Injectable({ providedIn: "root" })
+export class FormBindingService {
+ /** Whether the author is choosing which properties the form offers. Sticky (choosing spans
+ * several operators), so it stays on until the author turns it off. */
+ private choosingSubject = new BehaviorSubject<boolean>(false);
+ public readonly choosing$: Observable<boolean> = this.choosingSubject.asObservable();
+
+ constructor(
+ private workflowActionService: WorkflowActionService,
+ private operatorMetadataService: OperatorMetadataService,
+ private dynamicSchemaService: DynamicSchemaService
+ ) {}
+
+ public isChoosing(): boolean {
+ return this.choosingSubject.value;
+ }
+
+ public setChoosing(choosing: boolean): void {
+ if (this.choosingSubject.value !== choosing) {
+ this.choosingSubject.next(choosing);
+ }
+ }
+
+ public getConfig(): FormBindingConfig {
+ return this.workflowActionService.getFormBinding();
+ }
+
+ /** Apply a partial edit to the definition, announcing it so it gets saved. */
+ public updateConfig(patch: Partial<FormBindingConfig>): void {
+ this.workflowActionService.setFormBinding({ ...this.getConfig(), ...patch });
+ }
+
+ public setFields(fields: FormFieldBinding[]): void {
+ this.updateConfig({ fields });
+ }
+
+ public updateBinding(id: string, patch: Partial<Omit<FormFieldBinding, "id">>): void {
+ this.setFields(this.getConfig().fields.map(p => (p.id === id ? { ...p, ...patch } : p)));
+ }
+
+ public removeBinding(id: string): void {
+ this.setFields(this.getConfig().fields.filter(p => p.id !== id));
+ }
+
+ /** The binding for one operator property, if it is currently exposed on the form. */
+ private findBinding(operatorID: string, propertyKey: string): FormFieldBinding | undefined {
+ return this.getConfig().fields.find(p => p.operatorID === operatorID && p.propertyKey === propertyKey);
+ }
+
+ /**
+ * Expose an operator property on the form. A no-op if it is already exposed; the display
+ * name starts from the schema's title so the author begins with a readable label.
+ */
+ public addBinding(operatorID: string, propertyKey: string): void {
+ if (this.findBinding(operatorID, propertyKey)) {
+ return;
+ }
+ const schema = this.propertySchema(operatorID, propertyKey);
+ this.setFields([
+ ...this.getConfig().fields,
+ {
+ id: `field-${uuid()}`,
+ operatorID,
+ propertyKey,
+ displayName: (schema?.title as string) || propertyKey,
+ // Deliberately not seeded from the schema's description. That text describes the
+ // operator to whoever wired it up -- "Multiple string key/value pairs" -- and
+ // appearing unbidden under a reader's input it is worse than nothing: it reads
+ // as guidance the author never wrote and cannot be told apart from guidance
+ // they did. Empty until the author has something to say.
+ helpText: undefined,
+ },
+ ]);
+ }
+
+ /**
+ * Apply an override to one field. An entry that no longer says anything is deleted
+ * rather than left as an empty object, so the saved definition stays a record of the
+ * author's decisions instead of accumulating every field they happened to look at.
+ */
+ public setFieldOverride(bindingId: string, path: string, patch: Partial<FormFieldOverride>): void {
+ const binding = this.getConfig().fields.find(p => p.id === bindingId);
+ if (!binding) {
+ return;
+ }
+ const merged: FormFieldOverride = { ...(binding.overrides?.[path] ?? {}), ...patch };
+ // Keep only values that actually say something, so an explicit `undefined`/`false`/empty
+ // in the patch clears the key rather than leaving a hollow override behind.
+ if (merged.displayName === undefined || merged.displayName.trim() === "") {
+ delete merged.displayName;
+ }
+ if (merged.hidden !== true) {
+ delete merged.hidden;
+ }
+ const overrides = { ...(binding.overrides ?? {}) };
+ if (Object.keys(merged).length === 0) {
+ delete overrides[path];
+ } else {
+ overrides[path] = merged;
+ }
+ this.updateBinding(bindingId, { overrides: Object.keys(overrides).length > 0 ? overrides : undefined });
+ }
+
+ public isExposed(operatorID: string, propertyKey: string): boolean {
+ return this.findBinding(operatorID, propertyKey) !== undefined;
+ }
+
+ /** Add or remove an input from the form, driven by the property editor's tick box. */
+ public setExposed(operatorID: string, propertyKey: string, exposed: boolean): void {
+ if (exposed) {
+ this.addBinding(operatorID, propertyKey);
+ return;
+ }
+ const existing = this.findBinding(operatorID, propertyKey);
+ if (existing) {
+ this.removeBinding(existing.id);
+ }
+ }
+
+ /** Move an input to a new position; array order is what the form renders. */
+ public reorder(from: number, to: number): void {
+ const fields = [...this.getConfig().fields];
+ if (from === to || from < 0 || to < 0 || from >= fields.length || to >= fields.length) {
+ return;
+ }
+ fields.splice(to, 0, fields.splice(from, 1)[0]);
+ this.setFields(fields);
+ }
+
+ /**
+ * Choose whether an operator's output is shown on the form after a run. This records the
+ * form's own selection only and never changes the canvas's view-result flags: the form
+ * offers exactly the operators the workflow already views, so their results are already
+ * materialised and nothing here needs to touch the graph. Read-only, one direction.
+ */
+ public toggleResultOperator(operatorID: string): void {
+ const shown = this.getConfig().resultOperatorIds;
+ const next = shown.includes(operatorID) ? shown.filter(id => id !== operatorID) : [...shown, operatorID];
+ this.updateConfig({ resultOperatorIds: next });
+ }
+
+ // ---------------------------------------------------------------------------
+ // Values. These are plain operator-property reads and writes.
+ // ---------------------------------------------------------------------------
+
+ public readValue(operatorID: string, propertyKey: string): unknown {
+ return this.getOperator(operatorID)?.operatorProperties?.[propertyKey];
+ }
+
+ /**
+ * Write a filled-in value back to its operator. This is the same edit as changing
+ * the property on the regular canvas, which is why both views agree immediately and
+ * why a run started from either one behaves identically.
+ */
+ public writeValue(binding: FormFieldBinding, value: unknown): void {
+ const operator = this.getOperator(binding.operatorID);
+ if (!operator) {
+ return;
+ }
+ this.workflowActionService.setOperatorProperty(binding.operatorID, {
+ ...operator.operatorProperties,
+ [binding.propertyKey]: value,
+ });
+ }
+
+ // ---------------------------------------------------------------------------
+ // Resolution against the live graph.
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Pair every binding with its current value and schema, flagging the ones that no
+ * longer point anywhere. Callers decide what to do with broken ones: the author sees
+ * them so they can be fixed, everyone else does not.
+ */
+ public resolveFields(): ResolvedField[] {
+ return this.getConfig().fields.map(binding => {
+ const operator = this.getOperator(binding.operatorID);
+ if (!operator) {
+ return {
+ binding,
+ value: undefined,
+ operatorLabel: binding.operatorID,
+ brokenReason: "the step it belonged to was removed from the workflow",
+ };
+ }
+ const label = this.operatorLabel(operator);
+ const schema = this.propertySchema(binding.operatorID, binding.propertyKey);
+ if (!schema) {
+ return {
+ binding,
+ value: undefined,
+ operatorLabel: label,
+ brokenReason: `"${binding.propertyKey}" is not a setting of ${label}`,
+ };
+ }
+ return {
+ binding,
+ value: operator.operatorProperties?.[binding.propertyKey],
+ operatorLabel: label,
+ schema,
+ };
+ });
+ }
+
+ public operatorLabel(operator: OperatorPredicate): string {
+ if (operator.customDisplayName?.trim()) {
+ return operator.customDisplayName.trim();
+ }
+ try {
+ return this.operatorMetadataService.getOperatorSchema(operator.operatorType).additionalMetadata.userFriendlyName;
+ } catch {
+ return operator.operatorType;
+ }
+ }
+
+ private getOperator(operatorID: string): OperatorPredicate | undefined {
+ const graph = this.workflowActionService.getTexeraGraph();
+ return graph.hasOperator(operatorID) ? graph.getOperator(operatorID) : undefined;
+ }
+
+ private operatorSchemaProperties(operatorID: string): Record<string, CustomJSONSchema7> {
+ const operator = this.getOperator(operatorID);
+ if (!operator) {
+ return {};
+ }
+ // The dynamic schema, not the operator's static one. Schema propagation fills in
+ // the list of upstream attribute names, which is what turns an attribute setting
+ // into a dropdown; reading the static schema gave the form a bare text box and
+ // asked people to type a column name from memory.
+ try {
+ const jsonSchema = this.dynamicSchemaService.getDynamicSchema(operatorID).jsonSchema;
+ return (jsonSchema.properties ?? {}) as Record<string, CustomJSONSchema7>;
+ } catch {
+ // No dynamic entry yet -- fall back so the form still renders something.
+ }
+ try {
+ const jsonSchema = this.operatorMetadataService.getOperatorSchema(operator.operatorType).jsonSchema;
+ return (jsonSchema.properties ?? {}) as Record<string, CustomJSONSchema7>;
+ } catch {
+ return {};
+ }
+ }
+
+ private propertySchema(operatorID: string, propertyKey: string): CustomJSONSchema7 | undefined {
+ return this.operatorSchemaProperties(operatorID)[propertyKey];
+ }
+}