diff --git a/.changeset/number-field-empty-decimal.md b/.changeset/number-field-empty-decimal.md new file mode 100644 index 0000000000..c54a919199 --- /dev/null +++ b/.changeset/number-field-empty-decimal.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/admin": patch +"emdash": patch +--- + +Fixes two problems with number fields in the content editor. Clearing a number field now saves it as empty instead of `0`, which previously failed a field's minimum (such as `min: 1`) or silently stored zero. Decimal values such as `4.50` no longer block the Save button with the browser's "nearest valid values" message, while `integer` fields keep stepping in whole numbers. diff --git a/packages/admin/src/components/BlocksField.tsx b/packages/admin/src/components/BlocksField.tsx index b8d1822a87..0cc3cb4983 100644 --- a/packages/admin/src/components/BlocksField.tsx +++ b/packages/admin/src/components/BlocksField.tsx @@ -38,6 +38,8 @@ interface NestedFieldDescriptor { required?: boolean; options?: Array<{ value: string; label: string }> | Record; validation?: Record; + /** Set on `integer` fields, which share the `number` kind. */ + integer?: boolean; } interface NestedFieldRenderInput { @@ -95,6 +97,7 @@ function nestedDescriptor( required: field.required, validation: field.validation, options, + ...(field.type === "integer" ? { integer: true } : {}), }; } diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index ee9d7a890f..19ea80a208 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -197,6 +197,8 @@ export interface FieldDescriptor { blockTypeFingerprint?: string; /** Value a new entry starts with. */ defaultValue?: unknown; + /** Set on `integer` fields, which share the `number` kind. */ + integer?: boolean; } /** @@ -2266,8 +2268,9 @@ function FieldRenderer({ label={{label}} id={id} type="number" + step={field.integer ? "1" : "any"} value={typeof value === "number" ? value : ""} - onChange={(e) => handleChange(Number(e.target.value))} + onChange={(e) => handleChange(e.target.value ? Number(e.target.value) : null)} required={field.required} min={range.min} max={range.max} diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts index cbd37eb951..2d2a84a4bb 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -174,6 +174,8 @@ export interface AdminManifest { blockTypeFingerprint?: string; /** Value a new entry starts with. */ defaultValue?: unknown; + /** Set on `integer` fields, which share the `number` kind. */ + integer?: boolean; } >; } diff --git a/packages/admin/tests/components/ContentEditor.test.tsx b/packages/admin/tests/components/ContentEditor.test.tsx index be1582c566..a454093d16 100644 --- a/packages/admin/tests/components/ContentEditor.test.tsx +++ b/packages/admin/tests/components/ContentEditor.test.tsx @@ -713,6 +713,129 @@ describe("ContentEditor", () => { }, ); + it("saves a cleared number field as null", async () => { + const onSave = vi.fn(); + const screen = await renderEditor({ + isNew: false, + item: makeItem({ data: { title: "Test", price: 5 } }), + onSave, + fields: { + title: { kind: "string", label: "Title", required: true }, + price: { kind: "number", label: "Price" }, + }, + }); + + await screen.getByLabelText("Price", { exact: true }).clear(); + await screen.getByRole("button", { name: "Save" }).first().click(); + + await vi.waitFor(() => + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ price: null }), + }), + ), + ); + }); + + it("accepts and saves a decimal in a number field", async () => { + const onSave = vi.fn(); + const screen = await renderEditor({ + isNew: false, + item: makeItem({ data: { title: "Test" } }), + onSave, + fields: { + title: { kind: "string", label: "Title", required: true }, + price: { kind: "number", label: "Price" }, + }, + }); + + const input = screen.getByLabelText("Price", { exact: true }); + await input.fill("4.50"); + await expect.element(input).toBeValid(); + await screen.getByRole("button", { name: "Save" }).first().click(); + + await vi.waitFor(() => + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ price: 4.5 }), + }), + ), + ); + }); + + it("rejects a decimal in an integer field", async () => { + const screen = await renderEditor({ + isNew: false, + item: makeItem({ data: { title: "Test" } }), + fields: { + title: { kind: "string", label: "Title", required: true }, + seats: { kind: "number", label: "Seats", integer: true }, + }, + }); + + const input = screen.getByLabelText("Seats", { exact: true }); + await input.fill("44.5"); + await expect.element(input).not.toBeValid(); + await input.fill("44"); + await expect.element(input).toBeValid(); + }); + + it("rejects a decimal in an integer field inside a block", async () => { + const screen = await renderEditor({ + isNew: false, + item: makeItem({ + data: { + title: "Test", + layout: [{ _type: "event", _version: 1, _key: "first", seats: 40, price: 4 }], + }, + }), + fields: { + title: { kind: "string", label: "Title", required: true }, + layout: { + kind: "blocks", + label: "Layout", + validation: { allowedTypes: ["event"], retiredTypes: [] }, + blockTypes: [ + { + id: "event-type", + slug: "event", + label: "Event", + currentVersion: 1, + source: "user", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + versions: [ + { + id: "event-v1", + blockTypeId: "event-type", + version: 1, + fields: [ + { slug: "seats", label: "Seats", type: "integer" }, + { slug: "price", label: "Price", type: "number" }, + ], + fingerprint: "one", + active: true, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }, + ], + }, + }, + }); + + const seats = screen.getByLabelText("Seats", { exact: true }); + await seats.fill("44.5"); + await expect.element(seats).not.toBeValid(); + await seats.fill("44"); + await expect.element(seats).toBeValid(); + + const price = screen.getByLabelText("Price", { exact: true }); + await price.fill("4.50"); + await expect.element(price).toBeValid(); + }); + it("keeps URL values left-to-right inside an RTL editor", async () => { document.documentElement.dir = "rtl"; try { diff --git a/packages/core/src/api/handlers/manifest.ts b/packages/core/src/api/handlers/manifest.ts index fe94d216d6..52567adc29 100644 --- a/packages/core/src/api/handlers/manifest.ts +++ b/packages/core/src/api/handlers/manifest.ts @@ -353,6 +353,7 @@ function dbFieldDescriptor( id: field.id, }; if (field.unsupportedType) entry.unsupportedType = field.unsupportedType; + if (field.type === "integer") entry.integer = true; if (field.blockTypes) entry.blockTypes = field.blockTypes; if (field.blockTypeFingerprint) entry.blockTypeFingerprint = field.blockTypeFingerprint; diff --git a/packages/core/src/api/types.ts b/packages/core/src/api/types.ts index 4413fe9dee..d5a1da116a 100644 --- a/packages/core/src/api/types.ts +++ b/packages/core/src/api/types.ts @@ -76,6 +76,8 @@ export interface ManifestFieldDescriptor extends FieldDescriptor { blockTypeFingerprint?: string; /** Value a new entry starts with in the admin editor. */ defaultValue?: unknown; + /** Set on `integer` fields, which share the `number` kind. */ + integer?: boolean; } export interface FieldDescriptor { diff --git a/packages/core/tests/unit/runtime/manifest-build.test.ts b/packages/core/tests/unit/runtime/manifest-build.test.ts index 7247f6273f..3694df45c2 100644 --- a/packages/core/tests/unit/runtime/manifest-build.test.ts +++ b/packages/core/tests/unit/runtime/manifest-build.test.ts @@ -152,7 +152,9 @@ describe("generateManifest()", () => { expect(manifest.collections.currents?.fields.priority).toMatchObject({ kind: "number", label: "Priority", + integer: true, }); + expect(manifest.collections.currents?.fields.title).not.toHaveProperty("integer"); }); it("publishes the sidebar icon and group for database collections", async () => {