From 980bfc730106a8d1c945315596af86cee5a66956 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 27 May 2026 22:16:05 -0700 Subject: [PATCH] fix(ui): omit optional scalar defaults from JsonSchemaForm so empty number fields don't submit as 0 (PAPA-407) getDefaultForSchema previously materialised a value of 0 for any number field without an explicit default (and "" for any optional string), which is what `getDefaultValues` then seeded the form with. That meant the exe.dev environment form opened with `cpu: 0` baked in even though the user never touched the field, and submitting the form failed server-side validation: "cpu must be greater than 0 when provided." Fix: only materialise scalar (string / number / integer / secret-ref) defaults when the schema actually declares one. Booleans, enums, arrays and objects still get sensible empty values so checkbox/array UI keeps working. Object recursion now skips properties whose defaults resolve to undefined so they stay out of the submitted payload. Adds a regression test that getDefaultValues omits optional scalars without an explicit default while still honouring fields that do declare one. Co-Authored-By: Paperclip --- ui/src/components/JsonSchemaForm.test.tsx | 27 ++++++++++++++++++++++- ui/src/components/JsonSchemaForm.tsx | 17 +++++++++----- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/ui/src/components/JsonSchemaForm.test.tsx b/ui/src/components/JsonSchemaForm.test.tsx index c438c3def2f..755acc61519 100644 --- a/ui/src/components/JsonSchemaForm.test.tsx +++ b/ui/src/components/JsonSchemaForm.test.tsx @@ -3,7 +3,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { JsonSchemaForm } from "./JsonSchemaForm"; +import { JsonSchemaForm, getDefaultValues } from "./JsonSchemaForm"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -350,6 +350,31 @@ describe("JsonSchemaForm secret-ref rendering", () => { }); }); + it("omits optional scalar fields from getDefaultValues so empty inputs aren't submitted as 0/''", () => { + const defaults = getDefaultValues({ + type: "object", + properties: { + apiKey: { type: "string", format: "secret-ref" }, + sshPort: { type: "number", default: 22 }, + cpu: { type: "number" }, + memory: { type: "string" }, + reuseLease: { type: "boolean", default: false }, + tags: { type: "array", items: { type: "string" } }, + }, + }); + + // Fields with explicit defaults round-trip. + expect(defaults.sshPort).toBe(22); + expect(defaults.reuseLease).toBe(false); + expect(defaults.tags).toEqual([]); + + // Optional scalars without explicit defaults stay out of the payload so + // the server doesn't see e.g. `cpu: 0` and reject the submission. + expect("apiKey" in defaults).toBe(false); + expect("cpu" in defaults).toBe(false); + expect("memory" in defaults).toBe(false); + }); + it("keeps the password fallback for short raw values", async () => { const root = createRoot(container); diff --git a/ui/src/components/JsonSchemaForm.tsx b/ui/src/components/JsonSchemaForm.tsx index e35d86f888c..b9f18521913 100644 --- a/ui/src/components/JsonSchemaForm.tsx +++ b/ui/src/components/JsonSchemaForm.tsx @@ -134,7 +134,14 @@ export function labelFromKey(key: string, schema: JsonSchemaNode): string { .replace(/\b\w/g, (c) => c.toUpperCase()); } -/** Produce a sensible default value for a schema node. */ +/** + * Produce a sensible default value for a schema node. + * + * Optional scalar fields (string, number, integer, secret-ref) without an + * explicit `default` return `undefined` so they stay out of the submitted + * payload — otherwise an empty field would round-trip as `""` or `0` and + * trip server-side "X must be greater than 0 when provided" style validators. + */ export function getDefaultForSchema(schema: JsonSchemaNode): unknown { if (schema.default !== undefined) return schema.default; @@ -142,10 +149,9 @@ export function getDefaultForSchema(schema: JsonSchemaNode): unknown { switch (type) { case "string": case "secret-ref": - return ""; case "number": case "integer": - return schema.minimum ?? 0; + return undefined; case "boolean": return false; case "enum": @@ -156,12 +162,13 @@ export function getDefaultForSchema(schema: JsonSchemaNode): unknown { if (!schema.properties) return {}; const obj: Record = {}; for (const [key, propSchema] of Object.entries(schema.properties)) { - obj[key] = getDefaultForSchema(propSchema); + const def = getDefaultForSchema(propSchema); + if (def !== undefined) obj[key] = def; } return obj; } default: - return ""; + return undefined; } }