Skip to content

fix(admin): save a cleared number field as empty and allow decimals - #3791

Merged
danielmlr merged 3 commits into
emdash-cms:mainfrom
DiogoDuart3:fix/number-field-empty-decimal
Oct 8, 2026
Merged

danielmlr merged 3 commits into
emdash-cms:mainfrom
DiogoDuart3:fix/number-field-empty-decimal

Conversation

@DiogoDuart3

@DiogoDuart3 DiogoDuart3 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

This fixes two problems with the number input in the content editor.

  1. A cleared field is saved as 0. The input's onChange sends Number(""), which is 0. With validation: { min: 1 }, every save and autosave then fails with "Price must be at least 1." Without a minimum, a 0 the editor never typed is silently stored. The server cannot tell this from a real zero, so the widget now sends null for an empty input. This is the same idiom the repeater's number sub-field already uses (RepeaterField.tsx).
  2. Decimals block Save. The input has no step, so the browser's default step of 1 marks 4.50 invalid. Clicking Save shows "Please enter a valid value. The two nearest valid values are 4 and 5." and nothing is sent. Autosave skips form validation and stores the value anyway, which hides the problem. The input now has step="any".

Reproduction on a fresh demos/simple:

  1. Sign in with /_emdash/api/setup/dev-bypass?redirect=/_emdash/admin.
  2. Create a collection with an optional Number field price with min: 1.
  3. Create an entry with price 5.
  4. Clear Price and click Save. The PUT returns 400 too_small.
  5. Type 4.50 and click Save. The browser blocks the submit with the "nearest valid values" message.

With this change, step 4 saves price: null and step 5 saves 4.5.

No existing issue.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Not applicable: no new strings.
  • I have added and reviewed the user-facing changeset (if this PR changes a published package)
  • New features link to an approved Discussion: not applicable (bug fix).
  • I have included screenshots below if this PR changes the UI

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5.5 (Claude Code)

Screenshots / test output

Edit entry screen before the change: clearing Price shows 0, the browser flags it below the minimum, and autosave fails with "Price must be at least 1."
Edit entry screen after the change: Price cleared and saved without an error
Edit entry screen before the change: Price 4.50 shows the browser message "The two nearest valid values are 4 and 5" while the header says Saved from autosave
Edit entry screen after the change: Price 4.5 saved
Edit entry screen after the change: an integer field with a minimum of 0 holding 44.5, with the browser message "The two nearest valid values are 44 and 45" and nothing saved

Two new cases in packages/admin/tests/components/ContentEditor.test.tsx both fail without the change:

  • Clearing a number field calls onSave with price: null. Without the change it is called with 0.
  • 4.50 leaves the input valid and is saved as 4.5. Without the change the input fails toBeValid().

Added in review, both failing without the integer change:

  • ContentEditor.test.tsx "rejects a decimal in an integer field": with integer: true, 44.5 leaves the input invalid and 44 is valid.
  • packages/core/tests/unit/runtime/manifest-build.test.ts: an integer field's descriptor has integer: true; a string field has no integer key.

Results:

  • Admin suite: 220 files and 3010 tests passed. Core suite: 9150 passed, 11 skipped.
  • pnpm build, pnpm typecheck, pnpm lint and pnpm format:check are clean.
  • pnpm test:e2e field-widgets autosave content-crud form-data-loss: 28 passed. The full e2e suite was not run.

Integer fields: they share the number editor kind in the manifest, so the manifest field descriptor now carries an optional integer: true for them (added in review, commit 2c2362f). The input uses step="1" for integer fields and step="any" for number fields, matching how RepeaterField picks the step from the sub-field type. The flag is additive and only set on integer fields.

A browser limitation worth knowing: the step check is measured from the input's min, or, when there is no min, from its value attribute. React rewrites that attribute when the input loses focus, so on an integer field without a min a decimal typed and then followed by a Save click is no longer flagged by the browser and is rejected by the server instead ("Seats has an invalid value."), as it was before this PR. With a min (for example min: 0), the browser blocks the Save click with "The two nearest valid values are 44 and 45". RepeaterField's integer sub-fields behave the same way.

