Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/number-field-empty-decimal.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions packages/admin/src/components/BlocksField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ interface NestedFieldDescriptor {
required?: boolean;
options?: Array<{ value: string; label: string }> | Record<string, unknown>;
validation?: Record<string, unknown>;
/** Set on `integer` fields, which share the `number` kind. */
integer?: boolean;
}

interface NestedFieldRenderInput {
Expand Down Expand Up @@ -95,6 +97,7 @@ function nestedDescriptor(
required: field.required,
validation: field.validation,
options,
...(field.type === "integer" ? { integer: true } : {}),
};
}

Expand Down
5 changes: 4 additions & 1 deletion packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -2266,8 +2268,9 @@ function FieldRenderer({
label={<span className={labelClass}>{label}</span>}
id={id}
type="number"
step={field.integer ? "1" : "any"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fields inside a Blocks field end up here too, through BlocksField's renderField. Their settings come from nestedDescriptor() in BlocksField.tsx, which turns integer into number but drops the integer flag. So an integer field in a block would now take 4.5, and people only find out when the server says no. Adding integer?: boolean to NestedFieldDescriptor and setting it when field.type === "integer" should keep these at whole numbers.

~ 🤖 Claude Code (Opus 5.5)

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}
Expand Down
2 changes: 2 additions & 0 deletions packages/admin/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
>;
}
Expand Down
123 changes: 123 additions & 0 deletions packages/admin/tests/components/ContentEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/api/handlers/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/tests/unit/runtime/manifest-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading