Skip to content
Draft
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
41 changes: 41 additions & 0 deletions .changeset/number-field-use-grouping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@objectstack/spec": minor
---

feat(spec): `Field.number` gains a `useGrouping` presentation hint (#7768)

`scale` was the only presentation-adjacent property a `number` field carried,
and it governs decimal places, not digit grouping. Console number renderers
construct `Intl.NumberFormat` with grouping unconditionally ON, so an
ordinal/identifier integer authored as `Field.number({ scale: 0, min: 1900 })`
(e.g. a year) renders `2,026` everywhere it is shown. Downstream apps hit this
three times (hotcrm-heimao#35, #40, #59) and each time converted the field to
`Field.text` to escape the comma — trading away numeric semantics (range
validation, sort-as-number, arithmetic) for a display detail unrelated to the
field's type.

**New:** `useGrouping?: boolean` on `FieldSchema` (flat, alongside
`precision`/`scale`/`min`/`max`), threaded automatically through
`Field.number(...)` and every other type's builder via the existing
`FieldInput` shape — no builder special-casing needed, the same way
`scale`/`min` travel today.

Deliberately three-valued and NO default declared:

- **absent** — the author has not judged whether this integer reads as a
quantity or an identifier; the renderer decides (an interim heuristic today,
the locale's own default eventually — that contract lives in objectui, not
here).
- **`false`** — the author's explicit opt-out: never group this number
(a year, an ID, a zip code).
- **`true`** — the author pins grouping on, overriding the heuristic the
other way.

Maps 1:1 onto `Intl.NumberFormat`'s `useGrouping`. This is Option A of the
card's fork — the narrowest shape with measured pull — ruled on #7768,
2026-08-11, with the maintainer's veto window open. No `displayFormat` slot,
no other presentation knobs.

Unblocks objectui#4033, the console renderer half that consumes the explicit
hint and retires the interim heuristic (explicit-hint > heuristic >
locale-default).
1 change: 1 addition & 0 deletions content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const result = CurrencyConfigSchema.parse(data);
| **scale** | `number` | optional | Decimal places |
| **min** | `number` | optional | Minimum value |
| **max** | `number` | optional | Maximum value |
| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields (#7768) — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. |
| **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. |
| **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. |
| **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect |
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@
"data/Field:trackHistory",
"data/Field:type",
"data/Field:unique",
"data/Field:useGrouping",
"data/Field:visibleWhen",
"data/Field:widget",
"data/FieldReference:$field",
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/liveness/field.json
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@
"status": "live",
"note": "CAVEAT — grid formatting only; DDL never sizes."
},
"useGrouping": {
"status": "planned",
"note": "[#7768] Declared 2026-08-11 — Option A of the card's fork (narrowest shape with measured pull, maintainer veto window open). Maps 1:1 to `Intl.NumberFormat`'s `useGrouping`. The runtime consumer is objectui#4033 (in flight at declaration time): its interim heuristic (`scale === 0 && no currency` ⇒ ungrouped) is explicitly documented at its own definition as \"overridden by the spec presentation hint when it lands\" — i.e. the read side is designed to pick this key up, not a speculative future phase. Not `authorWarn`'d: unlike `externalSharingModel`'s scheduled-phase precedent, an author who sets this today loses nothing and is not misled — the value becomes effective the moment the objectui consumer lands, no re-authoring needed. Re-verify to `live` once #4033 (or its successor) actually reads the key."
},
"reference": {
"status": "live",
"evidence": "packages/objectql/src/engine.ts:1672",
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/liveness/state-counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ for both corollaries.
| Type | live | exp | dead | planned | classified |
|---|---|---|---|---|---|
| `object` | 49 | 0 | 0 | 1 | 50 |
| `field` | 66 | 0 | 0 | 0 | 66 |
| `field` | 66 | 0 | 0 | 1 | 67 |
| `flow` | 34 | 0 | 6 | 0 | 40 |
| `action` | 42 | 0 | 2 | 0 | 44 |
| `hook` | 18 | 0 | 2 | 0 | 20 |
Expand Down Expand Up @@ -57,4 +57,4 @@ for both corollaries.
| `api` | 25 | 0 | 0 | 2 | 27 |
| `capability` | 12 | 0 | 0 | 0 | 12 |
| `qa` | 4 | 0 | 5 | 0 | 9 |
| **total** | **776** | **6** | **52** | **5** | **839** |
| **total** | **776** | **6** | **52** | **6** | **840** |
70 changes: 70 additions & 0 deletions packages/spec/src/data/field.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import {
FieldSchema,
FieldType,
Expand Down Expand Up @@ -239,6 +240,75 @@ describe('FieldSchema', () => {
});
});

describe('useGrouping — number-field digit-grouping presentation hint (#7768)', () => {
it('accepts an explicit `false` (author opts out of grouping — e.g. a year)', () => {
const yearField: Field = {
name: 'founded_year',
label: 'Founded Year',
type: 'number',
scale: 0,
min: 1900,
useGrouping: false,
};
const result = FieldSchema.parse(yearField);
expect(result.useGrouping).toBe(false);
});

it('accepts an explicit `true` (author pins grouping on)', () => {
const result = FieldSchema.parse({ type: 'number', useGrouping: true });
expect(result.useGrouping).toBe(true);
});

it('is optional — absent stays absent, no default materializes', () => {
const result = FieldSchema.parse({ type: 'number' }) as Record<string, unknown>;
expect(result.useGrouping).toBeUndefined();
expect('useGrouping' in result).toBe(false);
});

it('rejects a non-boolean value', () => {
expect(() => FieldSchema.parse({ type: 'number', useGrouping: 'true' })).toThrow();
expect(() => FieldSchema.parse({ type: 'number', useGrouping: 1 })).toThrow();
expect(() => FieldSchema.parse({ type: 'number', useGrouping: null })).toThrow();
});

it('is not type-restricted at the schema level (flat on FieldSchema, like scale/min)', () => {
// FieldSchema does not discriminate its constraint keys by `type` — same
// posture as `scale`/`min`, which parse on any field type too. A type-aware
// "only meaningful on number/currency/percent" restriction is a renderer/lint
// concern, not a parse-time one.
expect(() => FieldSchema.parse({ type: 'text', useGrouping: false })).not.toThrow();
});

it('does not disturb FieldSchema unknown-key strictness (#4001)', () => {
expect(() => FieldSchema.parse({
type: 'number',
useGrouping: false,
totallyBogusKey: true,
} as unknown as Field)).toThrow(/Unrecognized key/);
});

it('Field.number(...) threads useGrouping through like scale/min (no special-casing needed)', () => {
const f = Field.number({ label: 'Founded Year', scale: 0, min: 1900, useGrouping: false });
expect(f).toEqual({ type: 'number', label: 'Founded Year', scale: 0, min: 1900, useGrouping: false });
expect(() => FieldSchema.parse(f)).not.toThrow();
});

it('declares a boolean JSON-Schema slot with NO default — absence defers to the renderer', () => {
const js = z.toJSONSchema(FieldSchema as unknown as z.ZodType, {
unrepresentable: 'any',
io: 'input',
}) as any;
const prop = js.properties?.useGrouping;
expect(prop).toBeDefined();
expect(prop.type).toBe('boolean');
// Unlike `autonumberFormat`, this key carries no JSON-Schema `default`
// annotation — there is no renderer-agnostic grouping behavior to declare
// until the objectui half (#4033) retires the interim heuristic.
expect(prop.default).toBeUndefined();
expect(js.required ?? []).not.toContain('useGrouping');
});
});

describe('Select Field', () => {
it('should accept select field with options', () => {
const selectField: Field = {
Expand Down
39 changes: 39 additions & 0 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,45 @@ export const FieldSchema = lazySchema(() => strictObject({
scale: z.number().optional().describe('Decimal places'),
min: z.number().optional().describe('Minimum value'),
max: z.number().optional().describe('Maximum value'),
/**
* Presentation hint (#7768): whether a `number` field renders with digit
* grouping (`Intl.NumberFormat`'s `useGrouping`, e.g. `2,026` vs `2026`).
* `scale` was the ONLY presentation-adjacent property `number` had, and it
* governs decimal places, not grouping — console renderers construct
* `Intl.NumberFormat` with grouping unconditionally ON, so an
* ordinal/identifier integer stored as `Field.number({ scale: 0, min: 1900
* })` (a year) renders `2,026` everywhere it is shown. Downstream apps hit
* this three times (hotcrm-heimao#35/#40/#59) and each time converted the
* field to `Field.text` to escape the comma — trading away numeric
* semantics (range validation, sort-as-number, arithmetic) for a display
* detail that had nothing to do with the field's TYPE.
*
* Three-valued, and the absent case is deliberately NOT "grouping off":
* - **absent** (default state) — the author has not judged whether this
* number reads as a quantity or an identifier; the RENDERER decides.
* Today that is an interim heuristic (objectui#4033, e.g. `scale: 0`
* + no upper bound reads as a plain count and keeps grouping, a small
* bounded integer range reads as ordinal-shaped and drops it);
* eventually the locale's own default. Neither contract lives here —
* this key only carries the author's EXPLICIT override when they have
* one, exactly like `min`/`max`/`scale` carry constraints without
* asserting what an unconstrained field means.
* - **`false`** — the author's explicit opt-out: this integer is an
* identifier/ordinal (year, ID, zip code, quantity meant to scan
* un-grouped), never grouped regardless of what the renderer's
* heuristic would have guessed.
* - **`true`** — the author pins grouping ON, overriding the heuristic
* the other way (a large monetary-like count that should always read
* with separators even if it would otherwise be judged ordinal-shaped).
*
* Maps 1:1 onto `Intl.NumberFormat`'s `useGrouping` option; the console
* number renderers are expected to pass it straight through. No default is
* declared here on purpose — unlike `autonumberFormat`'s JSON-Schema
* `default` annotation, there is no single grouping behavior every
* `number` field should present until the renderer half of this contract
* (objectui#4033) lands and retires the interim heuristic.
*/
useGrouping: z.boolean().optional().describe('Digit-grouping presentation hint for `number` fields (#7768) — maps to `Intl.NumberFormat`\'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on.'),

/**
* Media Constraints (ADR-0104 D3 wave 2)
Expand Down
Loading