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-8946-27bc3b7703c7e044c03a8d76edeb708146441fce in repository https://gitbox.apache.org/repos/asf/texera.git
commit 2faa5c507461a1dfb589cac0699602b4a82479c8 Author: Matthew B. <[email protected]> AuthorDate: Mon Oct 12 00:41:15 2026 +0000 refactor(frontend): extract HuggingFace field bindings into a helper (#8946) ### What changes were proposed in this PR? - Extracted the ~185-line HuggingFace-specific field block out of `operator-property-edit-frame.component.ts` into a pure helper `applyHuggingFaceFieldBindings(field)` in `workspace/util/hugging-face-field-bindings.ts`, beside the existing `custom-formly-type.ts` that owns the sibling "which widget a field renders as" concern. - The component now calls the helper under a single `operatorType === "HuggingFace"` guard; the helper reproduces both original guards (`key === "task"` hide, then the string-key gate), so the task lists, `hide` expressions, required-validators, and `validation.show` flags are byte-identical to before. - Added `hugging-face-field-bindings.spec.ts` with direct unit tests for the hide/validator logic per task (image/audio inputs and their columns, prompt column, text-generation-only controls, context, candidate labels, sentences), coverage that was impossible while the logic was inline. ### Any related issues, documentation, discussions? Closes: #8944 ### How was this PR tested? - Run `cd frontend && npx ng test --include='**/hugging-face-field-bindings.spec.ts'`, expect 15 tests to pass. - Run `cd frontend && npx ng test --include='**/operator-property-edit-frame.component.spec.ts'`, expect the existing suite to pass unchanged (262 pass, 1 pre-existing skip). - In the workflow editor, add a HuggingFace operator and change its Task: confirm the image upload / input image column appear only for image tasks, the audio upload / input audio column only for audio tasks, the prompt column hides for image-only and audio tasks, systemPrompt/maxNewTokens/temperature show only for text-generation, and the required-field validators still block running when a needed input is empty. ### Was this PR authored or co-authored using generative AI tooling? Co-authored with Claude Opus 4.8 in compliance with ASF --- .../operator-property-edit-frame.component.ts | 191 +----------------- .../util/hugging-face-field-bindings.spec.ts | 178 ++++++++++++++++ .../workspace/util/hugging-face-field-bindings.ts | 223 +++++++++++++++++++++ 3 files changed, 404 insertions(+), 188 deletions(-) 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 6825eac1a0..f9c2f6f6c5 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 @@ -39,6 +39,7 @@ import { } from "../../../types/custom-json-schema.interface"; import { isDefined } from "../../../../common/util/predicate"; import { customFormlyFieldType, NON_FORM_FIELD_TYPES } from "../../../util/custom-formly-type"; +import { applyHuggingFaceFieldBindings } from "../../../util/hugging-face-field-bindings"; import { ExecutionState, OperatorState } 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"; @@ -960,194 +961,8 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On mappedField.type = customType; } - if (mappedField.key === "task" && this.currentOperatorSchema?.operatorType === "HuggingFace") { - mappedField.hide = true; - } - - // ── Dynamic field visibility for HuggingFace based on selected task ── - if (this.currentOperatorSchema?.operatorType === "HuggingFace" && typeof mappedField.key === "string") { - const hfKey = mappedField.key; - const imageOnlyTasks = ["image-classification", "object-detection", "image-segmentation", "image-to-text"]; - const imageInputTasks = [ - ...imageOnlyTasks, - "visual-question-answering", - "document-question-answering", - "zero-shot-image-classification", - "image-text-to-text", - "image-to-image", - "image-to-video", - ]; - const audioInputTasks = ["automatic-speech-recognition", "audio-classification"]; - const promptRequiredTasks = [ - "text-generation", - "text-classification", - "token-classification", - "question-answering", - "table-question-answering", - "zero-shot-classification", - "translation", - "summarization", - "feature-extraction", - "fill-mask", - "sentence-similarity", - "text-ranking", - "visual-question-answering", - "document-question-answering", - "zero-shot-image-classification", - ]; - const getSelectedTask = (field: FormlyFieldConfig): string | undefined => { - const fromForm = field.form?.get("task")?.value ?? field.formControl?.parent?.get("task")?.value; - if (typeof fromForm === "string" && fromForm.trim().length > 0) { - return fromForm; - } - const fromModel = field.model?.task; - if (typeof fromModel === "string" && fromModel.trim().length > 0) { - return fromModel; - } - return undefined; - }; - if (hfKey === "imageInput") { - // type ("huggingface-image-upload") is set by customFormlyFieldType above - mappedField.expressions = { - ...mappedField.expressions, - hide: (field: FormlyFieldConfig) => { - const t = getSelectedTask(field); - return t === undefined || !imageInputTasks.includes(t); - }, - }; - mappedField.validators = { - ...mappedField.validators, - requiredImageInput: { - expression: (_control: AbstractControl, field: FormlyFieldConfig) => { - const t = getSelectedTask(field); - if (t === undefined || !imageInputTasks.includes(t)) { - return true; - } - const inputImageCol = field.model?.inputImageColumn; - if (typeof inputImageCol === "string" && inputImageCol.trim().length > 0) { - return true; - } - const value = field.formControl?.value ?? field.model?.imageInput; - return typeof value === "string" && value.trim().length > 0; - }, - message: () => "Upload an image or select an Input Image Column for this task.", - }, - }; - mappedField.validation = { - ...mappedField.validation, - show: true, - }; - } - if (hfKey === "audioInput") { - // type ("huggingface-audio-upload") is set by customFormlyFieldType above - mappedField.expressions = { - ...mappedField.expressions, - hide: (field: FormlyFieldConfig) => { - const t = getSelectedTask(field); - return t === undefined || !audioInputTasks.includes(t); - }, - }; - mappedField.validators = { - ...mappedField.validators, - requiredAudioInput: { - expression: (_control: AbstractControl, field: FormlyFieldConfig) => { - const t = getSelectedTask(field); - if (t === undefined || !audioInputTasks.includes(t)) { - return true; - } - const inputAudioCol = field.model?.inputAudioColumn; - if (typeof inputAudioCol === "string" && inputAudioCol.trim().length > 0) { - return true; - } - const value = field.formControl?.value ?? field.model?.audioInput; - return typeof value === "string" && value.trim().length > 0; - }, - message: () => "Upload audio or select an Input Audio Column for this task.", - }, - }; - mappedField.validation = { - ...mappedField.validation, - show: true, - }; - } - if (hfKey === "inputImageColumn") { - mappedField.expressions = { - ...mappedField.expressions, - hide: (field: FormlyFieldConfig) => { - const t = getSelectedTask(field); - return t === undefined || !imageInputTasks.includes(t); - }, - }; - } - if (hfKey === "inputAudioColumn") { - mappedField.expressions = { - ...mappedField.expressions, - hide: (field: FormlyFieldConfig) => { - const t = getSelectedTask(field); - return t === undefined || !audioInputTasks.includes(t); - }, - }; - } - if (hfKey === "promptColumn") { - mappedField.expressions = { - ...mappedField.expressions, - hide: (field: FormlyFieldConfig) => { - const t = getSelectedTask(field); - return t !== undefined && (imageOnlyTasks.includes(t) || audioInputTasks.includes(t)); - }, - }; - mappedField.validators = { - ...mappedField.validators, - requiredPromptColumn: { - expression: (_control: AbstractControl, field: FormlyFieldConfig) => { - const t = getSelectedTask(field); - if (t === undefined || !promptRequiredTasks.includes(t)) { - return true; - } - const value = field.formControl?.value ?? field.model?.promptColumn; - return typeof value === "string" && value.trim().length > 0; - }, - message: () => "Select a prompt column for this task.", - }, - }; - mappedField.validation = { - ...mappedField.validation, - show: true, - }; - } - if (["systemPrompt", "maxNewTokens", "temperature"].includes(hfKey)) { - mappedField.expressions = { - ...mappedField.expressions, - hide: (field: FormlyFieldConfig) => { - const t = getSelectedTask(field); - return t !== "text-generation"; - }, - }; - } - if (hfKey === "contextColumn") { - mappedField.expressions = { - ...mappedField.expressions, - hide: (field: FormlyFieldConfig) => getSelectedTask(field) !== "question-answering", - }; - } - if (hfKey === "candidateLabels") { - mappedField.expressions = { - ...mappedField.expressions, - hide: (field: FormlyFieldConfig) => { - const t = getSelectedTask(field); - return t !== "zero-shot-classification" && t !== "zero-shot-image-classification"; - }, - }; - } - if (hfKey === "sentencesColumn") { - mappedField.expressions = { - ...mappedField.expressions, - hide: (field: FormlyFieldConfig) => { - const t = getSelectedTask(field); - return t !== "sentence-similarity" && t !== "text-ranking"; - }, - }; - } + if (this.currentOperatorSchema?.operatorType === "HuggingFace") { + applyHuggingFaceFieldBindings(mappedField); } // Show the required marker for a field the schema requires conditionally, diff --git a/frontend/src/app/workspace/util/hugging-face-field-bindings.spec.ts b/frontend/src/app/workspace/util/hugging-face-field-bindings.spec.ts new file mode 100644 index 0000000000..05de5e0266 --- /dev/null +++ b/frontend/src/app/workspace/util/hugging-face-field-bindings.spec.ts @@ -0,0 +1,178 @@ +/** + * 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 { AbstractControl } from "@angular/forms"; +import { FormlyFieldConfig } from "@ngx-formly/core"; +import { applyHuggingFaceFieldBindings } from "./hugging-face-field-bindings"; + +// Builds a field with the given key, runs the bindings on it, and returns it ready to probe. +function bind(key: unknown): FormlyFieldConfig { + const field: FormlyFieldConfig = { key: key as FormlyFieldConfig["key"] }; + applyHuggingFaceFieldBindings(field); + return field; +} + +// Invokes the field's compiled hide expression against a model carrying the selected task. +function hiddenForTask(field: FormlyFieldConfig, task?: string): boolean { + const hide = field.expressions?.hide as (f: FormlyFieldConfig) => boolean; + return hide({ model: task === undefined ? {} : { task } } as FormlyFieldConfig); +} + +// Invokes a named validator's expression (true = valid) against a given field state. +function validatorPasses( + field: FormlyFieldConfig, + name: string, + state: { task?: string; value?: string; model?: Record<string, unknown> } +): boolean { + const validator = (field.validators as Record<string, { expression: Function }>)[name]; + const probe = { + model: { task: state.task, ...(state.model ?? {}) }, + formControl: { value: state.value }, + } as unknown as FormlyFieldConfig; + return validator.expression({} as AbstractControl, probe); +} + +describe("applyHuggingFaceFieldBindings", () => { + it("hides the task field itself", () => { + expect(bind("task").hide).toBe(true); + }); + + it("does nothing for a non-string key", () => { + const field = bind(undefined); + expect(field.expressions).toBeUndefined(); + expect(field.validators).toBeUndefined(); + }); + + it("does nothing for a key the operator does not drive", () => { + const field = bind("modelId"); + expect(field.expressions).toBeUndefined(); + expect(field.validators).toBeUndefined(); + }); + + describe("imageInput", () => { + it("shows for an image task and hides otherwise", () => { + const field = bind("imageInput"); + expect(hiddenForTask(field, "image-classification")).toBe(false); + expect(hiddenForTask(field, "visual-question-answering")).toBe(false); + expect(hiddenForTask(field, "text-generation")).toBe(true); + expect(hiddenForTask(field, undefined)).toBe(true); + }); + + it("requires an upload or an Input Image Column only for an image task", () => { + const field = bind("imageInput"); + // non-image task: always valid + expect(validatorPasses(field, "requiredImageInput", { task: "text-generation" })).toBe(true); + // image task, nothing provided: invalid + expect(validatorPasses(field, "requiredImageInput", { task: "image-classification" })).toBe(false); + // satisfied by an uploaded value + expect(validatorPasses(field, "requiredImageInput", { task: "image-classification", value: "pic.png" })).toBe( + true + ); + // satisfied by an Input Image Column instead + expect( + validatorPasses(field, "requiredImageInput", { + task: "image-classification", + model: { inputImageColumn: "col" }, + }) + ).toBe(true); + }); + }); + + describe("audioInput", () => { + it("shows for an audio task and hides otherwise", () => { + const field = bind("audioInput"); + expect(hiddenForTask(field, "automatic-speech-recognition")).toBe(false); + expect(hiddenForTask(field, "image-classification")).toBe(true); + }); + + it("requires an upload or an Input Audio Column only for an audio task", () => { + const field = bind("audioInput"); + expect(validatorPasses(field, "requiredAudioInput", { task: "text-generation" })).toBe(true); + expect(validatorPasses(field, "requiredAudioInput", { task: "audio-classification" })).toBe(false); + expect(validatorPasses(field, "requiredAudioInput", { task: "audio-classification", value: "a.wav" })).toBe(true); + expect( + validatorPasses(field, "requiredAudioInput", { + task: "audio-classification", + model: { inputAudioColumn: "col" }, + }) + ).toBe(true); + }); + }); + + it("hides inputImageColumn / inputAudioColumn by their task families", () => { + expect(hiddenForTask(bind("inputImageColumn"), "object-detection")).toBe(false); + expect(hiddenForTask(bind("inputImageColumn"), "audio-classification")).toBe(true); + expect(hiddenForTask(bind("inputAudioColumn"), "audio-classification")).toBe(false); + expect(hiddenForTask(bind("inputAudioColumn"), "object-detection")).toBe(true); + }); + + describe("promptColumn", () => { + it("hides only for image-only and audio tasks", () => { + const field = bind("promptColumn"); + expect(hiddenForTask(field, "image-classification")).toBe(true); + expect(hiddenForTask(field, "audio-classification")).toBe(true); + expect(hiddenForTask(field, "text-generation")).toBe(false); + expect(hiddenForTask(field, "visual-question-answering")).toBe(false); + expect(hiddenForTask(field, undefined)).toBe(false); + }); + + it("requires a value only for a prompt-required task", () => { + const field = bind("promptColumn"); + expect(validatorPasses(field, "requiredPromptColumn", { task: "image-classification" })).toBe(true); + expect(validatorPasses(field, "requiredPromptColumn", { task: "summarization" })).toBe(false); + expect(validatorPasses(field, "requiredPromptColumn", { task: "summarization", value: "col" })).toBe(true); + }); + }); + + it("shows text-generation-only controls solely for text-generation", () => { + for (const key of ["systemPrompt", "maxNewTokens", "temperature"]) { + const field = bind(key); + expect(hiddenForTask(field, "text-generation")).toBe(false); + expect(hiddenForTask(field, "summarization")).toBe(true); + } + }); + + it("shows contextColumn only for question-answering", () => { + const field = bind("contextColumn"); + expect(hiddenForTask(field, "question-answering")).toBe(false); + expect(hiddenForTask(field, "text-generation")).toBe(true); + }); + + it("shows candidateLabels only for the zero-shot tasks", () => { + const field = bind("candidateLabels"); + expect(hiddenForTask(field, "zero-shot-classification")).toBe(false); + expect(hiddenForTask(field, "zero-shot-image-classification")).toBe(false); + expect(hiddenForTask(field, "text-classification")).toBe(true); + }); + + it("shows sentencesColumn only for similarity and ranking tasks", () => { + const field = bind("sentencesColumn"); + expect(hiddenForTask(field, "sentence-similarity")).toBe(false); + expect(hiddenForTask(field, "text-ranking")).toBe(false); + expect(hiddenForTask(field, "translation")).toBe(true); + }); + + it("preserves expressions already present on the field", () => { + const existing = () => false; + const field: FormlyFieldConfig = { key: "contextColumn", expressions: { "props.required": existing } }; + applyHuggingFaceFieldBindings(field); + expect(field.expressions?.["props.required"]).toBe(existing); + expect(typeof field.expressions?.hide).toBe("function"); + }); +}); diff --git a/frontend/src/app/workspace/util/hugging-face-field-bindings.ts b/frontend/src/app/workspace/util/hugging-face-field-bindings.ts new file mode 100644 index 0000000000..a3f31e5629 --- /dev/null +++ b/frontend/src/app/workspace/util/hugging-face-field-bindings.ts @@ -0,0 +1,223 @@ +/** + * 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 { AbstractControl } from "@angular/forms"; +import { FormlyFieldConfig } from "@ngx-formly/core"; + +const imageOnlyTasks = ["image-classification", "object-detection", "image-segmentation", "image-to-text"]; +const imageInputTasks = [ + ...imageOnlyTasks, + "visual-question-answering", + "document-question-answering", + "zero-shot-image-classification", + "image-text-to-text", + "image-to-image", + "image-to-video", +]; +const audioInputTasks = ["automatic-speech-recognition", "audio-classification"]; +const promptRequiredTasks = [ + "text-generation", + "text-classification", + "token-classification", + "question-answering", + "table-question-answering", + "zero-shot-classification", + "translation", + "summarization", + "feature-extraction", + "fill-mask", + "sentence-similarity", + "text-ranking", + "visual-question-answering", + "document-question-answering", + "zero-shot-image-classification", +]; + +const getSelectedTask = (field: FormlyFieldConfig): string | undefined => { + const fromForm = field.form?.get("task")?.value ?? field.formControl?.parent?.get("task")?.value; + if (typeof fromForm === "string" && fromForm.trim().length > 0) { + return fromForm; + } + const fromModel = field.model?.task; + if (typeof fromModel === "string" && fromModel.trim().length > 0) { + return fromModel; + } + return undefined; +}; + +/** + * Applies the HuggingFace operator's task-driven field behaviour to a single formly field: the + * `task` field is hidden, and each input field (image/audio uploads and their column pickers, the + * prompt column, text-generation controls, context, candidate labels, sentences) gets the hide + * expression and required-validator that the currently selected task calls for. A no-op for any + * field whose key the HuggingFace operator does not drive. The widget a field renders as is decided + * separately in {@link customFormlyFieldType}. + */ +export function applyHuggingFaceFieldBindings(mappedField: FormlyFieldConfig): void { + if (mappedField.key === "task") { + mappedField.hide = true; + } + + if (typeof mappedField.key !== "string") { + return; + } + const hfKey = mappedField.key; + if (hfKey === "imageInput") { + // type ("huggingface-image-upload") is set by customFormlyFieldType + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t === undefined || !imageInputTasks.includes(t); + }, + }; + mappedField.validators = { + ...mappedField.validators, + requiredImageInput: { + expression: (_control: AbstractControl, field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + if (t === undefined || !imageInputTasks.includes(t)) { + return true; + } + const inputImageCol = field.model?.inputImageColumn; + if (typeof inputImageCol === "string" && inputImageCol.trim().length > 0) { + return true; + } + const value = field.formControl?.value ?? field.model?.imageInput; + return typeof value === "string" && value.trim().length > 0; + }, + message: () => "Upload an image or select an Input Image Column for this task.", + }, + }; + mappedField.validation = { + ...mappedField.validation, + show: true, + }; + } + if (hfKey === "audioInput") { + // type ("huggingface-audio-upload") is set by customFormlyFieldType + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t === undefined || !audioInputTasks.includes(t); + }, + }; + mappedField.validators = { + ...mappedField.validators, + requiredAudioInput: { + expression: (_control: AbstractControl, field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + if (t === undefined || !audioInputTasks.includes(t)) { + return true; + } + const inputAudioCol = field.model?.inputAudioColumn; + if (typeof inputAudioCol === "string" && inputAudioCol.trim().length > 0) { + return true; + } + const value = field.formControl?.value ?? field.model?.audioInput; + return typeof value === "string" && value.trim().length > 0; + }, + message: () => "Upload audio or select an Input Audio Column for this task.", + }, + }; + mappedField.validation = { + ...mappedField.validation, + show: true, + }; + } + if (hfKey === "inputImageColumn") { + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t === undefined || !imageInputTasks.includes(t); + }, + }; + } + if (hfKey === "inputAudioColumn") { + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t === undefined || !audioInputTasks.includes(t); + }, + }; + } + if (hfKey === "promptColumn") { + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t !== undefined && (imageOnlyTasks.includes(t) || audioInputTasks.includes(t)); + }, + }; + mappedField.validators = { + ...mappedField.validators, + requiredPromptColumn: { + expression: (_control: AbstractControl, field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + if (t === undefined || !promptRequiredTasks.includes(t)) { + return true; + } + const value = field.formControl?.value ?? field.model?.promptColumn; + return typeof value === "string" && value.trim().length > 0; + }, + message: () => "Select a prompt column for this task.", + }, + }; + mappedField.validation = { + ...mappedField.validation, + show: true, + }; + } + if (["systemPrompt", "maxNewTokens", "temperature"].includes(hfKey)) { + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t !== "text-generation"; + }, + }; + } + if (hfKey === "contextColumn") { + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => getSelectedTask(field) !== "question-answering", + }; + } + if (hfKey === "candidateLabels") { + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t !== "zero-shot-classification" && t !== "zero-shot-image-classification"; + }, + }; + } + if (hfKey === "sentencesColumn") { + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t !== "sentence-similarity" && t !== "text-ranking"; + }, + }; + } +}
