Skip to content

Commit 7e2f7c0

Browse files
committed
fix(plugin-dashboard): read a field's format as a date pattern only on date/datetime fields
renderFieldValue's date branch fired on any format containing a Y/M/D/H/m/s letter, whatever the field type. A text field hinted format 'email' (a mailto hint the shared cell-renderer resolver honours) was painted as an em dash; 'money' on a number rendered a 1970 date; an autonumber pattern with date tokens and a time field's HH:mm rendered an em dash. Gate the branch on the field type (date/datetime) rather than excluding a hand-kept list of hint words: that list would be a second copy of the resolver's vocabulary and would still miss the autonumber and time cases. Date and datetime fields render exactly as before. The display-locale test's date fixture now declares type 'date', the type that puts a field on the date-format branch. Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8b1f066 commit 7e2f7c0

4 files changed

Lines changed: 201 additions & 2 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@object-ui/plugin-dashboard': patch
3+
---
4+
5+
fix(plugin-dashboard): a field's `format` is read as a date pattern only on a `date` / `datetime` field
6+
7+
The dashboard table (`object-data-table`) and the record-detail drawer render a
8+
value through `renderFieldValue`, whose date branch fired on any `format` string
9+
containing one of the letters Y, M, D, H, m or s, whatever the field's type. A
10+
text field hinted `format: 'email'` — which the shared cell-renderer resolver
11+
promotes to a `mailto:` link — contains an `m`, so its address went to
12+
`formatDate` and was painted as an em dash: the value disappeared. A text field
13+
hinted `format: 'money'` holding a number rendered as a 1970 date, an
14+
`autonumber` pattern with date tokens such as `ORD-{YYYYMMDD}-{00}` and a `time`
15+
field's `HH:mm` rendered as an em dash, all with no error.
16+
17+
The branch now runs only when the field's type is `date` or `datetime`. Those
18+
fields render exactly as before. A field of any other type no longer takes the
19+
date branch and falls through to the cell renderer its type and `format` hint
20+
select; the currency and percent branches ahead of it are unchanged. A column whose field
21+
type is unknown — no object-schema field and no `type` on the column — is no
22+
longer date-formatted from its `format` alone: declare `type: 'date'` on the
23+
column to keep the date face.

‎packages/plugin-dashboard/src/__tests__/dashboardFaces.displayLocale-9909.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ function session(locale: string, node: React.ReactNode): React.ReactElement {
4242
}
4343