Also related: #3758 (open) touches ContentEditor.tsx, but not this input.

https://claude.ai/code/session_011SKvTndrtRUPdctoaRyUw9

@changeset-bot

changeset-bot Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5ea766b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@emdash-cms/admin Patch
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/plugin-test Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
create-emdash Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@DiogoDuart3

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@DiogoDuart3

Copy link
Copy Markdown
Contributor Author

recheck

@emdashbot emdashbot Bot left a comment

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.

This is the right fix for the two reported bugs, scoped to the admin content editor and following the same empty-as-null idiom that RepeaterField already uses. I checked the number input branch in ContentEditor.tsx, compared it to RepeaterField.tsx, traced how the value flows into formData and the onSave/onAutosave payload, and verified that ContentRepository.update serializes null for optional column writes. The added regression tests cover both behaviors behaviorally, and the changeset accurately describes the user-facing fix.

The only thing worth flagging is a deliberate trade-off acknowledged in the PR description: SERVER_FIELD_TYPE_TO_EDITOR_KIND maps both server types number and integer to the same editor kind: "number", so the new step="any" also applies to integer fields and removes the browser's integer-only validation on the Save-click path. That is the correct minimal choice for this bug fix, but it would be worth a follow-up to plumb the original field type through FieldDescriptor (or expose a step hint) so integer fields can restore step="1" while number fields keep step="any" — RepeaterField already has access to subField.type and does exactly this.

label={<span className={labelClass}>{label}</span>}
id={id}
type="number"
step="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.

[suggestion] step="any" is required to fix the decimal bug, but it also affects integer fields because SERVER_FIELD_TYPE_TO_EDITOR_KIND maps both server types to the same editor kind: "number". Previously, integer fields relied on the browser's default step="1" to block decimals on Save-click; with step="any" those values now reach the server and are rejected via the invalid_type toast instead. The PR description acknowledges this, so this is a low-priority follow-up rather than a blocker: once the original field type is plumbed through FieldDescriptor (or a step hint is added), this branch can choose step={field.type === "integer" ? "1" : "any"} to match RepeaterField.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 2c2362f. The manifest field descriptor now carries an optional integer: true for integer fields (packages/core/src/api/handlers/manifest.ts, typed in packages/core/src/api/types.ts and the admin's AdminManifest), and the number input uses step={field.integer ? "1" : "any"}, the same split RepeaterField makes from subField.type. The flag is only set on integer fields, so other descriptors are unchanged.

Tests:

  • packages/admin/tests/components/ContentEditor.test.tsx, "rejects a decimal in an integer field": 44.5 is invalid and 44 is valid with integer: true.
  • packages/core/tests/unit/runtime/manifest-build.test.ts: an integer field's descriptor has integer: true, a string field has none.

Both fail without the change. The changeset now also lists emdash.

One caveat from checking it in the running admin: browsers measure step from min, or from the value attribute when there is no min, and React rewrites that attribute on blur. So an integer field with a min blocks 44.5 on Save, but one without a min lets the click through and the server rejects it, as before. RepeaterField's integer sub-fields have the same behaviour.

@emdashbot emdashbot Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Oct 2, 2026
@github-actions github-actions Bot added review/needs-rereview New commits since the last review area/core overlap and removed review/awaiting-author Reviewed; waiting on the author to respond labels Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@DiogoDuart3

Copy link
Copy Markdown
Contributor Author

On the overlap notice: #3758 adds defaultValue to the same three interfaces this PR adds integer to (FieldDescriptor in ContentEditor.tsx, AdminManifest fields in client.ts, ManifestFieldDescriptor in api/types.ts), so whichever merges second gets adjacent-line conflicts; the resolution is to keep both fields. #1365's conflicts are in files this PR doesn't touch. This PR still merges cleanly into current main.

@pkg-pr-new

pkg-pr-new Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@3791

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@3791

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@3791

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@3791

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@3791

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@3791

emdash

