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
10 changes: 10 additions & 0 deletions .changeset/11683-formula-datetime-cell-text.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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}`);
});
});

Expand Down Expand Up @@ -270,7 +273,7 @@ describe('field.format now works for datetime, the way it already did for date',
'en-US',
<DateTimeCellRenderer value={INSTANT} field={undefined as any} />,
);
expect(container.textContent).toBe('7/4/20247:00 am');
expect(container.textContent).toBe('7/4/2024 7:00 am');
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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');
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
<LocalizationProvider value={{ locale }}>{node}</LocalizationProvider>
</I18nProvider>,
);
}

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) => (
<DateTimeCellRenderer
value={INSTANT}
field={{ type: 'datetime', name: 'created_at', ...(format === undefined ? {} : { format }) } as never}
/>
);

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<string, unknown> = {}) => (
<Formula value={value} field={{ type: 'formula', name: 'budget_remaining', ...extra } as never} />
);

/** What the `number` cell draws for the same value, in the same session. */
const numberFace = (locale: string, value: number, extra: Record<string, unknown> = {}) =>
textOf(locale, <NumberCellRenderer value={value} field={{ type: 'number', name: 'n', ...extra } as never} />);

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, <Summary value={8900} field={{ type: 'summary', name: 'total' } as never} />);
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();
});
});
49 changes: 44 additions & 5 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1257,13 +1260,22 @@ export function DateTimeCellRenderer({ value, field }: CellRendererProps): React
// identically.
// `null` is unreachable: the invalid/empty values it answers for already
// returned `<EmptyValue />` 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 (
<span className={cellClass}>
<span>{parts.date}</span>
<span className="ml-2 text-muted-foreground">{parts.time}</span>
<span>{parts.date}</span>{' '}
<span className="ml-1 text-muted-foreground">{parts.time}</span>
</span>
);
}
Expand Down Expand Up @@ -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 <EmptyValue />;

// 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 <NumberCellRenderer value={value} field={field} />;
}

return (
<span className="text-gray-700 font-mono text-sm">
{String(safe)}
Expand Down
Loading