Skip to content

Commit f4c9b59

Browse files
fix: derive what an emptied form field means from its schema
A cleared text box hands back "", and for a field like group_column an empty string is not "no value", it is a column named "". That reached scikit-learn, or got persisted and raised a KeyError in a Huey worker much later. 77 fields accept string-or-null today, including every class_weight and max_features in the sklearn wrappers. The int half of this was fixed years ago in the leaf inputs, where IntegerInput and NumberInput each coerce "" to null themselves, and it was the visible half because "" fails int validation loudly. The string half was never fixed, because it fails silently. The historical attempt went the other way, translating "" to None during validation, and there was no good place for it: from a bare string the schema layer cannot tell whether the author meant "unset" or "the empty string". What made it decidable is that the schema already says both things, and nobody was reading them: - the field does not admit null -> "" is a value, leave it - it admits null and its placeholder is "" -> empty means "", which is the 14 negative_prompt fields across the diffusion models and exactly why a global rule would have been wrong for them - it admits null and its placeholder is anything else -> empty means unset So emptyValueFor derives it per field with no new keyword, no authoring burden and no backend change, and normalizeEmptyValue is applied at the one point where a form reports a change, so every input type is covered and no leaf component has to know about it. The pipelines dispatcher calls the same shared helper rather than growing its own copy, since the two must not disagree. Also removes the display of the literal word "none" for a null value (TextInput, a work-in-progress line from March 2024). It stayed harmless only because the null branch renders that input disabled: with the input enabled it would be a submittable string, and pydantic does not parse "none" to None, it keeps it as the string 'none'. And passes the `placeholder` prop that FormSchemaFieldWithOptions documents and reads but neither of its two call sites ever provided, which is why leaving the Null chip used to set the value to undefined. Not changed: the backend still accepts "" for a nullable string. Tightening that would reject params already persisted, so it is a separate decision with a data-migration dimension rather than something to ride along here.
1 parent 9b01cb2 commit f4c9b59

6 files changed

Lines changed: 216 additions & 7 deletions

File tree