npm i https://pkg.pr.new/emdash@3791

create-emdash

npm i https://pkg.pr.new/create-emdash@3791

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@3791

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@3791

@emdash-cms/plugin-test

npm i https://pkg.pr.new/@emdash-cms/plugin-test@3791

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@3791

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@3791

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@3791

@emdash-cms/registry-loader

npm i https://pkg.pr.new/@emdash-cms/registry-loader@3791

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/@emdash-cms/registry-moderation@3791

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@3791

upgrade-emdash

npm i https://pkg.pr.new/upgrade-emdash@3791

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@3791

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@3791

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@3791

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@3791

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@3791

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@3791

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@3791

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@3791

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@3791

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@3791

commit: 5ea766b

@danielmlr danielmlr self-assigned this Oct 4, 2026
@danielmlr

Copy link
Copy Markdown
Contributor

Thanks for your work on this and for checking the overlap with #3758. Now that it's merged, please:

  • Rebase your branch onto the latest main.
  • Where the changes overlap, keep both defaultValue and integer.
  • Keep fix(admin): start new entries with each field's default value #3758's defaults for new entries and the separate copy for each entry, along with your number-field fixes and both sets of tests.
  • Run the local checks again, including the editor and manifest tests.
  • Push your updated branch.
    I'll keep an eye on the branch, so there's no need to send me a separate update.

Thanks!

@danielmlr danielmlr left a comment

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.

Thanks for this, and for picking up the integer step so quickly!
The fix works and the tests do their job: if I undo the two ContentEditor.tsx changes, both new number tests fail.

There's one more place that needs the integer flag. Integer fields inside a Blocks field get their settings from nestedDescriptor() in packages/admin/src/components/BlocksField.tsx, and that never sets integer. So with this change they'd let people type decimals. I left the details inline. Could you add it there too, ideally with a small test?

You can do that together with the rebase onto main. Since #3758 landed, defaultValue sits right next to your new integer line in three places (FieldDescriptor in ContentEditor.tsx, AdminManifest in lib/api/client.ts, ManifestFieldDescriptor in core/src/api/types.ts). Just keep both lines and the conflicts are gone.

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)

Clearing a number field sent Number("") = 0, which failed a `min` of 1 or
above and otherwise stored a zero the editor never typed. The server cannot
tell that zero from a real one, so the widget now sends null for an empty
input, as the repeater's number sub-field already does.

The input also had no `step`, so the browser's default step of 1 made a
value such as 4.50 invalid and blocked the Save button with "nearest valid
values are 4 and 5", while autosave, which skips form validation, still
saved it. `step="any"` lets number fields hold decimals. Integer fields share
the "number" editor kind, so a decimal typed into one now reaches the server
and is rejected there instead of by the browser.

Claude-Session: https://claude.ai/code/session_011SKvTndrtRUPdctoaRyUw9
`step="any"` fixed decimals in number fields but also reached integer
fields, because the manifest gives both the "number" editor kind. The
manifest field descriptor now carries an optional `integer: true` for
integer fields, and the number input uses `step="1"` for them and
`step="any"` otherwise, as the repeater's number sub-fields already do
from their own type. The flag is additive: other fields and consumers
of the manifest are unchanged.

Claude-Session: https://claude.ai/code/session_011SKvTndrtRUPdctoaRyUw9
Fields of a block reach FieldRenderer through nestedDescriptor(), which mapped integer to the number
kind and dropped the flag, so with step="any" an integer in a block accepted 4.5 and only the server
refused it.
@DiogoDuart3
DiogoDuart3 force-pushed the fix/number-field-empty-decimal branch from 2c2362f to 5ea766b Compare October 8, 2026 17:08

@danielmlr danielmlr left a comment

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.

Thanks!
Integer fields in blocks now reject decimals, with a test, and the rebase kept emdash-cms/emdash#3758 intact.
Looks good.

@danielmlr
danielmlr merged commit dd983cd into emdash-cms:main Oct 8, 2026
42 checks passed
@emdashbot emdashbot Bot mentioned this pull request Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants