diff --git a/.changeset/11683-formula-datetime-cell-text.md b/.changeset/11683-formula-datetime-cell-text.md new file mode 100644 index 0000000000..8b8695261e --- /dev/null +++ b/.changeset/11683-formula-datetime-cell-text.md @@ -0,0 +1,10 @@ +--- +'@object-ui/fields': patch +--- + +Two cells now draw the right text (objectui#11683). + +- **Formula cell (`FormulaCellRenderer`).** A numeric result is drawn by `NumberCellRenderer`, so it is grouped in the viewer's locale and set in the number cell's tabular figures, no longer printed raw in monospace: a formula with no `returnType` holding `200000` reads `200,000` in en-US and zh-CN. A result counts as numeric when the field declares `returnType: 'number'`, or when it declares no `returnType` and the value is a JS number. A declared `scale` sets the width, as on a number field. A string of digits from a formula with no `returnType` stays text, and any other declared `returnType` is drawn as before. `summary` fields use this renderer, so a numeric roll-up is formatted the same way. No type is inferred from the formula's expression or its inputs: the spec's `returnType` has no currency value, so a formula over currency fields reads as a plain number. +- **Datetime cell (`DateTimeCellRenderer`).** On the compact face, the default, the date and the time are separated by a space in the text, not by a margin alone. Copied text, screen readers and `textContent` get `10/6/2026 1:42 am` (en-US) and `2026/10/6 上午1:42` (zh-CN) where they got the two halves run together. The text is now exactly what `formatDateTime(value, { style: 'compact' })` returns. The time keeps its muted colour; its margin narrows from `ml-2` to `ml-1` beside the space. + +No export, prop or language-pack key is added. diff --git a/packages/fields/src/__tests__/datetime-carriers.impossibleDay-10301.test.tsx b/packages/fields/src/__tests__/datetime-carriers.impossibleDay-10301.test.tsx index be82e1a6ad..244c718893 100644 --- a/packages/fields/src/__tests__/datetime-carriers.impossibleDay-10301.test.tsx +++ b/packages/fields/src/__tests__/datetime-carriers.impossibleDay-10301.test.tsx @@ -118,13 +118,14 @@ describe('objectui#10301 — the `datetime` cell', () => { // This suite runs in UTC (`vitest.config.mts` pins it). const root = renderCell('datetime', REAL_DATETIME); expect(affordanceIn(root)).toBeNull(); - expect(root.textContent).toBe('2/28/202610:00 am'); + // The halves are joined by a space, the compact face's own joiner (objectui#11683). + expect(root.textContent).toBe('2/28/2026 10:00 am'); }); it('control: a real day written with an offset renders its instant, not the affordance', () => { const root = renderCell('datetime', REAL_DAY_WITH_OFFSET); expect(affordanceIn(root)).toBeNull(); const parts = formatDateTimeCompactParts(new Date(REAL_DAY_WITH_OFFSET), { locale: 'en' }); - expect(root.textContent).toBe(`${parts!.date}${parts!.time}`); + expect(root.textContent).toBe(`${parts!.date} ${parts!.time}`); }); }); diff --git a/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx b/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx index 4094ff94fe..5dcedce26e 100644 --- a/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx +++ b/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx @@ -215,9 +215,12 @@ describe('every existing datetime cell renders unchanged', () => { expect(spans[1].textContent).toBe(former.time); // The time half stays muted and offset — the two-tone face is the visual // half of "renders unchanged", and collapsing it to one string would be a - // visible change even with identical text. + // visible change even with identical text. Since objectui#11683 the offset + // is a text space plus `ml-1` (it was `ml-2` alone, so the text ran the + // halves together), and the cell's text is the joined compact face. expect(spans[1].className).toMatch(/text-muted-foreground/); - expect(spans[1].className).toMatch(/ml-2/); + expect(spans[1].className).toMatch(/\bml-1\b/); + expect(container.textContent).toBe(`${former.date} ${former.time}`); }); it('an authored empty format is still the compact face, not the verbose one', () => { @@ -229,7 +232,7 @@ describe('every existing datetime cell renders unchanged', () => { field={{ type: 'datetime', name: 'created_at', format: '' } as any} />, ); - expect(container.textContent).toBe(`${former.date}${former.time}`); + expect(container.textContent).toBe(`${former.date} ${former.time}`); }); }); @@ -270,7 +273,7 @@ describe('field.format now works for datetime, the way it already did for date', 'en-US', , ); - expect(container.textContent).toBe('7/4/20247:00 am'); + expect(container.textContent).toBe('7/4/2024 7:00 am'); }); }); diff --git a/packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx b/packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx index a70711ef3b..b3375362a4 100644 --- a/packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx +++ b/packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx @@ -116,10 +116,14 @@ const dateTimeCell = (value: string, format?: string) => ( /> ); -/** The compact face as the cell paints it — two spans, so no separating space. */ +/** + * The compact face as the cell paints it — two spans with a text space between + * them, the compact face's own joiner (objectui#11683; before it the gap was a + * margin alone, so the text ran the halves together). + */ function compactCellText(value: string, locale: string): string { const parts = formatDateTimeCompactParts(value, { locale })!; - return `${parts.date}${parts.time}`; + return `${parts.date} ${parts.time}`; } afterEach(() => { @@ -153,7 +157,7 @@ describe('THE REPRO — `format: relative` means the same thing in both cells (# // face is the one this cell already paints, so a measure tile and a grid // cell showing the same instant agree. expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).toBe(compactCellText(IN_WINDOW, 'en-US')); - expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).toBe('9/11/20269:30 am'); + expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).toBe('9/11/2026 9:30 am'); expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).not.toBe('Sep 11, 2026, 09:30 AM'); }); diff --git a/packages/fields/src/__tests__/formulaDatetimeCells.copiedText-11683.test.tsx b/packages/fields/src/__tests__/formulaDatetimeCells.copiedText-11683.test.tsx new file mode 100644 index 0000000000..7d2f7d615d --- /dev/null +++ b/packages/fields/src/__tests__/formulaDatetimeCells.copiedText-11683.test.tsx @@ -0,0 +1,181 @@ +/** + * 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#11683 — two cells whose TEXT was wrong while their pixels looked + * nearly right. + * + * The 2026-10-06 showcase dogfood (objectui#11672, items 10 and 16) read: + * + * - Projects' Budget Remaining, a `formula` with no `returnType`, rendered + * `200000`, raw and in monospace, beside a formatted currency column. + * - A `datetime` cell rendered `2026/10/6上午1:42`: the gap between the date + * and the time was an `ml-2` margin, not text, so a copy, a screen reader + * and `textContent` got the two halves run together. + * + * Every case renders through a real `I18nProvider` and sets the tag on the + * TENANT locale channel, the first one `useDisplayLocale()` reads, so the + * locale under test is the exact tag named here. + * + * `textContent` is the measured property throughout: it is the text a copy + * takes and a screen reader reads, which is what the card is about. + * + * en-US and zh-CN group thousands the same way, so a formatter that ignored + * the locale would pass both. de-DE rides beside them for that reason only. + */ +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 { + DateTimeCellRenderer, + NumberCellRenderer, + formatDateTime, + formatDateTimeCompactParts, + getCellRenderer, +} from '../index'; + +afterEach(() => cleanup()); + +const LOCALES = ['en-US', 'zh-CN'] as const; + +function renderSession(locale: string, node: React.ReactElement) { + return render( + + {node} + , + ); +} + +function textOf(locale: string, node: React.ReactElement): string { + const { container } = renderSession(locale, node); + const text = container.textContent ?? ''; + cleanup(); + return text; +} + +// ── the datetime cell ────────────────────────────────────────────────────── + +/** The card's instant: `2026/10/6上午1:42` in zh-CN (the suite runs in UTC). */ +const INSTANT = '2026-10-06T01:42:00.000Z'; + +const dateTimeCell = (format?: string) => ( + +); + +describe('the datetime cell carries a real separator in its text (objectui#11683)', () => { + const LITERAL: Record<(typeof LOCALES)[number], string> = { + 'en-US': '10/6/2026 1:42 am', + 'zh-CN': '2026/10/6 上午1:42', + }; + + it.each(LOCALES)('%s — the copied text is the shared compact face, separator included', (locale) => { + const text = textOf(locale, dateTimeCell()); + // By construction: the string every non-cell caller of the compact face gets. + expect(text).toBe(formatDateTime(INSTANT, { style: 'compact', locale })); + // And by literal, so a change that moved both sides together cannot pass. + expect(text).toBe(LITERAL[locale]); + }); + + it.each(LOCALES)('%s — the run-together text the card read is gone', (locale) => { + const parts = formatDateTimeCompactParts(INSTANT, { locale })!; + expect(textOf(locale, dateTimeCell())).not.toBe(`${parts.date}${parts.time}`); + }); + + it.each(LOCALES)('%s — `format: short` reaches the same face, separator included', (locale) => { + expect(textOf(locale, dateTimeCell('short'))).toBe(LITERAL[locale]); + }); + + it.each(LOCALES)('%s — the time half keeps its muted colour', (locale) => { + const { container } = renderSession(locale, dateTimeCell()); + const halves = container.querySelectorAll('span > span'); + expect(halves).toHaveLength(2); + const parts = formatDateTimeCompactParts(INSTANT, { locale })!; + expect(halves[0].textContent).toBe(parts.date); + expect(halves[1].textContent).toBe(parts.time); + expect(halves[1].className).toMatch(/text-muted-foreground/); + }); +}); + +// ── the formula cell ─────────────────────────────────────────────────────── + +const Formula = getCellRenderer('formula'); +const Summary = getCellRenderer('summary'); + +const formulaCell = (value: unknown, extra: Record = {}) => ( + +); + +/** What the `number` cell draws for the same value, in the same session. */ +const numberFace = (locale: string, value: number, extra: Record = {}) => + textOf(locale, ); + +describe('a formula with no returnType formats a numeric result as a number (objectui#11683)', () => { + const LITERAL: Record<(typeof LOCALES)[number], string> = { + 'en-US': '200,000', + 'zh-CN': '200,000', + }; + + it.each(LOCALES)('%s — Budget Remaining reads 200,000, the number face', (locale) => { + const text = textOf(locale, formulaCell(200000)); + expect(text).toBe(LITERAL[locale]); + expect(text).toBe(numberFace(locale, 200000)); + expect(text).not.toBe('200000'); + }); + + it.each(LOCALES)('%s — the number is not drawn in monospace', (locale) => { + const { container } = renderSession(locale, formulaCell(200000)); + expect(container.querySelector('.font-mono')).toBeNull(); + expect(container.querySelector('.tabular-nums')).not.toBeNull(); + }); + + it('the locale reaches the formatter: de-DE groups with a dot', () => { + expect(textOf('de-DE', formulaCell(200000))).toBe('200.000'); + }); + + it.each(LOCALES)('%s — a fraction keeps its natural precision, as on a number field', (locale) => { + expect(textOf(locale, formulaCell(1234.5))).toBe(numberFace(locale, 1234.5)); + expect(textOf('en-US', formulaCell(1234.5))).toBe('1,234.5'); + }); + + it.each(LOCALES)('%s — a summary roll-up, registered to the same renderer, is a number too', (locale) => { + const text = textOf(locale, ); + expect(text).toBe(numberFace(locale, 8900)); + expect(text).toBe('8,900'); + }); + + it.each(LOCALES)('%s — a string of digits stays text: only a JS number is read as a number', (locale) => { + const { container } = renderSession(locale, formulaCell('200000')); + expect(container.textContent).toBe('200000'); + expect(container.querySelector('.font-mono')).not.toBeNull(); + }); +}); + +describe('a declared returnType is read as declared (objectui#11683)', () => { + it.each(LOCALES)("%s — returnType 'number' is the number face", (locale) => { + expect(textOf(locale, formulaCell(200000, { returnType: 'number' }))).toBe('200,000'); + }); + + it.each(LOCALES)("%s — returnType 'number' reads a numeric string as a number", (locale) => { + expect(textOf(locale, formulaCell('200000', { returnType: 'number' }))).toBe('200,000'); + }); + + it.each(LOCALES)('%s — a declared scale is the width (the spec applies scale to a formula)', (locale) => { + expect(textOf(locale, formulaCell(200000, { scale: 2 }))).toBe(numberFace(locale, 200000, { scale: 2 })); + expect(textOf(locale, formulaCell(200000, { scale: 2 }))).toBe('200,000.00'); + }); + + it.each(LOCALES)("%s — returnType 'text' over a number stays text: the declaration wins", (locale) => { + const { container } = renderSession(locale, formulaCell(200000, { returnType: 'text' })); + expect(container.textContent).toBe('200000'); + expect(container.querySelector('.font-mono')).not.toBeNull(); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 727e6ab23e..883f72b7dd 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -393,7 +393,10 @@ export { coerceToSafeValue }; * and all three are legitimate: * * - **the floor exactly** — `SelectCellRenderer`, `LookupCellRenderer`, - * `TextCellRenderer`, `FormulaCellRenderer`, `ColorSwatchCellRenderer`, and + * `TextCellRenderer`, `FormulaCellRenderer` (a numeric result past its + * guard is then drawn by `NumberCellRenderer`, objectui#11683, so a + * `returnType: 'number'` formula holding whitespace answers this helper's + * extension below), `ColorSwatchCellRenderer`, and * since objectui#8678 `MaskedCellRenderer` (on the coerced text, as `text` * reads it), `VectorCellRenderer` and `GridCellRenderer`; * - **the floor EXTENDED** — this helper (+ whitespace, on the coerced text); @@ -1257,13 +1260,22 @@ export function DateTimeCellRenderer({ value, field }: CellRendererProps): React // identically. // `null` is unreachable: the invalid/empty values it answers for already // returned `` above. + // + // The gap between the halves is TEXT, one space (objectui#11683). It used to + // be an `ml-2` margin alone, so the cell's text was the two halves run + // together: `2026/10/6上午1:42` in zh-CN, `10/6/20261:42 am` in en-US. That is + // what a copy, a screen reader and `textContent` got. The space is the joiner + // `formatDateTime`'s compact face puts between the same two halves, so the + // cell's text is now that string exactly. The time half keeps its muted + // colour and a narrower `ml-1` margin, which with the space keeps the halves + // visibly apart. if (style === 'compact') { const parts = formatDateTimeCompactParts(date, { locale }); if (parts) { return ( - {parts.date} - {parts.time} + {parts.date}{' '} + {parts.time} ); } @@ -2644,13 +2656,40 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R } /** - * Formula field cell renderer (read-only) + * Formula field cell renderer (read-only). `summary` is registered to it too. */ -export function FormulaCellRenderer({ value }: CellRendererProps): React.ReactElement { +export function FormulaCellRenderer({ value, field }: CellRendererProps): React.ReactElement { const safe = coerceToSafeValue(value); // THE FLOOR by name and nothing more (objectui#8496), on the coerced text — // same relation as `TextCellRenderer`, which this renderer's output mirrors. if (isEmptyValue(safe)) return ; + + // A NUMERIC result is drawn by the number cell (objectui#11683). This + // renderer printed every result as raw monospace text, so the showcase's + // Budget Remaining (a formula declaring no `returnType`) read `200000` beside + // a formatted currency column. A number now goes through `NumberCellRenderer`, + // whose `formatNumberFieldValue` is the one call every number face makes: the + // locale's grouping, and the width `resolveFieldScale` answers for this field. + // The spec applies `scale` to a `formula` field, so a declared one is honoured. + // + // Which results are numbers: + // - `returnType: 'number'`: the spec's declared value type, read as declared. + // - no `returnType`: the value's own JSON type. Only a JS number counts; a + // string of digits from an undeclared formula stays text, because nothing + // says it is a quantity (a postcode built by concatenation is not one). + // - any other `returnType`: drawn as before. A declaration is honoured, + // never overridden by the value. + // + // ⛔ No type is inferred from the expression or from its inputs. The spec's + // own field form says of `returnType` that consumers read it "instead of + // re-parsing the expression", and its four values (`number` / `text` / + // `boolean` / `date`) carry no currency, so a formula over two currency + // fields renders as a number here, not as money. + const returnType = field && 'returnType' in field ? field.returnType : undefined; + if (returnType === 'number' || (returnType === undefined && typeof value === 'number')) { + return ; + } + return ( {String(safe)}