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/05 18:07:45 UTC

(texera) branch main updated: feat(workflow-form): share the operator property panel's field-type rules (#8436)

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 27f21a9ba2 feat(workflow-form): share the operator property panel's field-type rules (#8436)
27f21a9ba2 is described below

commit 27f21a9ba2d215767aec385a77d82d426deaf77c
Author: yangzhang75 <ya...@uci.edu>
AuthorDate: Sat Sep 5 17:55:59 2026 +0000

    feat(workflow-form): share the operator property panel's field-type rules (#8436)
    
    ### What changes were proposed in this PR?
    
    Field-type infrastructure the Form View reader will build on (parent
    issue #8011). Frontend only. The refactor half is behaviour-preserving;
    the one exposability rule it adds has a single visible effect, and only
    with the feature flag on: in the authoring panel, a code-editor property
    no longer offers the expose checkbox (an ordinary property still does).
    
    * Extract the operator property panel's custom-widget decisions --
    scattered inline through the field map (file picker, model picker,
    HuggingFace image/audio uploaders, dataset selector, code box,
    Projection's drag-reorder list) -- into one pure helper,
    `customFormlyFieldType`, and have the panel call it. Same widget for
    every property as before; the mapping is now a single source of truth a
    later view (the Form View) can share, so a selectable or uploadable
    property never silently degrades to a plain text box there.
    * Add the companion rule `NON_FORM_FIELD_TYPES` (the code editor): a
    property whose control cannot be a form field is not offered for
    exposure, since writing code is not "filling in a value". A drag-reorder
    property (e.g. Projection's columns) stays exposable -- it just renders
    without the drag in the form.
    
    Sub-field addressing (`childPath` / `arrayItemOf`) is intentionally not
    here: it lives on the form component and lands with the render PR that
    consumes it.
    
    ### Any related issues, documentation, discussions?
    
    Closes #8021. Part of the Form View feature (parent issue #8011); builds
    on the merged panel/choose work.
    
    ### How was this PR tested?
    
    Unit tests (vitest). `custom-formly-type.spec.ts` covers the helper at
    100% (statements and branches) -- every widget rule, each guarded
    negative, and the default. The shared
    `operator-property-edit-frame.component.spec.ts` (250 tests) is green,
    confirming the extraction leaves the panel's behaviour unchanged, plus a
    new test that a code-editor property is not offered for exposure while
    an ordinary one is. Every line added to the panel is covered. `ng build`
    is clean.
    
    The one visible effect (with the flag on): in the authoring panel a
    code-editor property no longer offers the expose checkbox, while an
    ordinary property still does. Screenshot available on request.
    
    ### 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 <no...@anthropic.com>
---
 .../operator-property-edit-frame.component.spec.ts | 25 +++++++
 .../operator-property-edit-frame.component.ts      | 52 ++++++--------
 .../app/workspace/util/custom-formly-type.spec.ts  | 82 ++++++++++++++++++++++
 .../src/app/workspace/util/custom-formly-type.ts   | 76 ++++++++++++++++++++
 4 files changed, 206 insertions(+), 29 deletions(-)

diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
index f320f5495d..5326030d78 100644
--- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
+++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
@@ -2988,5 +2988,30 @@ describe("OperatorPropertyEditFrameComponent", () => {
       expect(nestedTableName).toBeDefined();
       expect(nestedTableName?.props?.["toggleExposed"]).toBeUndefined();
     });
+
+    // Writing code is not "filling in a value", so a code-editor property is never offered for
+    // exposure; an ordinary property beside it still is.
+    it("does not offer exposure on a code-editor property", () => {
+      const formBindingService = TestBed.inject(FormBindingService);
+      vi.spyOn(formBindingService, "isExposed").mockReturnValue(false);
+      component.exposeChoosing = true;
+      component.currentOperatorId = "op-code";
+
+      component.setFormlyFormBinding({
+        type: "object",
+        properties: {
+          code: { type: "string", description: "input your code here" },
+          limit: { type: "number" },
+        },
+      });
+
+      const topLevel = component.formlyFields?.[0]?.fieldGroup ?? [];
+      const codeField = topLevel.find(f => f.key === "code");
+      expect(codeField?.type).toBe("codearea");
+      expect(codeField?.props?.["toggleExposed"]).toBeUndefined();
+      // an ordinary property is still offered
+      const decorated = topLevel.filter(f => f.props?.["toggleExposed"] !== undefined).map(f => f.key);
+      expect(decorated).toEqual(["limit"]);
+    });
   });
 });
diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
index daaa137536..be354825fa 100644
--- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
+++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
@@ -38,6 +38,7 @@ import {
   hideTypes,
 } from "../../../types/custom-json-schema.interface";
 import { isDefined } from "../../../../common/util/predicate";
+import { customFormlyFieldType, NON_FORM_FIELD_TYPES } from "../../../util/custom-formly-type";
 import { ExecutionState, OperatorState, OperatorStatistics } from "src/app/workspace/types/execute-workflow.interface";
 import { DynamicSchemaService } from "../../../service/dynamic-schema/dynamic-schema.service";
 import { WorkflowCompilingService } from "../../../service/compile-workflow/workflow-compiling.service";
@@ -905,17 +906,18 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
         };
       }
 
-      // if the title is fileName, then change it to custom autocomplete input template
-      if (mappedField.key === "fileName") {
-        mappedField.type = "inputautocomplete";
-      }
-
-      if (mappedField.key === "huggingFaceModel") {
-        mappedField.type = "huggingface";
-      }
-
-      if (mappedField.key === "modelId" && this.currentOperatorSchema?.operatorType === "HuggingFace") {
-        mappedField.type = "huggingface";
+      // The custom widget this property renders as (file picker, model picker, uploaders, dataset
+      // selector, code box, drag-reorder list). Extracted to customFormlyFieldType so a later view
+      // (the Form View) renders the same control; each field's extra behaviour -- the task-driven
+      // hide rules below, the Projection reorder callback -- stays here.
+      const customType = customFormlyFieldType({
+        key: mappedField.key,
+        operatorType: this.currentOperatorSchema?.operatorType,
+        description: mapSource?.description,
+        currentType: mappedField.type,
+      });
+      if (customType) {
+        mappedField.type = customType;
       }
 
       if (mappedField.key === "task" && this.currentOperatorSchema?.operatorType === "HuggingFace") {
@@ -965,7 +967,7 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
           return undefined;
         };
         if (hfKey === "imageInput") {
-          mappedField.type = "huggingface-image-upload";
+          // type ("huggingface-image-upload") is set by customFormlyFieldType above
           mappedField.expressions = {
             ...mappedField.expressions,
             hide: (field: FormlyFieldConfig) => {
@@ -997,7 +999,7 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
           };
         }
         if (hfKey === "audioInput") {
-          mappedField.type = "huggingface-audio-upload";
+          // type ("huggingface-audio-upload") is set by customFormlyFieldType above
           mappedField.expressions = {
             ...mappedField.expressions,
             hide: (field: FormlyFieldConfig) => {
@@ -1108,14 +1110,6 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
         }
       }
 
-      if (mappedField.key === "uiParameters") {
-        mappedField.type = "ui-udf-parameters";
-      }
-
-      if (mappedField.key === "datasetVersionPath") {
-        mappedField.type = "datasetversionselector";
-      }
-
       // Show the required marker for a field the schema requires conditionally,
       // e.g. Sklearn's Text Attribute once Count Vectorizer is on, or Aggregate's
       // attribute for every function but `count`.
@@ -1145,12 +1139,6 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
         };
       }
 
-      // if the title is python script (for Python UDF), then make this field a custom template 'codearea'
-      if (mapSource?.description?.toLowerCase() === "input your code here") {
-        if (mappedField.type) {
-          mappedField.type = "codearea";
-        }
-      }
       // if presetService is ready and operator property allows presets, setup formly field to display presets
       if (
         this.config.env.userPresetEnabled &&
@@ -1181,7 +1169,8 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
       // }
 
       if (this.currentOperatorSchema?.operatorType === "Projection" && mappedField.key === "attributes") {
-        mappedField.type = "repeat-section-dnd";
+        // type ("repeat-section-dnd") is set by customFormlyFieldType above; the reorder callback
+        // is the canvas's own and stays here.
         mappedField.props = {
           ...mappedField.props,
           reorder: () => this.onFormChanges(cloneDeep(this.formData)),
@@ -1374,7 +1363,12 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
     if (this.exposeChoosing && this.currentOperatorId && fields) {
       const operatorId = this.currentOperatorId;
       for (const topLevelField of fields) {
-        if (typeof topLevelField.key === "string") {
+        // A property whose control cannot be a form field (the code editor) is not offered for
+        // exposure -- its type was already resolved by customFormlyFieldType when the field was
+        // built, so the shared NON_FORM_FIELD_TYPES set decides it here.
+        const fieldType = topLevelField.type;
+        const isNonFormField = typeof fieldType === "string" && NON_FORM_FIELD_TYPES.has(fieldType);
+        if (typeof topLevelField.key === "string" && !isNonFormField) {
           const propertyKey = topLevelField.key;
           ExposePropertyWrapperComponent.decorate(
             topLevelField,
diff --git a/frontend/src/app/workspace/util/custom-formly-type.spec.ts b/frontend/src/app/workspace/util/custom-formly-type.spec.ts
new file mode 100644
index 0000000000..4417ca3f44
--- /dev/null
+++ b/frontend/src/app/workspace/util/custom-formly-type.spec.ts
@@ -0,0 +1,82 @@
+/**
+ * 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 { customFormlyFieldType, NON_FORM_FIELD_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", () => {
+    expect(NON_FORM_FIELD_TYPES.has("codearea")).toBe(true);
+    // a drag-reorder property is still a valid form field (it just renders without the drag)
+    expect(NON_FORM_FIELD_TYPES.has("repeat-section-dnd")).toBe(false);
+  });
+});
+
+describe("customFormlyFieldType", () => {
+  it("maps a fileName property to the autocomplete input", () => {
+    expect(customFormlyFieldType({ key: "fileName", operatorType: "CSVFileScan" })).toBe("inputautocomplete");
+  });
+
+  it("maps huggingFaceModel to the model picker regardless of operator", () => {
+    expect(customFormlyFieldType({ key: "huggingFaceModel", operatorType: "Anything" })).toBe("huggingface");
+  });
+
+  it("maps modelId to the model picker only on a HuggingFace operator", () => {
+    expect(customFormlyFieldType({ key: "modelId", operatorType: "HuggingFace" })).toBe("huggingface");
+    expect(customFormlyFieldType({ key: "modelId", operatorType: "PythonUDF" })).toBeUndefined();
+  });
+
+  it("maps HuggingFace image/audio inputs to their uploaders", () => {
+    expect(customFormlyFieldType({ key: "imageInput", operatorType: "HuggingFace" })).toBe("huggingface-image-upload");
+    expect(customFormlyFieldType({ key: "audioInput", operatorType: "HuggingFace" })).toBe("huggingface-audio-upload");
+    // Off a HuggingFace operator they are plain fields.
+    expect(customFormlyFieldType({ key: "imageInput", operatorType: "PythonUDF" })).toBeUndefined();
+    expect(customFormlyFieldType({ key: "audioInput", operatorType: "PythonUDF" })).toBeUndefined();
+  });
+
+  it("maps uiParameters and datasetVersionPath to their custom controls", () => {
+    expect(customFormlyFieldType({ key: "uiParameters", operatorType: "PythonUDF" })).toBe("ui-udf-parameters");
+    expect(customFormlyFieldType({ key: "datasetVersionPath", operatorType: "CSVFileScan" })).toBe(
+      "datasetversionselector"
+    );
+  });
+
+  it("maps the code-editor property to the code box only when it already has an editable control", () => {
+    expect(
+      customFormlyFieldType({
+        key: "code",
+        operatorType: "PythonUDF",
+        description: "Input your code here",
+        currentType: "textarea",
+      })
+    ).toBe("codearea");
+    // The description matches but the schema left no editable control -> keep the default.
+    expect(
+      customFormlyFieldType({ key: "code", operatorType: "PythonUDF", description: "input your code here" })
+    ).toBeUndefined();
+  });
+
+  it("maps Projection's attributes to the drag-reorder list, only on Projection", () => {
+    expect(customFormlyFieldType({ key: "attributes", operatorType: "Projection" })).toBe("repeat-section-dnd");
+    expect(customFormlyFieldType({ key: "attributes", operatorType: "Filter" })).toBeUndefined();
+  });
+
+  it("returns undefined for an ordinary property, keeping formly's default control", () => {
+    expect(customFormlyFieldType({ key: "limit", operatorType: "Limit" })).toBeUndefined();
+  });
+});
diff --git a/frontend/src/app/workspace/util/custom-formly-type.ts b/frontend/src/app/workspace/util/custom-formly-type.ts
new file mode 100644
index 0000000000..6b6e666be9
--- /dev/null
+++ b/frontend/src/app/workspace/util/custom-formly-type.ts
@@ -0,0 +1,76 @@
+/**
+ * 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.
+ */
+
+/**
+ * Widget types that cannot be a form field at all, so the property is not offered for exposure on
+ * the Form View. Only the code editor: editing code is not "filling in a value", and a form reader
+ * should not be writing code. (A drag-reorder property such as Projection's columns stays exposable
+ * -- it just renders without the drag in the form -- so it is deliberately NOT in this set.)
+ */
+export const NON_FORM_FIELD_TYPES: ReadonlySet<string> = new Set(["codearea"]);
+
+/**
+ * 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
+ * later view (the Form View) can render the same control instead of letting a selectable/uploadable
+ * property silently degrade to a plain text box.
+ *
+ * Returns undefined to keep formly's default control (string/number/textarea/...). Only the widget
+ * TYPE lives here; each caller keeps its own field behaviour (the panel's task-driven hide rules,
+ * validators, and the Projection reorder callback).
+ */
+export function customFormlyFieldType(input: {
+  key: unknown;
+  operatorType: string | undefined;
+  description?: string;
+  /** formly's already-resolved type; the code box only replaces an editable control. */
+  currentType?: unknown;
+}): string | undefined {
+  const { key, operatorType, description, currentType } = input;
+
+  if (key === "fileName") {
+    return "inputautocomplete";
+  }
+  if (key === "huggingFaceModel") {
+    return "huggingface";
+  }
+  if (key === "modelId" && operatorType === "HuggingFace") {
+    return "huggingface";
+  }
+  if (key === "imageInput" && operatorType === "HuggingFace") {
+    return "huggingface-image-upload";
+  }
+  if (key === "audioInput" && operatorType === "HuggingFace") {
+    return "huggingface-audio-upload";
+  }
+  if (key === "uiParameters") {
+    return "ui-udf-parameters";
+  }
+  if (key === "datasetVersionPath") {
+    return "datasetversionselector";
+  }
+  // Python UDF script box: only when the schema already resolved to an editable control.
+  if (description?.toLowerCase() === "input your code here" && currentType) {
+    return "codearea";
+  }
+  if (operatorType === "Projection" && key === "attributes") {
+    return "repeat-section-dnd";
+  }
+  return undefined;
+}