4444
const CURRENCY: FieldMeta = { name: 'amount', label: 'Amount', format: '$0,0.00' };
45-
const DATE: FieldMeta = { name: 'closed_at', label: 'Closed', format: 'YYYY-MM-DD' };
45+
// `type: 'date'` is what puts this field on the date-format branch: a `format`
46+
// is read as a date pattern only on a date / datetime field (objectui#10220).
47+
const DATE: FieldMeta = { name: 'closed_at', label: 'Closed', type: 'date', format: 'YYYY-MM-DD' };
4648
/** 2020-03-04 — a past year, so the date face keeps its year in both locales. */
4749
const STORED = '2020-03-04T12:00:00.000Z';
4850

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#10220 — `renderFieldValue` reads a field's `format` as a date
11+
* pattern only when the field IS a date.
12+
*
13+
* ## The defect
14+
*
15+
* The date branch used to fire on any `format` string containing one of the
16+
* letters Y / M / D / H / m / s, whatever the field's type. One `format` key has
17+
* several readers with different vocabularies, and that letter test collided
18+
* with all of them:
19+
*
20+
* - `{ type: 'text', format: 'email' }` — a hint `resolveCellRendererType`
21+
* (`@object-ui/fields`) honours as a `mailto:` cell — contains `m`, so the
22+
* address went to `formatDate` and came back as an em dash: the value
23+
* vanished from the dashboard table and the record drawer.
24+
* - `format: 'money'` (the same resolver's currency hint) took the same
25+
* branch; a numeric value then PARSED as an epoch timestamp and rendered as
26+
* a 1970 date instead of money.
27+
* - an `autonumber` field's `format` is its record-number pattern, and the
28+
* date-token patterns the field docs teach (`ORD-{YYYYMMDD}-{00}`) hit the
29+
* same branch — not a renderer hint word at all, so excluding hint words
30+
* would not have reached it. Nor would it have reached a `time` field's
31+
* `HH:mm`: `formatDate` cannot parse a bare time, so that value vanished
32+
* too.
33+
*
34+
* ## Why the gate is the field TYPE, not a list of hint words
35+
*
36+
* A hint-word exclusion list would be a second copy of the resolver's
37+
* vocabulary, kept in step by hand — and the autonumber case above shows it
38+
* would still be incomplete. `@objectstack/spec` already describes `format` as
39+
* type-dependent (`FieldSchema.format`: the `date` and `datetime` cells read it
40+
* as a display style; plain-text fields read a small set of renderer-hint
41+
* words). So the branch now asks the question that description asks: is this
42+
* a `date` / `datetime` field? A field whose type is unknown is not a date
43+
* cell either — a column with no schema type declares one (`type: 'date'`) to
44+
* get the date face, which the last case below pins.
45+
*
46+
* ## Cases
47+
*
48+
* Every case renders through the real `renderFieldValue` and reads the painted
49+
* text, because the defect's symptom IS the painted text. The two date cases
50+
* are the positive controls: they must render the date face identically
51+
* before and after the change.
52+
*/
53+
54+
import * as React from 'react';
55+
import { describe, it, expect, afterEach } from 'vitest';
56+
import { render, cleanup } from '@testing-library/react';
57+
import { buildFieldMeta, renderFieldValue, type FieldMeta } from '../recordFields';
58+
59+
afterEach(() => cleanup());
60+
61+
const EM_DASH = '—';
62+
/** Midday UTC, a past year: `Mar 4, 2020` in en-US from any runner timezone. */
63+
const STORED_DATE = '2020-03-04T12:00:00.000Z';
64+
65+
function paint(value: unknown, meta: FieldMeta): HTMLElement {
66+
const { container } = render(<>{renderFieldValue(value, meta, undefined, 'en-US')}</>);
67+
return container;
68+
}
69+
70+
describe('renderFieldValue — a renderer hint is not a date pattern (objectui#10220)', () => {
71+
it("the card's probe: a text field hinted `format: 'email'` paints its address as a mailto link", () => {
72+
const meta = buildFieldMeta({
73+
accessorKey: 'email_addr',
74+
label: 'Email',
75+
def: { type: 'text', format: 'email' },
76+
});
77+
const el = paint('ada@example.com', meta);
78+
expect(el.textContent).not.toBe(EM_DASH);
79+
expect(el.textContent).toContain('ada@example.com');
80+
expect(el.querySelector('a[href="mailto:ada@example.com"]')).not.toBeNull();
81+
});
82+
83+
it("a text field hinted `format: 'money'` paints money, not an epoch date", () => {
84+
const meta = buildFieldMeta({
85+
accessorKey: 'deal_value',
86+
label: 'Deal value',
87+
def: { type: 'text', format: 'money', currency: 'USD' },
88+
});
89+
const el = paint(1234.5, meta);
90+
expect(el.textContent).toContain('1,234.50');
91+
expect(el.textContent).not.toMatch(/1970/);
92+
});
93+
94+
it("a type-less meta hinted `format: 'email'` is not a date cell either", () => {
95+
const el = paint('ada@example.com', { name: 'contact', label: 'Contact', format: 'email' });
96+
expect(el.querySelector('a[href="mailto:ada@example.com"]')).not.toBeNull();
97+
});
98+
99+
it('an autonumber pattern carrying date tokens paints the record number', () => {
100+
const meta = buildFieldMeta({
101+
accessorKey: 'order_no',
102+
label: 'Order #',
103+
def: { type: 'autonumber', format: 'ORD-{YYYYMMDD}-{00}' },
104+
});
105+
const el = paint('ORD-20240315-01', meta);
106+
expect(el.textContent).toBe('ORD-20240315-01');
107+
});
108+
109+
it("a time field's `format: 'HH:mm'` paints the stored time — `formatDate` cannot parse a bare time", () => {
110+
const meta = buildFieldMeta({
111+
accessorKey: 'opens_at',
112+
label: 'Opens at',
113+
def: { type: 'time', format: 'HH:mm' },
114+
});
115+
expect(paint('14:30', meta).textContent).toBe('14:30');
116+
});
117+
118+
it("CONTROL: a date field's `format: 'YYYY-MM-DD'` still takes the date face", () => {
119+
const meta = buildFieldMeta({
120+
accessorKey: 'close_date',
121+
label: 'Close date',
122+
def: { type: 'date', format: 'YYYY-MM-DD' },
123+
});
124+
expect(paint(STORED_DATE, meta).textContent).toBe('Mar 4, 2020');
125+
});
126+
127+
it("CONTROL: a datetime field's pattern still takes the same date face", () => {
128+
const meta = buildFieldMeta({
129+
accessorKey: 'closed_at',
130+
label: 'Closed at',
131+
def: { type: 'datetime', format: 'YYYY-MM-DD HH:mm' },
132+
});
133+
expect(paint(STORED_DATE, meta).textContent).toBe('Mar 4, 2020');
134+
});
135+
136+
it("a column with no schema field gets the date face by declaring `type: 'date'`", () => {
137+
const meta = buildFieldMeta({
138+
accessorKey: 'closed_on',
139+
label: 'Closed on',
140+
overrides: { type: 'date', format: 'YYYY-MM-DD' },
141+
});
142+
expect(paint(STORED_DATE, meta).textContent).toBe('Mar 4, 2020');
143+
});
144+
});

‎packages/plugin-dashboard/src/recordFields.tsx‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,28 @@ export const NUMERIC_FIELD_TYPES = new Set([
6565
'currency', 'money', 'number', 'integer', 'decimal', 'float', 'percent', 'percentage',
6666
]);
6767

68+
/**
69+
* Field types whose `format` {@link renderFieldValue} reads as a date display
70+
* pattern (objectui#10220).
71+
*
72+
* `format` has one key and several readers, each with its own vocabulary —
73+
* `@objectstack/spec` says so in `FieldSchema.format`'s own description: the
74+
* `date` / `datetime` cells read it as a display style, while on a plain-text
75+
* field the shared resolver (`resolveCellRendererType`, `@object-ui/fields`)
76+
* reads a small set of renderer-hint words. The date branch used to fire on any
77+
* `format` containing a Y / M / D / H / m / s letter, whatever the field's type,
78+
* so `format: 'email'` (it has an `m`) sent an address to `formatDate` and
79+
* painted an em dash. So did an `autonumber` record-number pattern carrying
80+
* date tokens and a `time` field's `HH:mm` — neither of them a hint word.
81+
*
82+
* ⛔ Do not replace this gate with a list of hint words to exclude: that is a
83+
* second copy of the resolver's vocabulary, kept in step by hand, and the
84+
* autonumber and time cases show it would still miss. A field whose type is
85+
* unknown is not a date cell either; a column with no schema type declares
86+
* `type: 'date'` to get the date face.
87+
*/
88+
const DATE_PATTERN_FIELD_TYPES = new Set(['date', 'datetime']);
89+
6890
/**
6991
* Relational keys copied from the OBJECT SCHEMA field def onto the built
7092
* {@link FieldMeta}, so a lookup / master_detail / tree cell can resolve the
@@ -427,7 +449,15 @@ export function renderFieldValue(
427449
// Deleting the branch fixes all three, because they were never three bugs.
428450
return formatPercent(value, decimals, displayLocale);
429451
}
430-
if (typeof fmt === 'string' && /[YMDHms]/.test(fmt)) {
452+
// A date pattern only on a date field (objectui#10220) — see
453+
// `DATE_PATTERN_FIELD_TYPES` for why the gate is the type. A date field's
454+
// style word with none of those letters (`relative`) still falls through to
455+
// its cell renderer below, as before.
456+
if (
457+
typeof fmt === 'string'
458+
&& DATE_PATTERN_FIELD_TYPES.has(fieldMeta.type as string)
459+
&& /[YMDHms]/.test(fmt)
460+
) {
431461
return formatDate(value, fmt, { locale: displayLocale });
432462
}
433463
const Renderer = getCellRenderer(resolveCellRendererType(fieldMeta as any));

0 commit comments

Comments
 (0)