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 <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-05-29 17:22:32 -07:00
1 parent 58ff7db6a2
commit 980bfc7301
2 files changed
+38 -6

No files matched your search

+26 -1
View File
@@ -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);
+12 -5
View File
@@ -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<string, unknown> = {};
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;
}
}