DashAI/front/src/components/configurableObject/Inputs/TextInput.jsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ function TextInput({
3030
size="small"
3131
name={name}
3232
label={label}
33-
value={value === null ? "none" : value}
33+
// A null value is an empty box, not the word "none". Displaying the
34+
// literal was a work-in-progress line from 2024 that only stayed
35+
// harmless because the null branch renders this input disabled: with
36+
// the input enabled it would be a submittable string.
37+
value={value ?? ""}
3438
onChange={onChange}
3539
autoComplete="off"
3640
error={!!showError}

DashAI/front/src/components/pipelines/ParamsSettings.jsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import FormSchemaFieldWithOptions from "../../components/shared/FormSchemaFieldW
1515
import FormSchemaFieldWithCollapse from "../../components/shared/FormSchemaFieldWithCollapse";
1616
import FormSchemaFieldWithOptimizers from "../../components/shared/FormSchemaFieldWithOptimizers";
1717
import FormSchemaFieldWithParent from "../../components/shared/FormSchemaFieldWithParent";
18-
import { getModelFromSubform } from "../../utils/schema";
18+
import { getModelFromSubform, normalizeEmptyValue } from "../../utils/schema";
1919

2020
function ParamsSettings({
2121
open,
@@ -64,7 +64,14 @@ function ParamsSettings({
6464
const value = localValues?.[objName];
6565

6666
const fieldOnChange = (fieldValue) => {
67-
handleFieldChange(objName, fieldValue);
67+
// Same shared helper as the main renderer: this fork exists only
68+
// because the shared one needs a provider context, so the empty-value
69+
// semantics must not differ between the two. When the two dispatchers
70+
// are collapsed, this call site goes with them.
71+
handleFieldChange(
72+
objName,
73+
normalizeEmptyValue(fieldValue, fieldSchema),
74+
);
6875
};
6976

7077
if ("anyOf" in fieldSchema) {
@@ -76,6 +83,7 @@ function ParamsSettings({
7683
options={fieldSchema.anyOf}
7784
required={fieldSchema.required}
7885
objName={objName}
86+
placeholder={fieldSchema.placeholder}
7987
field={{
8088
value: value,
8189
onChange: fieldOnChange,

DashAI/front/src/components/shared/FormSchemaRenderFields.jsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import FormSchemaFieldWithOptions from "./FormSchemaFieldWithOptions";
55
import FormSchemaFieldWithCollapse from "./FormSchemaFieldWithCollapse";
66
import FormSchemaFieldWithOptimizers from "./FormSchemaFieldWithOptimizers";
77
import FormSchemaFieldWithParent from "./FormSchemaFieldWithParent";
8-
import { getModelFromSubform, getSchemaRules } from "../../utils/schema";
8+
import {
9+
getModelFromSubform,
10+
getSchemaRules,
11+
normalizeEmptyValue,
12+
} from "../../utils/schema";
913
import { evaluateRules } from "../../utils/ruleEngine";
1014
import { Stack } from "@mui/material";
1115
import PropTypes from "prop-types";
@@ -63,8 +67,15 @@ function FormSchemaRenderFields({
6367
if (!modelSchema) return null;
6468

6569
const handleChange = useCallback(
66-
(name, subName) => (value) => {
70+
(name, subName) => (rawValue) => {
6771
const fieldPath = subName ? `${name}.${subName}` : name;
72+
// One place decides what an emptied input means, so no leaf input has to
73+
// know: a cleared box on a nullable field submits null instead of the
74+
// empty string that used to reach sklearn as a column named "".
75+
const fieldSchema = subName
76+
? modelSchema?.[name]?.properties?.[subName]
77+
: modelSchema?.[name];
78+
const value = normalizeEmptyValue(rawValue, fieldSchema);
6879
formik.setFieldValue(fieldPath, value, true);
6980
// Always pass complete formik.values so handleUpdateSchema receives
7081
// ALL fields regardless of whether the context store has been
@@ -74,7 +85,7 @@ function FormSchemaRenderFields({
7485
autoSave ? onFormSubmit : null,
7586
);
7687
},
77-
[formik, handleUpdateSchema, autoSave, onFormSubmit],
88+
[formik, handleUpdateSchema, autoSave, onFormSubmit, modelSchema],
7889
);
7990

8091
// Which fields the schema's own rules say are meaningful right now. The
@@ -144,6 +155,7 @@ function FormSchemaRenderFields({
144155
setError={setError}
145156
field={baseField}
146157
disabled={disabled}
158+
placeholder={fieldSchema.placeholder}
147159
/>,
148160
);
149161
} else if (isOptimizable) {

DashAI/front/src/components/shared/FormSchemaRenderFields.test.jsx

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import React from "react";
19-
import { screen, within } from "@testing-library/react";
19+
import { screen, waitFor, within } from "@testing-library/react";
2020
import userEvent from "@testing-library/user-event";
2121

2222
// react-markdown ships ESM only and Create React App's jest does not transform
@@ -317,3 +317,80 @@ describe("relevance on a union field, the SimpleImputer case", () => {
317317
expect(inputFor(container, "strategy")).not.toBeDisabled();
318318
});
319319
});
320+
321+
describe("an emptied nullable field", () => {
322+
// The oldest defect on this path: a cleared box handed back "" and the
323+
// backend stored a column named "", which sklearn then choked on. The
324+
// historical workaround displayed the word "none" for a null value, which
325+
// survived only because the null branch renders the input disabled.
326+
const nullableSchema = (placeholder) => ({
327+
title: "S",
328+
type: "object",
329+
required: ["group_column"],
330+
properties: {
331+
group_column: {
332+
anyOf: [{ type: "string" }, { type: "null" }],
333+
placeholder,
334+
title: "Group column",
335+
description: "The column to group by.",
336+
},
337+
},
338+
});
339+
340+
async function renderNullable(value, placeholder = null) {
341+
const modelSchema = await formattedModel(nullableSchema(placeholder));
342+
const formik = fakeFormik({ group_column: value });
343+
const result = renderWithProviders(
344+
<FormSchemaRenderFields
345+
modelSchema={modelSchema}
346+
formik={formik}
347+
handleUpdateSchema={jest.fn()}
348+
/>,
349+
);
350+
return { ...result, formik };
351+
}
352+
353+
it("shows an empty box for a null value, not the word none", async () => {
354+
const { container } = await renderNullable(null);
355+
const input = inputFor(container, "group_column");
356+
expect(input).toHaveValue("");
357+
expect(screen.queryByDisplayValue("none")).not.toBeInTheDocument();
358+
});
359+
360+
it("stores null when the user clears it", async () => {
361+
const { container, formik } = await renderNullable("some_column");
362+
const input = inputFor(container, "group_column");
363+
expect(input).not.toBeDisabled();
364+
365+
await userEvent.clear(input);
366+
367+
await waitFor(() =>
368+
expect(formik.setFieldValue).toHaveBeenCalledWith(
369+
"group_column",
370+
null,
371+
true,
372+
),
373+
);
374+
// Never the empty string, which is the value that used to reach sklearn.
375+
expect(formik.setFieldValue).not.toHaveBeenCalledWith(
376+
"group_column",
377+
"",
378+
true,
379+
);
380+
});
381+
382+
it("keeps the empty string when the author chose it as the default", async () => {
383+
// The diffusion models' negative_prompt: same wire shape, opposite
384+
// meaning, which is why this is derived per field instead of globally.
385+
const { container, formik } = await renderNullable("a prompt", "");
386+
await userEvent.clear(inputFor(container, "group_column"));
387+
388+
await waitFor(() =>
389+
expect(formik.setFieldValue).toHaveBeenCalledWith(
390+
"group_column",
391+
"",
392+
true,
393+
),
394+
);
395+
});
396+
});

DashAI/front/src/utils/schema.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,57 @@ export async function resolveDefaults(
4343
}
4444
}
4545

46+
/**
47+
* What an emptied input means for a given field.
48+
*
49+
* The problem this answers is old: a cleared text box hands back `""`, and for
50+
* a field like `group_column` an empty string is not "no value", it is a column
51+
* named "". sklearn then fails on it, or the backend stores it and a Huey worker
52+
* raises a KeyError much later. The historical patch went the other way, trying
53+
* to translate `""` to None during validation, and there was no good place for
54+
* it: the schema layer cannot tell, from a bare string, whether the author meant
55+
* "unset" or "the empty string".
56+
*
57+
* It can be derived instead, from what the schema already says on the wire:
58+
*
59+
* - The field does not admit null: `""` is a value like any other, so it stays.
60+
* A required field then reports itself empty, which is correct.
61+
* - It admits null and its placeholder is `""`: the author chose the empty
62+
* string as the default, so that is what empty means. This is the case of the
63+
* 14 `negative_prompt` fields across the diffusion models, and it is exactly
64+
* why a single global rule would have been wrong.
65+
* - It admits null and its placeholder is anything else: empty means unset.
66+
*
67+
* So there is no new keyword, no authoring burden and no backend change. The
68+
* answer was already in the schema; nobody was reading it.
69+
*
70+
* @param {object} subSchema one property of a formatted schema
71+
* @returns {null|string} the value an emptied input should submit
72+
*/
73+
export const emptyValueFor = (subSchema) => {
74+
const branches = Array.isArray(subSchema?.anyOf) ? subSchema.anyOf : [];
75+
const admitsNull =
76+
branches.some((branch) => branch.type === "null") ||
77+
subSchema?.type === "null";
78+
if (!admitsNull) return "";
79+
return subSchema?.placeholder === "" ? "" : null;
80+
};
81+
82+
/**
83+
* Replace an emptied input's value with what empty means for that field.
84+
*
85+
* Applied at the single point where a form reports a change, so every input
86+
* type is covered and no leaf component has to know about it.
87+
*
88+
* @param {*} value the value the input handed back
89+
* @param {object} subSchema the schema of the field that changed
90+
* @returns {*} the value to store
91+
*/
92+
export const normalizeEmptyValue = (value, subSchema) => {
93+
if (value !== "" && value !== undefined) return value;
94+
return emptyValueFor(subSchema);
95+
};
96+
4697
// Generate a Yup schema from a JSON schema object based on the JSON schema specification from the api, it also generates the initial values of the form
4798
export const generateYupSchema = (schemaObj) => {
4899
const schema = {};

DashAI/front/src/utils/schema.test.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ import { validateYupSchema, yupToFormErrors } from "formik";
1414

1515
import {
1616
SCHEMA_RULES,
17+
emptyValueFor,
1718
formattedModel,
1819
generateYupSchema,
1920
getSchemaRules,
21+
normalizeEmptyValue,
2022
} from "./schema";
2123
import {
2224
holdoutWireSchema,
@@ -177,3 +179,58 @@ describe("generateYupSchema enforces the declared rules", () => {
177179
expect(errors.validation).toMatch(/must sum to 1/);
178180
});
179181
});
182+
183+
describe("what an emptied input means, derived from the schema", () => {
184+
// The old question, answered from data instead of by a global rule. The
185+
// schema already says whether null is allowed and what the author chose as
186+
// the default; between those two facts there is no ambiguity left.
187+
const nullableColumn = {
188+
anyOf: [{ type: "string" }, { type: "null" }],
189+
placeholder: null,
190+
};
191+
const nullablePrompt = {
192+
anyOf: [{ type: "string" }, { type: "null" }],
193+
placeholder: "",
194+
};
195+
const plainString = { type: "string", placeholder: "abc" };
196+
const nullableInt = {
197+
anyOf: [{ type: "integer" }, { type: "null" }],
198+
placeholder: null,
199+
};
200+
201+
it("means unset for a nullable field whose default is not the empty string", () => {
202+
// group_column and the other 76 string-or-null fields: an empty string
203+
// here is a column named "", which is what reached sklearn.
204+
expect(emptyValueFor(nullableColumn)).toBeNull();
205+
expect(emptyValueFor(nullableInt)).toBeNull();
206+
});
207+
208+
it("means the empty string when the author chose it as the default", () => {
209+
// The 14 negative_prompt fields across the diffusion models. A single
210+
// global "empty means null" rule would be wrong for exactly these.
211+
expect(emptyValueFor(nullablePrompt)).toBe("");
212+
});
213+
214+
it("leaves a non-nullable field alone", () => {
215+
expect(emptyValueFor(plainString)).toBe("");
216+
});
217+
218+
it("tolerates a field it knows nothing about", () => {
219+
expect(emptyValueFor(undefined)).toBe("");
220+
expect(emptyValueFor({})).toBe("");
221+
});
222+
223+
it("only rewrites empty values, never real ones", () => {
224+
expect(normalizeEmptyValue("abc", nullableColumn)).toBe("abc");
225+
expect(normalizeEmptyValue(0, nullableInt)).toBe(0);
226+
expect(normalizeEmptyValue(false, nullableColumn)).toBe(false);
227+
expect(normalizeEmptyValue(null, nullableColumn)).toBeNull();
228+
});
229+
230+
it("treats a cleared box and an absent value the same way", () => {
231+
expect(normalizeEmptyValue("", nullableColumn)).toBeNull();
232+
expect(normalizeEmptyValue(undefined, nullableColumn)).toBeNull();
233+
expect(normalizeEmptyValue("", nullablePrompt)).toBe("");
234+
expect(normalizeEmptyValue(undefined, nullablePrompt)).toBe("");
235+
});
236+
});

0 commit comments

Comments
 (0)