diff --git a/bin/single-node/nginx.conf b/bin/single-node/nginx.conf
index 688e256d46d..f0d6970f11b 100644
--- a/bin/single-node/nginx.conf
+++ b/bin/single-node/nginx.conf
@@ -33,6 +33,12 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}
+ location /api/workflow-to-python {
+ proxy_pass http://workflow-compiling-service:9090;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+
location /api/dataset {
proxy_pass http://file-service:9092;
proxy_set_header Host $host;
diff --git a/frontend/proxy.config.json b/frontend/proxy.config.json
index 7801e0c256f..27bef687b15 100755
--- a/frontend/proxy.config.json
+++ b/frontend/proxy.config.json
@@ -30,6 +30,11 @@
"secure": false,
"changeOrigin": true
},
+ "/api/workflow-to-python": {
+ "target": "http://localhost:9090",
+ "secure": false,
+ "changeOrigin": true
+ },
"/api/dataset": {
"target": "http://localhost:9092",
"secure": false,
diff --git a/frontend/src/app/common/formly/formly-config.ts b/frontend/src/app/common/formly/formly-config.ts
index c4fc54fd77f..1ea2b679833 100644
--- a/frontend/src/app/common/formly/formly-config.ts
+++ b/frontend/src/app/common/formly/formly-config.ts
@@ -28,6 +28,7 @@ import { DatasetFileSelectorComponent } from "../../workspace/component/dataset-
import { CollabWrapperComponent } from "./collab-wrapper/collab-wrapper/collab-wrapper.component";
import { FormlyRepeatDndComponent } from "./repeat-dnd/repeat-dnd.component";
import { UiUdfParametersComponent } from "../../workspace/component/ui-udf-parameters/ui-udf-parameters.component";
+import { ConstrainedValueComponent } from "../../workspace/component/constrained-value/constrained-value.component";
import { DatasetVersionSelectorComponent } from "../../workspace/component/dataset-version-selector/dataset-version-selector.component";
import { HuggingFaceImageUploadComponent } from "../../workspace/component/hugging-face-image-upload/hugging-face-image-upload.component";
import { HuggingFaceComponent } from "../../workspace/component/hugging-face/hugging-face.component";
@@ -88,6 +89,7 @@ export const TEXERA_FORMLY_CONFIG = {
{ name: "huggingface-image-upload", component: HuggingFaceImageUploadComponent, wrappers: ["form-field"] },
{ name: "repeat-section-dnd", component: FormlyRepeatDndComponent },
{ name: "ui-udf-parameters", component: UiUdfParametersComponent, wrappers: ["form-field"] },
+ { name: "constrainedvalue", component: ConstrainedValueComponent, wrappers: ["form-field"] },
],
wrappers: [
{ name: "preset-wrapper", component: PresetWrapperComponent },
diff --git a/frontend/src/app/common/formly/formly-utils.spec.ts b/frontend/src/app/common/formly/formly-utils.spec.ts
index 4bec75a6692..6f8feea682f 100644
--- a/frontend/src/app/common/formly/formly-utils.spec.ts
+++ b/frontend/src/app/common/formly/formly-utils.spec.ts
@@ -17,14 +17,26 @@
* under the License.
*/
-import { FormlyFieldConfig } from "@ngx-formly/core";
+import { FormlyFieldConfig, FormlyModule } from "@ngx-formly/core";
import {
createOutputFormChangeEventStream,
createShouldHideFieldFunc,
+ createValueRulesValidator,
getFieldByName,
+ matchingValueRule,
setChildTypeDependency,
setHideExpression,
+ setValueRules,
+ valueRulesValidationMessage,
} from "./formly-utils";
+import { ValueRuleSet } from "../../workspace/types/custom-json-schema.interface";
+import { Component } from "@angular/core";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { AbstractControl, FormGroup, ReactiveFormsModule } from "@angular/forms";
+import { FormlyNgZorroAntdModule } from "@ngx-formly/ng-zorro-antd";
+import { TEXERA_FORMLY_CONFIG } from "./formly-config";
import { Subject } from "rxjs";
import { FORM_DEBOUNCE_TIME_MS } from "../../workspace/service/execute-workflow/execute-workflow.service";
import { PortSchema } from "../../workspace/types/workflow-compiling.interface";
@@ -206,3 +218,312 @@ describe("createOutputFormChangeEventStream", () => {
expect(modelCheck).toHaveBeenCalledTimes(2);
});
});
+
+describe("valueRules", () => {
+ // the shape the sklearn trainers emit: one branch per hyperparameter, keyed on the
+ // `parameter` chosen beside the value in the same row
+ const rules: ValueRuleSet = {
+ allOf: [
+ {
+ if: { parameter: { valEnum: ["C"] } },
+ then: { type: "number", exclusiveMinimum: 0, examples: ["1.0"] },
+ },
+ {
+ if: { parameter: { valEnum: ["degree"] } },
+ then: { type: "integer", minimum: 0, examples: ["3"] },
+ },
+ { if: { parameter: { valEnum: ["coef0"] } }, then: { type: "number", examples: ["0.0"] } },
+ // gamma takes either of two words or a number, which no type names
+ {
+ if: { parameter: { valEnum: ["gamma"] } },
+ then: { pattern: "^\\s*(?:scale|auto|[-+]?[0-9]*\\.?[0-9]+)\\s*$", examples: ["scale"] },
+ },
+ // an accepted set carries no example: it already names every value worth offering, and
+ // the estimator's own default leads
+ {
+ if: { parameter: { valEnum: ["kernel"] } },
+ then: { enum: ["rbf", "linear", "poly", "sigmoid", "precomputed"] },
+ },
+ ],
+ };
+
+ const rowField = (row: unknown): FormlyFieldConfig => ({ parent: { model: row } }) as FormlyFieldConfig;
+ const control = (value: unknown) => ({ value }) as any;
+ const check = (parameter: string, value: unknown) =>
+ createValueRulesValidator(rules)(control(value), rowField({ parameter }));
+
+ describe("matchingValueRule", () => {
+ it("selects the branch the sibling's value names", () => {
+ expect(matchingValueRule(rules, { parameter: "kernel" })?.enum).toEqual([
+ "rbf",
+ "linear",
+ "poly",
+ "sigmoid",
+ "precomputed",
+ ]);
+ expect(matchingValueRule(rules, { parameter: "degree" })?.type).toBe("integer");
+ });
+
+ it("selects nothing when the sibling holds a value no branch names", () => {
+ expect(matchingValueRule(rules, { parameter: "metric_params" })).toBeUndefined();
+ });
+
+ it("selects nothing before the row has a sibling value at all", () => {
+ expect(matchingValueRule(rules, {})).toBeUndefined();
+ expect(matchingValueRule(rules, undefined)).toBeUndefined();
+ expect(matchingValueRule(undefined, { parameter: "C" })).toBeUndefined();
+ });
+ });
+
+ describe("createValueRulesValidator", () => {
+ it("accepts a value the chosen parameter's set contains", () => {
+ expect(check("kernel", "rbf")).toBe(true);
+ });
+
+ it("rejects a value outside that set, including one of another parameter's", () => {
+ expect(check("kernel", "1")).toBe(false);
+ expect(check("kernel", "uniform")).toBe(false);
+ });
+
+ it("holds a numeric parameter to a number", () => {
+ expect(check("C", "1.0")).toBe(true);
+ // coef0 carries no bound, so it is where number-ness alone can be checked
+ expect(check("coef0", "-2.5e3")).toBe(true);
+ expect(check("C", "abc")).toBe(false);
+ });
+
+ it("holds a whole-number parameter to a whole number", () => {
+ expect(check("degree", "3")).toBe(true);
+ // int() raises on this, so the form should not let it reach the operator
+ expect(check("degree", "1.5")).toBe(false);
+ expect(check("coef0", "-1")).toBe(true);
+ });
+
+ it("leaves emptiness to the required rule rather than answering twice", () => {
+ expect(check("C", "")).toBe(true);
+ expect(check("C", null)).toBe(true);
+ expect(check("kernel", undefined)).toBe(true);
+ });
+
+ it("accepts anything for a parameter no branch constrains", () => {
+ expect(check("metric_params", "whatever")).toBe(true);
+ });
+
+ it("holds a value to the bound the estimator puts on it", () => {
+ // C is open at zero, degree is closed at it, and coef0 has no bound at all
+ expect(check("C", "0")).toBe(false);
+ expect(check("C", "-1")).toBe(false);
+ expect(check("C", "0.0001")).toBe(true);
+ expect(check("degree", "0")).toBe(true);
+ expect(check("degree", "-1")).toBe(false);
+ expect(check("coef0", "-100")).toBe(true);
+ });
+
+ it("holds a parameter with a pattern to the shape it declares", () => {
+ // both halves of the union it describes
+ expect(check("gamma", "scale")).toBe(true);
+ expect(check("gamma", "auto")).toBe(true);
+ expect(check("gamma", "0.1")).toBe(true);
+ expect(check("gamma", " 1 ")).toBe(true);
+ expect(check("gamma", "abc")).toBe(false);
+ expect(check("gamma", "scaleauto")).toBe(false);
+ });
+
+ it("judges the same value against whichever parameter the row now holds", () => {
+ // a value typed for one parameter is usually wrong for the next, and stays visible. That
+ // the form asks again when the parameter changes is the rendered form's test below
+ expect(check("C", "1.0")).toBe(true);
+ expect(check("kernel", "1.0")).toBe(false);
+ });
+ });
+
+ describe("valueRulesValidationMessage", () => {
+ const field = (parameter: string): FormlyFieldConfig =>
+ ({ props: { valueRules: rules }, parent: { model: { parameter } } }) as FormlyFieldConfig;
+
+ it("names the accepted values when there is a set", () => {
+ expect(valueRulesValidationMessage(null, field("kernel"))).toBe(
+ "must be one of rbf, linear, poly, sigmoid, precomputed"
+ );
+ });
+
+ it("distinguishes a whole number from a number, and names the bound where there is one", () => {
+ expect(valueRulesValidationMessage(null, field("degree"))).toBe("must be a whole number of at least 0");
+ expect(valueRulesValidationMessage(null, field("C"))).toBe("must be a number greater than 0");
+ expect(valueRulesValidationMessage(null, field("coef0"))).toBe("must be a number");
+ });
+
+ it("points at a working value where a pattern is what the branch declares", () => {
+ expect(valueRulesValidationMessage(null, field("gamma"))).toBe(
+ "is not a value this parameter takes, such as scale"
+ );
+ });
+
+ it("says only what it knows when a pattern branch offers no example", () => {
+ const noExample: ValueRuleSet = {
+ allOf: [{ if: { parameter: { valEnum: ["gamma"] } }, then: { pattern: "^scale$" } }],
+ };
+ const bare = { props: { valueRules: noExample }, parent: { model: { parameter: "gamma" } } };
+ expect(valueRulesValidationMessage(null, bare as FormlyFieldConfig)).toBe("is not a value this parameter takes");
+ });
+
+ it("falls back to the numeric wording when no branch applies at all", () => {
+ // reached when the row's parameter changes between the check failing and the message
+ // being read, so the message must still say something rather than throw
+ expect(valueRulesValidationMessage(null, field("metric_params"))).toBe("must be a number");
+ });
+ });
+
+ /**
+ * Through a rendered form rather than a hand-made field, because what the field carries is
+ * only half of it: the other half is when Angular runs a validator, which is when the control
+ * carrying it changes and not when the parameter beside it does.
+ */
+ describe("setValueRules in a rendered form", () => {
+ /** One `paraList` row: the parameter dropdown and the value field that follows it. */
+ @Component({
+ standalone: true,
+ imports: [ReactiveFormsModule, FormlyModule],
+ template: `
`,
+ })
+ class RowHost {
+ readonly form = new FormGroup({});
+ readonly model: Record = {
+ paraList: [
+ { parameter: "C", value: "1.0" },
+ { parameter: "kernel", value: "rbf" },
+ ],
+ };
+ readonly fields: FormlyFieldConfig[] = [
+ {
+ key: "paraList",
+ type: "array",
+ fieldArray: {
+ fieldGroup: [
+ {
+ key: "parameter",
+ type: "enum",
+ props: {
+ options: ["C", "degree", "gamma", "kernel", "metric_params"].map(p => ({ label: p, value: p })),
+ },
+ },
+ valueField,
+ ],
+ },
+ },
+ ];
+ }
+
+ let valueField: FormlyFieldConfig;
+ let fixture: ComponentFixture;
+ let parameter: AbstractControl;
+ let value: AbstractControl;
+
+ beforeEach(async () => {
+ valueField = { key: "value" };
+ setValueRules(valueField, rules);
+
+ await TestBed.configureTestingModule({
+ imports: [RowHost, NoopAnimationsModule, FormlyModule.forRoot(TEXERA_FORMLY_CONFIG), FormlyNgZorroAntdModule],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(RowHost);
+ fixture.detectChanges();
+ parameter = rowControl(0, "parameter");
+ value = rowControl(0, "value");
+ });
+
+ const rowControl = (row: number, key: string): AbstractControl =>
+ fixture.componentInstance.form.get(`paraList.${row}.${key}`)!;
+
+ /** What the user does: picks a parameter, leaving whatever value the row already held. */
+ const choose = (parameterName: string) => {
+ parameter.setValue(parameterName);
+ fixture.detectChanges();
+ };
+
+ it("gives the value field the control and the validator the rules call for", () => {
+ expect(valueField.type).toBe("constrainedvalue");
+ expect(value.valid).toBe(true);
+ expect(fixture.debugElement.query(By.css("texera-constrained-value"))).not.toBeNull();
+ });
+
+ it("re-judges a value the row already holds when the parameter changes under it", () => {
+ // 1.0 is a C, and no kernel at all
+ expect(value.valid).toBe(true);
+
+ choose("kernel");
+
+ expect(value.valid).toBe(false);
+ expect(value.hasError("valueRules")).toBe(true);
+ });
+
+ it("clears the error once the parameter changes to one the value suits", () => {
+ choose("kernel");
+ expect(value.valid).toBe(false);
+
+ choose("gamma");
+
+ expect(value.valid).toBe(true);
+ });
+
+ it("re-judges when the new parameter constrains the value no rule did before", () => {
+ choose("metric_params");
+ value.setValue("1.5");
+ expect(value.valid).toBe(true);
+
+ choose("degree");
+
+ expect(value.valid).toBe(false);
+ });
+
+ it("leaves an empty value to the required rule whichever parameter it sits under", () => {
+ value.setValue("");
+ choose("kernel");
+ expect(value.valid).toBe(true);
+ });
+
+ it("gives the message of the parameter now chosen, not the one judged against", () => {
+ choose("kernel");
+ value.markAsTouched();
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.textContent).toContain("must be one of rbf, linear, poly, sigmoid, precomputed");
+ });
+
+ it("follows the parameter with the control the branch calls for", () => {
+ // scoped to the row's value field, so that neither the parameter's own dropdown nor the
+ // second row is what is seen
+ const valueControl = (selector: string) =>
+ fixture.debugElement.queryAll(By.css("texera-constrained-value"))[0].query(By.css(selector));
+ expect(valueControl("input[nz-input]").nativeElement.type).toBe("number");
+ expect(valueControl("nz-select")).toBeNull();
+
+ choose("kernel");
+
+ expect(valueControl("nz-select")).not.toBeNull();
+ expect(valueControl("input[nz-input]")).toBeNull();
+ });
+
+ it("re-judges only the row whose parameter changed", () => {
+ const otherValue = rowControl(1, "value");
+ expect(otherValue.valid).toBe(true);
+
+ choose("kernel");
+
+ // rbf is still a kernel, whatever the row above holds
+ expect(value.valid).toBe(false);
+ expect(otherValue.valid).toBe(true);
+
+ rowControl(1, "parameter").setValue("degree");
+ fixture.detectChanges();
+
+ expect(otherValue.valid).toBe(false);
+ });
+ });
+});
diff --git a/frontend/src/app/common/formly/formly-utils.ts b/frontend/src/app/common/formly/formly-utils.ts
index cb80abe2bd5..15c25fb071a 100644
--- a/frontend/src/app/common/formly/formly-utils.ts
+++ b/frontend/src/app/common/formly/formly-utils.ts
@@ -22,9 +22,10 @@ import { isDefined } from "../util/predicate";
import { Observable } from "rxjs";
import { FORM_DEBOUNCE_TIME_MS } from "../../workspace/service/execute-workflow/execute-workflow.service";
-import { debounceTime, distinctUntilChanged, filter, share } from "rxjs/operators";
-import { HideType } from "../../workspace/types/custom-json-schema.interface";
+import { debounceTime, distinctUntilChanged, filter, share, tap } from "rxjs/operators";
+import { HideType, ValueRuleSet } from "../../workspace/types/custom-json-schema.interface";
import { PortSchema } from "../../workspace/types/workflow-compiling.interface";
+import { AbstractControl } from "@angular/forms";
export function getFieldByName(fieldName: string, fields: FormlyFieldConfig[]): FormlyFieldConfig | undefined {
return fields.filter((field, _, __) => field.key === fieldName)[0];
@@ -39,6 +40,134 @@ export function setHideExpression(toggleHidden: string[], fields: FormlyFieldCon
});
}
+type ValueRule = ValueRuleSet["allOf"][number]["then"];
+
+/**
+ * The one branch of `valueRules` that the row's current contents select, or undefined where
+ * none does. A branch names its sibling fields and the values of theirs it applies to, so the
+ * row model is what decides; `field.parent.model` is that row for an array item and the
+ * operator itself for a top-level field.
+ */
+export function matchingValueRule(rules: ValueRuleSet | undefined, rowModel: any): ValueRule | undefined {
+ if (!isDefined(rules) || !isDefined(rowModel)) {
+ return undefined;
+ }
+ return rules.allOf.find(branch =>
+ Object.entries(branch.if).every(([sibling, condition]) => (condition.valEnum ?? []).includes(rowModel[sibling]))
+ )?.then;
+}
+
+/**
+ * Validator holding a field to whichever branch of `valueRules` currently applies.
+ *
+ * An empty value passes: whether emptiness is allowed is `required`'s business, and a field
+ * that answers twice would report the wrong thing once. The numeric branches accept what
+ * JavaScript reads as a number, which is slightly narrower than the Python converters on the
+ * other end (they take `1_000` and `inf`); erring narrow here would be wrong for a field whose
+ * accepted set is open, but these two are bounded and the values it turns away are ones no one
+ * types into a hyperparameter.
+ */
+export function createValueRulesValidator(rules: ValueRuleSet) {
+ return (control: AbstractControl, field: FormlyFieldConfig): boolean => {
+ const rule = matchingValueRule(rules, field?.parent?.model);
+ if (!isDefined(rule)) {
+ return true;
+ }
+ const value = control.value;
+ if (value === null || value === undefined || value === "") {
+ return true;
+ }
+ const text = String(value).trim();
+ if (isDefined(rule.enum)) {
+ return rule.enum.includes(text);
+ }
+ if (isDefined(rule.pattern)) {
+ // anchored the way the declaration writes it, so the same expression judges the value
+ // here, in the operator's own tests and in the generated Python
+ return new RegExp(rule.pattern).test(String(value));
+ }
+ if (rule.type === "integer" && !/^[-+]?\d+$/.test(text)) {
+ return false;
+ }
+ if (rule.type === "number" && !(text.length > 0 && Number.isFinite(Number(text)))) {
+ return false;
+ }
+ if (isDefined(rule.type)) {
+ // the estimator's own bound, which it would otherwise raise on after the run started
+ const value = Number(text);
+ if (isDefined(rule.minimum) && value < rule.minimum) {
+ return false;
+ }
+ if (isDefined(rule.exclusiveMinimum) && value <= rule.exclusiveMinimum) {
+ return false;
+ }
+ }
+ return true;
+ };
+}
+
+/** Says what the field will take, naming the branch rather than the rule that rejected it. */
+export function valueRulesValidationMessage(_err: unknown, field: FormlyFieldConfig): string {
+ const rule = matchingValueRule(field?.props?.valueRules, field?.parent?.model);
+ if (isDefined(rule?.enum)) {
+ return `must be one of ${rule.enum.join(", ")}`;
+ }
+ if (isDefined(rule?.pattern)) {
+ // a pattern covers shapes no short phrase names, so point at a value that works instead
+ const example = rule.examples?.[0];
+ return isDefined(example)
+ ? `is not a value this parameter takes, such as ${example}`
+ : "is not a value this parameter takes";
+ }
+ const kind = rule?.type === "integer" ? "a whole number" : "a number";
+ if (isDefined(rule?.minimum)) {
+ return `must be ${kind} of at least ${rule.minimum}`;
+ }
+ if (isDefined(rule?.exclusiveMinimum)) {
+ return `must be ${kind} greater than ${rule.exclusiveMinimum}`;
+ }
+ return `must be ${kind}`;
+}
+
+/**
+ * Gives a field whose accepted values follow a sibling's both the control they call for and the
+ * validator holding it to them.
+ *
+ * Angular re-runs a validator only when the control carrying it changes, so the field has to be
+ * re-judged when the sibling deciding its rule changes. The hook reads formly's own event rather
+ * than the sibling control's because formly writes the row model before emitting, and the row
+ * model is what picks the branch.
+ */
+export function setValueRules(field: FormlyFieldConfig, rules: ValueRuleSet): void {
+ const siblings = new Set(rules.allOf.flatMap(branch => Object.keys(branch.if)));
+ field.type = "constrainedvalue";
+ // written into the existing object rather than over it: `props` and `templateOptions` are two
+ // names for one object, and replacing it leaves them pointing at different ones
+ field.props = field.props ?? {};
+ (field.props as Record).valueRules = rules;
+ field.validators = {
+ ...field.validators,
+ valueRules: {
+ expression: createValueRulesValidator(rules),
+ message: valueRulesValidationMessage,
+ },
+ };
+ field.hooks = {
+ ...field.hooks,
+ // returned rather than subscribed, so that formly ends it with the field
+ onInit: valueField =>
+ valueField.options?.fieldChanges?.pipe(
+ filter(
+ change =>
+ change.type === "valueChanges" &&
+ change.field.parent === valueField.parent &&
+ siblings.has(String(change.field.key))
+ ),
+ tap(() => valueField.formControl?.updateValueAndValidity())
+ ),
+ };
+}
+
/* Factory function to make functions that hide expressions for a particular field */
export function createShouldHideFieldFunc(
hideTarget: string,
diff --git a/frontend/src/app/dashboard/service/user/workflow-to-python/workflow-to-python.service.ts b/frontend/src/app/dashboard/service/user/workflow-to-python/workflow-to-python.service.ts
new file mode 100644
index 00000000000..301515d9568
--- /dev/null
+++ b/frontend/src/app/dashboard/service/user/workflow-to-python/workflow-to-python.service.ts
@@ -0,0 +1,58 @@
+/**
+ * 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 { HttpClient, HttpHeaders } from "@angular/common/http";
+import { Injectable } from "@angular/core";
+import { Observable } from "rxjs";
+import { AppSettings } from "../../../../common/app-setting";
+import { LogicalPlan } from "../../../../workspace/types/execute-workflow.interface";
+
+export const WORKFLOW_TO_PYTHON_ENDPOINT = "workflow-to-python";
+
+export interface WorkflowToPythonResponse {
+ type: "success" | "failure";
+ pythonCode?: string;
+ errorMessage?: string;
+}
+
+@Injectable({
+ providedIn: "root",
+})
+export class WorkflowToPythonService {
+ constructor(private httpClient: HttpClient) {}
+
+ public convertToPython(logicalPlan: LogicalPlan): Observable {
+ const body = {
+ operators: logicalPlan.operators,
+ links: logicalPlan.links,
+ opsToReuseResult: [],
+ opsToViewResult: [],
+ };
+
+ return this.httpClient.post(
+ `${AppSettings.getApiEndpoint()}/${WORKFLOW_TO_PYTHON_ENDPOINT}`,
+ body,
+ {
+ headers: new HttpHeaders({
+ "Content-Type": "application/json",
+ }),
+ }
+ );
+ }
+}
diff --git a/frontend/src/app/workspace/component/constrained-value/constrained-value.component.spec.ts b/frontend/src/app/workspace/component/constrained-value/constrained-value.component.spec.ts
new file mode 100644
index 00000000000..ea5221a0734
--- /dev/null
+++ b/frontend/src/app/workspace/component/constrained-value/constrained-value.component.spec.ts
@@ -0,0 +1,136 @@
+/**
+ * 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 { FormControl } from "@angular/forms";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
+import { FormlyFieldConfig } from "@ngx-formly/core";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { ValueRuleSet } from "../../types/custom-json-schema.interface";
+import { ConstrainedValueComponent } from "./constrained-value.component";
+
+describe("ConstrainedValueComponent", () => {
+ // one branch of each shape a rule can take, keyed on the sibling `parameter`
+ const rules: ValueRuleSet = {
+ allOf: [
+ {
+ if: { parameter: { valEnum: ["kernel"] } },
+ then: { enum: ["rbf", "linear", "poly", "sigmoid", "precomputed"] },
+ },
+ { if: { parameter: { valEnum: ["C"] } }, then: { type: "number", examples: ["1.0"] } },
+ { if: { parameter: { valEnum: ["degree"] } }, then: { type: "integer", examples: ["3"] } },
+ {
+ if: { parameter: { valEnum: ["gamma"] } },
+ then: { pattern: "^\\s*(?:scale|auto|[-+]?[0-9]*\\.?[0-9]+)\\s*$", examples: ["scale"] },
+ },
+ ],
+ };
+
+ let fixture: ComponentFixture;
+ let component: ConstrainedValueComponent;
+
+ /** Puts the component in the row a real `paraList` item would give it. */
+ const showFor = (parameter: string, value: string = ""): FormControl => {
+ const formControl = new FormControl(value);
+ (component as any).field = {
+ key: "value",
+ formControl,
+ props: { valueRules: rules },
+ parent: { model: { parameter } },
+ } as FormlyFieldConfig;
+ fixture.detectChanges();
+ return formControl;
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ConstrainedValueComponent, NoopAnimationsModule],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ConstrainedValueComponent);
+ component = fixture.componentInstance;
+ });
+
+ it("offers the accepted values as a dropdown when the parameter is chosen from a set", () => {
+ showFor("kernel");
+ expect(component.acceptedValues).toEqual(["rbf", "linear", "poly", "sigmoid", "precomputed"]);
+ expect(fixture.debugElement.query(By.css("nz-select"))).not.toBeNull();
+ // nz-select carries a hidden input of its own, so look for ours rather than for any
+ expect(fixture.debugElement.query(By.css("input[nz-input]"))).toBeNull();
+ });
+
+ it("gives a numeric parameter a number input instead", () => {
+ showFor("C");
+ expect(component.acceptedValues).toEqual([]);
+ expect(component.inputType).toBe("number");
+ expect(fixture.debugElement.query(By.css("nz-select"))).toBeNull();
+ expect(fixture.debugElement.query(By.css("input[nz-input]")).nativeElement.type).toBe("number");
+ });
+
+ it("keeps a parameter described by a pattern on a text input, since it may hold a word", () => {
+ showFor("gamma");
+ expect(component.inputType).toBe("text");
+ expect(fixture.debugElement.query(By.css("input[nz-input]")).nativeElement.type).toBe("text");
+ });
+
+ it("leaves a parameter no branch names as a plain text box", () => {
+ showFor("metric_params");
+ expect(component.acceptedValues).toEqual([]);
+ expect(component.inputType).toBe("text");
+ });
+
+ it("follows the row when the parameter beside it changes", () => {
+ showFor("kernel");
+ expect(fixture.debugElement.query(By.css("nz-select"))).not.toBeNull();
+
+ (component as any).field.parent.model.parameter = "C";
+ fixture.detectChanges();
+
+ expect(fixture.debugElement.query(By.css("nz-select"))).toBeNull();
+ expect(fixture.debugElement.query(By.css("input[nz-input]")).nativeElement.type).toBe("number");
+ });
+
+ it("writes the control as a string whichever control produced the value", () => {
+ const control = showFor("C");
+ // a number input yields a number once its text parses
+ component.write(0.1);
+ expect(control.value).toBe("0.1");
+ expect(typeof control.value).toBe("string");
+ });
+
+ it("writes an empty string when a dropdown is cleared", () => {
+ const control = showFor("kernel", "rbf");
+ component.write(null);
+ expect(control.value).toBe("");
+ });
+
+ it("marks the control touched so the error shows on the first bad value", () => {
+ const control = showFor("C");
+ expect(control.touched).toBe(false);
+ component.write("abc");
+ expect(control.dirty).toBe(true);
+ expect(control.touched).toBe(true);
+ });
+
+ it("reads an unset control as an empty string rather than null", () => {
+ const control = showFor("C");
+ control.setValue(null);
+ expect(component.current).toBe("");
+ });
+});
diff --git a/frontend/src/app/workspace/component/constrained-value/constrained-value.component.ts b/frontend/src/app/workspace/component/constrained-value/constrained-value.component.ts
new file mode 100644
index 00000000000..87e305458e8
--- /dev/null
+++ b/frontend/src/app/workspace/component/constrained-value/constrained-value.component.ts
@@ -0,0 +1,95 @@
+/**
+ * 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 { ChangeDetectionStrategy, Component } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { FormsModule } from "@angular/forms";
+import { FieldType, FieldTypeConfig, FormlyModule } from "@ngx-formly/core";
+import { NzInputModule } from "ng-zorro-antd/input";
+import { NzSelectModule } from "ng-zorro-antd/select";
+import { matchingValueRule } from "../../../common/formly/formly-utils";
+
+/**
+ * A field whose accepted values depend on what a sibling field holds: a chosen-from-a-set
+ * parameter renders as a dropdown, a numeric one as a number input, and anything the rules do
+ * not cover stays a plain text box.
+ *
+ * The value stays a string whichever control is showing. Operators that read one of these put
+ * the text through a converter of their own, so handing them a JSON number instead would only
+ * move the coercion somewhere less visible.
+ */
+@Component({
+ selector: "texera-constrained-value",
+ standalone: true,
+ imports: [CommonModule, FormsModule, FormlyModule, NzInputModule, NzSelectModule],
+ changeDetection: ChangeDetectionStrategy.Default,
+ template: `
+ 0; else freeInput"
+ [ngModel]="current"
+ (ngModelChange)="write($event)"
+ [nzDisabled]="to.disabled ?? false"
+ nzAllowClear>
+
+
+
+
+
+
+ `,
+})
+export class ConstrainedValueComponent extends FieldType {
+ /** The branch of the rules that the sibling's current value selects, if any. */
+ private get rule() {
+ return matchingValueRule(this.props.valueRules, this.field?.parent?.model);
+ }
+
+ get acceptedValues(): ReadonlyArray {
+ return this.rule?.enum ?? [];
+ }
+
+ /** A number input where the rules call for a number, so a keyboard offers digits and the
+ * browser refuses most of what the converter would reject.
+ */
+ get inputType(): string {
+ return this.rule?.type === undefined ? "text" : "number";
+ }
+
+ get current(): string {
+ return this.formControl.value ?? "";
+ }
+
+ /** Writes the control as a string whatever the control was. `nz-select` clears to null and a
+ * number input yields a number once its value parses, and both reach an operator expecting
+ * text.
+ */
+ write(raw: unknown): void {
+ this.formControl.setValue(raw === null || raw === undefined ? "" : String(raw));
+ this.formControl.markAsDirty();
+ this.formControl.markAsTouched();
+ }
+}
diff --git a/frontend/src/app/workspace/component/menu/menu.component.html b/frontend/src/app/workspace/component/menu/menu.component.html
index cfdeea77105..445996e90b1 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.html
+++ b/frontend/src/app/workspace/component/menu/menu.component.html
@@ -129,6 +129,15 @@
nz-icon
nzType="download">
+