From a31973f9673f4d0ce349da75292e5cb8a09d25dd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:14:19 +0000 Subject: [PATCH 1/2] fix(fields): a summary reads one stored value the same in the form and in the cell (objectui#11752) The read-only summary widget formatted the value by the roll-up's aggregation function: `count` with `String(value)`, `sum` / `avg` / `min` / `max` with `toFixed(2)`. Its table cell (`FormulaCellRenderer`) draws a JS number through the number cell, so a `sum` over 15750.5 read `15750.50` in the form and `15,750.5` in the table. The widget now reads a summary's value the way the cell does, whatever the function: the shared emptiness floor on the coerced value, then `formatNumberFieldValue` for a JS number, and the coerced text otherwise. A count stays whole (no fixed width without a declared `scale`). Pins per aggregation function x face x locale (en-US, zh-CN), equality with the number field's own faces, and a form-equals-cell leg. The prose that described the old face moves with it: `summary.mdx` and the `summaryOperations` doc comments in `@object-ui/types`. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- content/docs/fields/summary.mdx | 14 +- .../summaryFaces.numberFace-11752.test.tsx | 243 ++++++++++++++++++ packages/fields/src/widgets/SummaryField.tsx | 66 +++-- .../formula-summary-spec-reads-11070.test.tsx | 31 ++- packages/types/src/field-types.ts | 11 +- packages/types/src/form.ts | 19 +- 6 files changed, 346 insertions(+), 38 deletions(-) create mode 100644 packages/fields/src/__tests__/summaryFaces.numberFace-11752.test.tsx diff --git a/content/docs/fields/summary.mdx b/content/docs/fields/summary.mdx index f0ddc27ce3..a4a00dd5d6 100644 --- a/content/docs/fields/summary.mdx +++ b/content/docs/fields/summary.mdx @@ -49,8 +49,18 @@ they are runtime widget props. See [Field Widget Props](/docs/fields/widget-prop ## Aggregation Functions -The summary field formats values by `summaryOperations.function`: `count` as it -arrives, the other four to two decimal places. +The function decides what the backend computes, not how the value is drawn. The +form and the table cell read the same rule for every function, so one stored +value reads the same in both: + +- **a number**: Formatted as a number field formats it, in the viewer's display + locale (`15750.5` reads `15,750.5` in en-US). With no `scale` on the field it + keeps its own precision, so a `count` reads as a whole number (`12000` reads + `12,000`); a `scale` on the field sets a fixed width in both places (`scale: 2` + reads `15,750.50`) +- **anything else**: The value as text; an empty value reads as empty + +The five functions: - **count**: Count of related records - **sum**: Sum of numeric field values diff --git a/packages/fields/src/__tests__/summaryFaces.numberFace-11752.test.tsx b/packages/fields/src/__tests__/summaryFaces.numberFace-11752.test.tsx new file mode 100644 index 0000000000..01d6ea0c2b --- /dev/null +++ b/packages/fields/src/__tests__/summaryFaces.numberFace-11752.test.tsx @@ -0,0 +1,243 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11752 — a summary (roll-up) field draws one stored value one way, + * in the read-only form face (`SummaryField`) and in the table cell + * (`getCellRenderer('summary')`, which is `FormulaCellRenderer`). + * + * Before this card the form face formatted the value by itself, by the + * roll-up's aggregation `function`: `count` with `String(value)`, the other + * four with `toFixed(2)`. So a `sum` over `15750.5` read `15750.50`, ungrouped + * and in every locale, while the cell drew the same JS number through the + * number cell (objectui#11683) and read `15,750.5`. + * + * The rule both faces now read, whatever the function: + * + * - the shared emptiness floor (`isEmptyValue`) on the coerced value; + * - a JS number through `formatNumberFieldValue`, the call every number face + * makes: the display locale's grouping, the width a declared `scale` + * gives, the value's natural precision when none is declared. A `count` + * is a whole number, so it reads whole; + * - anything else as the text the cell prints. + * + * The pins run per aggregation function x face x locale, then compare each + * face with the `number` field's own face for the same value, and end with an + * equality leg that compares the two summary faces with each other rather than + * with a literal, so a change that moves one face alone goes red. + * + * Every case renders through a real `I18nProvider` and `LocalizationProvider`, + * so `zh-CN` is a Chinese session on both channels. `textContent` is the + * measured property: it is what a copy takes and a screen reader reads. + */ +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { NumberCellRenderer, NumberField, getCellRenderer } from '../index'; +import { SummaryField } from '../widgets/SummaryField'; + +afterEach(() => cleanup()); + +const noop = () => {}; + +/** A session per locale: the UI language and the display locale agree. */ +const SESSIONS = { + 'en-US': 'en', + 'zh-CN': 'zh', +} as const; +type Locale = keyof typeof SESSIONS; +const LOCALES = Object.keys(SESSIONS) as Locale[]; + +function renderSession(locale: Locale, node: React.ReactElement) { + return render( + + {node} + , + ); +} + +function textOf(locale: Locale, node: React.ReactElement): string { + const { container } = renderSession(locale, node); + const text = container.textContent ?? ''; + cleanup(); + return text; +} + +const FUNCTIONS = ['count', 'sum', 'avg', 'min', 'max'] as const; +type Fn = (typeof FUNCTIONS)[number]; +type Extra = Record; + +/** A roll-up shaped like the showcase's `expense_report.total_amount`. */ +const summaryField = (fn: Fn, extra: Extra = {}) => + ({ + type: 'summary', + name: 'total_amount', + summaryOperations: { object: 'showcase_expense_line', field: 'amount', function: fn }, + ...extra, + }) as never; + +const Cell = getCellRenderer('summary'); + +/** The read-only form face, as a record form renders a summary widget. */ +const formFace = (fn: Fn, value: unknown, extra: Extra = {}) => ( + +); + +/** The table cell face, resolved the way every list surface resolves it. */ +const cellFace = (fn: Fn, value: unknown, extra: Extra = {}) => ( + +); + +const FACES = [ + ['form', formFace], + ['cell', cellFace], +] as const; + +/** The shared "No value" affordance. */ +const affordance = (root: HTMLElement) => root.querySelector('[data-slot="empty-value"]'); + +/** What a `number` field draws for the same value, per face. */ +const numberCell = (locale: Locale, value: number, extra: Extra = {}) => + textOf(locale, ); +const numberForm = (locale: Locale, value: number, extra: Extra = {}) => + textOf(locale, ); + +// ── per function x face x locale ────────────────────────────────────────── + +/** The grouped reading of `15750.5`; both sessions group with a comma. */ +const SUM_TEXT: Record = { 'en-US': '15,750.5', 'zh-CN': '15,750.5' }; +/** The grouped reading of a count of `12000`. */ +const COUNT_TEXT: Record = { 'en-US': '12,000', 'zh-CN': '12,000' }; + +describe('every aggregation function reads the number face, in both faces (objectui#11752)', () => { + describe.each(LOCALES)('%s', (locale) => { + describe.each(['sum', 'avg', 'min', 'max'] as const)('%s', (fn) => { + it.each(FACES)("%s — 15750.5 reads grouped at its own precision, not toFixed's 15750.50", (_face, draw) => { + const text = textOf(locale, draw(fn, 15750.5)); + expect(text).toBe(SUM_TEXT[locale]); + expect(text).not.toBe('15750.50'); + }); + + it.each(FACES)('%s — a declared scale is the width', (_face, draw) => { + expect(textOf(locale, draw(fn, 15750.5, { scale: 2 }))).toBe(`${SUM_TEXT[locale]}0`); + }); + }); + + describe('count', () => { + it.each(FACES)('%s — a count stays whole, and groups like any number', (_face, draw) => { + const text = textOf(locale, draw('count', 12000)); + expect(text).toBe(COUNT_TEXT[locale]); + expect(text).not.toMatch(/[.]\d/); + }); + + it.each(FACES)('%s — a small count reads as it is', (_face, draw) => { + expect(textOf(locale, draw('count', 42))).toBe('42'); + }); + }); + + describe.each(FUNCTIONS)('%s', (fn) => { + // `count` and `sum` take `0` over an empty child set (objectstack's + // `summaryEmptySetValue`): a stored zero is a value, not an empty one. + it.each(FACES)('%s — 0 reads 0, not 0.00 and not the empty affordance', (_face, draw) => { + const { container } = renderSession(locale, draw(fn, 0)); + expect(container.textContent).toBe('0'); + expect(affordance(container)).toBeNull(); + }); + + it.each(FACES)('%s — the number is not drawn in monospace', (_face, draw) => { + const { container } = renderSession(locale, draw(fn, 15750.5)); + expect(container.querySelector('.font-mono')).toBeNull(); + }); + }); + }); + + it.each(FACES)('%s — the display locale reaches the formatter: de-DE groups with a dot', (_face, draw) => { + const { container } = render( + {draw('sum', 15750.5)}, + ); + expect(container.textContent).toBe('15.750,5'); + }); +}); + +// ── the number field's own faces ────────────────────────────────────────── + +describe("each summary face is the `number` field's face for the same value (objectui#11752)", () => { + const VALUES: Array<[string, number, Extra]> = [ + ['a fraction', 15750.5, {}], + ['a whole count', 12000, {}], + ['an average with no declared width', 10 / 3, {}], + ['a declared scale', 15750.5, { scale: 2 }], + ['a negative minimum', -42.25, {}], + ]; + + describe.each(LOCALES)('%s', (locale) => { + it.each(VALUES)('%s', (_label, value, extra) => { + for (const fn of FUNCTIONS) { + expect(textOf(locale, formFace(fn, value, extra)), `form, ${fn}`).toBe(numberForm(locale, value, extra)); + expect(textOf(locale, cellFace(fn, value, extra)), `cell, ${fn}`).toBe(numberCell(locale, value, extra)); + } + }); + }); +}); + +// ── empty and non-number values ─────────────────────────────────────────── + +describe('empty and non-number values read the cell text (objectui#11752)', () => { + // `min` / `max` / `avg` stay `null` over an empty child set. The form face + // read `value == null`, so `''` and `[]` drew a blank span with no + // accessible name here and the shared affordance in the table. + it.each(FACES)("%s — null, undefined, '' and [] draw the shared empty affordance", (_face, draw) => { + for (const fn of FUNCTIONS) { + for (const value of [null, undefined, '', []]) { + const { container } = renderSession('en-US', draw(fn, value)); + expect(affordance(container), `${fn} holding ${JSON.stringify(value)}`).not.toBeNull(); + cleanup(); + } + } + }); + + // A summary declares no `returnType`, so only a JS number is a number: a + // string of digits stays the text the cell prints. + it.each(FACES)('%s — a string of digits stays text', (_face, draw) => { + expect(textOf('en-US', draw('sum', '15750.5'))).toBe('15750.5'); + }); +}); + +// ── the equality leg ────────────────────────────────────────────────────── + +/** + * One stored value reads the same in the form and in the cell: every row is + * an (aggregation function, value, declaration) triple, drawn in both faces + * in both sessions, and the two texts are compared with each other. + */ +describe('one stored value, one reading: the form face equals the cell (objectui#11752)', () => { + const ROWS: Array<[string, Fn, unknown, Extra]> = [ + ['count, small', 'count', 42, {}], + ['count, large', 'count', 12000, {}], + ['count, empty child set', 'count', 0, {}], + ['sum, the card value', 'sum', 15750.5, {}], + ['sum, empty child set', 'sum', 0, {}], + ['sum, declared scale', 'sum', 15750.5, { scale: 2 }], + ['sum, declared ungrouped', 'sum', 15750.5, { useGrouping: false }], + ['avg, a repeating fraction', 'avg', 10 / 3, {}], + ['avg, declared scale', 'avg', 10 / 3, { scale: 2 }], + ['min, negative', 'min', -42.25, {}], + ['max, large', 'max', 1234567.891, {}], + ['max, numeric string', 'max', '15750.5', {}], + ]; + + describe.each(LOCALES)('%s', (locale) => { + it.each(ROWS)('%s', (_label, fn, value, extra) => { + const form = textOf(locale, formFace(fn, value, extra)); + const cell = textOf(locale, cellFace(fn, value, extra)); + expect(form).not.toBe(''); + expect(form).toBe(cell); + }); + }); +}); diff --git a/packages/fields/src/widgets/SummaryField.tsx b/packages/fields/src/widgets/SummaryField.tsx index 5d2f7f18de..598a6f12d7 100644 --- a/packages/fields/src/widgets/SummaryField.tsx +++ b/packages/fields/src/widgets/SummaryField.tsx @@ -1,35 +1,69 @@ import React from 'react'; import { EmptyValue } from '@object-ui/components'; -import type { SummaryFieldMetadata } from '@object-ui/types'; +import { useDisplayLocale } from '@object-ui/i18n'; +import { isEmptyValue } from '@object-ui/core'; import { FieldWidgetComponentProps } from './types.js'; +import { formatNumberFieldValue } from './number-format.js'; +import { coerceToSafeValue } from '../coerceToSafeValue.js'; /** * SummaryField - Read-only aggregation field * Values are aggregated from related records and cannot be edited + * + * ## One value, one reading (objectui#11752) + * + * This is the read-only FORM face of a roll-up; its TABLE face is the formula + * cell, `FormulaCellRenderer` in this package's barrel, which `summary` is + * registered to. The two used to format the value each by itself, so one + * stored value read two ways: this face picked a format by the roll-up's + * aggregation `function` (`count` as it arrived, the other four with + * `toFixed(2)`), so a `sum` over `15750.5` read `15750.50`, ungrouped and in + * every locale, beside the cell's `15,750.5`. + * + * This face now reads the value the way the cell reads a summary's value, + * whatever the function: + * + * - the emptiness FLOOR (`isEmptyValue`) on the coerced value, as the cell + * takes it, so `''` and `[]` read the shared affordance in both faces; + * - a JS number: `formatNumberFieldValue`, the one call every number face + * makes (the display locale's grouping, the width `resolveFieldScale` + * answers for a declared `scale`, the value's natural precision when none + * is declared). A `count` is a whole number, so it reads whole; an author + * who wants a fixed width on a `sum` or an `avg` declares `scale`, and both + * faces honour it. + * - anything else: the coerced value as text, as the cell prints it. A + * summary declares no `returnType`, so a string of digits is not a number. + * + * The rule is spelled here and in the cell, because the barrel and this lazily + * loaded widget may not import each other (the reason `number-format.ts` + * gives). `__tests__/summaryFaces.numberFace-11752.test.tsx` draws every + * aggregation function in both faces and compares the two texts, so neither + * can move alone. */ export function SummaryField({ value, field, ...props }: FieldWidgetComponentProps) { - // The aggregation `function` of the spec's `summaryOperations` roll-up - // (`{ object, field, function }`), the shape object metadata carries. It is - // the ONLY spelling read: the snake_case `summary_type` this widget used to - // read is retired (objectui#11070). - const summaryType = (field as SummaryFieldMetadata | undefined)?.summaryOperations?.function ?? 'count'; + // Before the empty-value early return: the hook count must not change when a + // value flips between null and set. + const locale = useDisplayLocale(); - if (value == null) { + // The floor, on the coerced value, exactly as the formula cell takes it. + // This read `value == null`, so `''` and `[]` drew a blank span with no + // accessible name here and the shared affordance in the table. + const safe = coerceToSafeValue(value); + if (isEmptyValue(safe)) { return ; } - let displayValue: string; - if (summaryType === 'count') { - displayValue = String(value); - } else if (['sum', 'avg', 'min', 'max'].includes(summaryType)) { - displayValue = typeof value === 'number' ? value.toFixed(2) : String(value); - } else { - displayValue = String(value); - } + // Only a JS number is a number, the cell's rule for a field with no + // `returnType` (objectui#11683); the aggregation `function` decides nothing + // here, so `summaryOperations` is not read. + const text = + typeof value === 'number' && !isNaN(value) + ? formatNumberFieldValue(value, field, locale) + : String(safe); return ( - {displayValue} + {text} ); } diff --git a/packages/fields/src/widgets/__tests__/formula-summary-spec-reads-11070.test.tsx b/packages/fields/src/widgets/__tests__/formula-summary-spec-reads-11070.test.tsx index 3d3c7eae46..c7639866d3 100644 --- a/packages/fields/src/widgets/__tests__/formula-summary-spec-reads-11070.test.tsx +++ b/packages/fields/src/widgets/__tests__/formula-summary-spec-reads-11070.test.tsx @@ -19,10 +19,12 @@ * type formatting. The snake_case reads are retired at once, with no dual * read. * - * Each case below has a control that changes only the key under test, so a - * green reads "the widget consumed the spec key", never "the default happened - * to match". The last block pins the retirement: a snake_case spelling alone - * no longer formats anything. + * Each formula case below has a control that changes only the key under + * test, so a green reads "the widget consumed the spec key", never "the + * default happened to match". The summary widget no longer reads any roll-up + * key to format (objectui#11752), so its block pins that the function moves + * nothing. The last block pins the retirement: a snake_case spelling alone no + * longer formats anything. * * The annotated literals are judged by TypeScript's excess-property check * (`tsc -p tsconfig.test.json`), which refuses the retired members by name. @@ -61,20 +63,26 @@ describe('objectui#11070 — `FormulaField` formats by the spec `returnType`', ( }); }); -describe('objectui#11070 — `SummaryField` formats by `summaryOperations.function`', () => { +describe('`SummaryField` draws every `summaryOperations.function` through the number face', () => { + // objectui#11070 pinned a per-function format here (`count` as it arrived, + // the other four to two decimals). objectui#11752 retired it: the form face + // now reads a summary's value as its table cell does, so the function + // decides nothing and a JS number reads the number face, grouped, at its + // natural precision. `__tests__/summaryFaces.numberFace-11752.test.tsx` + // pins that rule per function, face and locale. const rollUp = (fn: 'count' | 'sum' | 'avg' | 'min' | 'max'): SummaryFieldMetadata => ({ type: 'summary', name: 'line_total', summaryOperations: { object: 'order_line', field: 'amount', function: fn }, }); - it.each(['sum', 'avg', 'min', 'max'] as const)('`function: %s` formats to two decimals', (fn) => { - expect(summaryText(rollUp(fn), 15750.5)).toBe('15750.50'); + it.each(['count', 'sum', 'avg', 'min', 'max'] as const)('`function: %s` reads the same text as a field with no roll-up', (fn) => { + expect(summaryText(rollUp(fn), 15750.5)).toBe('15,750.5'); + expect(summaryText(rollUp(fn), 15750.5)).toBe(summaryText({ type: 'summary', name: 'line_total' }, 15750.5)); }); - it('`function: count` prints the value as it arrives, as does a field with no roll-up', () => { + it('`function: count` reads a whole number whole', () => { expect(summaryText(rollUp('count'), 42)).toBe('42'); - expect(summaryText({ type: 'summary', name: 'line_total' }, 15750.5)).toBe('15750.5'); }); }); @@ -87,8 +95,11 @@ describe('objectui#11070 — the snake_case spellings are retired: alone, they f expect(formulaText(legacy, '1234.5')).toBe('1234.5'); }); + // Since objectui#11752 no roll-up key decides the summary face, so the + // control is a field with no roll-up at all: a `summary_type` beside it + // must not move the text. it('`summary_type` is not read', () => { const legacy = { type: 'summary', name: 'line_total', summary_type: 'sum' } as unknown as SummaryFieldMetadata; - expect(summaryText(legacy, 15750.5)).toBe('15750.5'); + expect(summaryText(legacy, 15750.5)).toBe(summaryText({ type: 'summary', name: 'line_total' }, 15750.5)); }); }); diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index 6e4ed7c047..ac3d076216 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -895,9 +895,14 @@ export interface SummaryFieldMetadata extends BaseFieldMetadata { * drift (objectui#11070): the child `object`, the child `field` to * aggregate, the aggregation `function` (`count` / `sum` / `min` / `max` / * `avg`), and optionally the child's `relationshipField` and a `filter` - * restricting which child rows are aggregated. `SummaryField` formats the - * value by `function`: `count` as it arrives, the other four to two - * decimals. + * restricting which child rows are aggregated. The backend computes the + * value from it; neither face of a summary reads it to format. Whatever the + * `function`, `SummaryField` in a form and the summary table cell draw the + * value by one rule, so one stored value reads the same in each + * (objectui#11752): a number formatted as a `number` field formats it (the + * viewer's display locale, at the width a `scale` on the field sets, at its + * natural precision with none, so a `count` reads whole), and anything else + * as text. * * It replaces four retired snake_case members, one per part: * `summary_object` (now `object`), `summary_field` (now `field`), diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index da4781025e..504dbcb208 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -1961,9 +1961,9 @@ export interface FormField { // ⛔ Not every key a widget reads off the carrier is declared here. No // widget reads a snake_case second spelling of a spec key any more: the // length readers read ONLY the spec's `minLength` / `maxLength` (below), - // the lookup and user widgets ONLY the spec's `reference`, and the formula - // and summary widgets ONLY the spec's `returnType` and `summaryOperations`. - // Their snake_case forms (`min_length`, `max_length`, `reference_to`, + // the lookup and user widgets ONLY the spec's `reference`, the formula + // widget ONLY the spec's `returnType`, and the summary widget no roll-up + // key at all (objectui#11752). Their snake_case forms (`min_length`, `max_length`, `reference_to`, // `return_type`, `summary_type`, …) are retired, read by nothing and // refused by the strict face (objectui#11070). The grid field's `columns` // is the spec's inline grid column list, by reference (below). @@ -2052,10 +2052,15 @@ export interface FormField { /** * The roll-up definition of a `summary` field: the child `object`, the * child `field` and the aggregation `function`, plus an optional - * `relationshipField` and `filter`. The `summary` widget formats the value - * by `function`: `count` as it arrives, the other four to two decimals. The - * retired snake_case `summary_object` / `summary_field` / `summary_type` - * are read by nothing and refused. + * `relationshipField` and `filter`. The backend computes the value from + * it; the `summary` widget does not read it to format. Whatever the + * `function`, the widget draws the value by the same rule as the summary + * table cell, so one stored value reads the same in a form and in a table + * (objectui#11752): a number formatted as a `number` field formats it (the + * viewer's display locale, at the width a `scale` on the field sets, at its + * natural precision with none, so a `count` reads whole), and anything else + * as text. The retired snake_case `summary_object` / `summary_field` / + * `summary_type` are read by nothing and refused. */ summaryOperations?: SpecField['summaryOperations']; /** From 21266b39d821c5860a6a74ec4309c2bfc50ff5b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:15:42 +0000 Subject: [PATCH 2/2] chore(fields): changeset for the summary faces, and keep the toFixed enumeration free of prose (objectui#11752) Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11752-summary-number-face.md | 16 ++++++++++++++++ packages/fields/src/widgets/SummaryField.tsx | 6 +++--- 2 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 .changeset/11752-summary-number-face.md diff --git a/.changeset/11752-summary-number-face.md b/.changeset/11752-summary-number-face.md new file mode 100644 index 0000000000..3d7537e0d0 --- /dev/null +++ b/.changeset/11752-summary-number-face.md @@ -0,0 +1,16 @@ +--- +'@object-ui/fields': patch +'@object-ui/types': patch +--- + +A summary (roll-up) field reads the same in a read-only form and in a table cell (objectui#11752). + +The read-only form face (`SummaryField`) formatted the value by the roll-up's aggregation function: a `count` as it arrived, a `sum` / `avg` / `min` / `max` at two fixed decimals. The table cell, which `summary` shares with `formula`, draws a number as the number cell does. So one stored value read two ways: a `sum` over `15750.5` read `15750.50` in the form and `15,750.5` in the cell, and a `count` of `12000` read `12000` and `12,000`. + +The form face now reads the value the way the cell does, whatever the function: + +- **number**: formatted as a number field formats it, in the viewer's display locale. With no `scale` on the field the value keeps its own precision, so `15750.5` reads `15,750.5` and a `count` of `12000` reads `12,000` in en-US and zh-CN, in both faces. A count stays whole. A `scale` on the field fixes the width in both faces (`scale: 2` reads `15,750.50`). Two fixed decimals are no longer added, so an average reads at its own precision unless the field declares a `scale`. +- **empty**: an empty string or empty list draws the empty-value mark, as in the cell, where the form drew a blank. A stored `0` (a `count` or `sum` over no child rows) reads `0`, where the form read `0.00`. +- **anything else**: the value as text, as the cell prints it. + +No export, prop or language-pack key is added. The `summaryOperations` doc comments in `@object-ui/types`, on `SummaryFieldMetadata` and on the form field, now describe this rule in place of the retired per-function face. diff --git a/packages/fields/src/widgets/SummaryField.tsx b/packages/fields/src/widgets/SummaryField.tsx index 598a6f12d7..f51273819f 100644 --- a/packages/fields/src/widgets/SummaryField.tsx +++ b/packages/fields/src/widgets/SummaryField.tsx @@ -16,9 +16,9 @@ import { coerceToSafeValue } from '../coerceToSafeValue.js'; * cell, `FormulaCellRenderer` in this package's barrel, which `summary` is * registered to. The two used to format the value each by itself, so one * stored value read two ways: this face picked a format by the roll-up's - * aggregation `function` (`count` as it arrived, the other four with - * `toFixed(2)`), so a `sum` over `15750.5` read `15750.50`, ungrouped and in - * every locale, beside the cell's `15,750.5`. + * aggregation `function` (`count` as it arrived, the other four at two fixed + * decimals), so a `sum` over `15750.5` read `15750.50`, ungrouped and in every + * locale, beside the cell's `15,750.5`. * * This face now reads the value the way the cell reads a summary's value, * whatever the function: