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-8436-09b4e74560a0cb16c557816711639493564f9d3c in repository https://gitbox.apache.org/repos/asf/texera.git
commit 27f21a9ba2d215767aec385a77d82d426deaf77c Author: yangzhang75 <[email protected]> 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 <[email protected]> --- .../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; +}
