@object-ui/fields 17.3.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +941 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +7 -1
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +19 -1
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +48 -12
  9. package/dist/index.js +5026 -4933
  10. package/dist/widgets/AddressField.d.ts +3 -11
  11. package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
  12. package/dist/widgets/GridField.d.ts +12 -2
  13. package/dist/widgets/OptionsEmptyState.d.ts +15 -1
  14. package/dist/widgets/RecordPickerDialog.d.ts +32 -6
  15. package/dist/widgets/SelectField.d.ts +25 -5
  16. package/dist/widgets/address-format.d.ts +68 -0
  17. package/dist/widgets/file-size-guard.d.ts +41 -0
  18. package/dist/widgets/nativeDateValue.d.ts +37 -0
  19. package/dist/widgets/toDomProps.d.ts +30 -10
  20. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  21. package/dist/widgets/useRecordQuery.d.ts +7 -1
  22. package/package.json +17 -13
  23. package/src/FieldEditWidget.tsx +17 -1
  24. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  25. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  26. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  27. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  28. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  29. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  30. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  31. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  32. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  33. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  34. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  35. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  36. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  37. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  38. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  39. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  40. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  41. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  42. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  43. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  44. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  45. package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
  46. package/src/complex-widgets.test.tsx +22 -6
  47. package/src/currency.ts +130 -0
  48. package/src/datetime-widgets.test.tsx +94 -2
  49. package/src/field-type-alias.multiple.test.ts +81 -0
  50. package/src/field-type-alias.ts +58 -2
  51. package/src/index.css +44 -0
  52. package/src/index.tsx +489 -247
  53. package/src/standard-widgets.test.tsx +8 -1
  54. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  55. package/src/widgets/AddressField.tsx +79 -29
  56. package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
  57. package/src/widgets/BooleanField.tsx +41 -7
  58. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  59. package/src/widgets/CheckboxesField.tsx +35 -2
  60. package/src/widgets/CurrencyField.tsx +55 -15
  61. package/src/widgets/DateField.tsx +11 -2
  62. package/src/widgets/DateTimeField.tsx +12 -3
  63. package/src/widgets/FileField.tsx +27 -9
  64. package/src/widgets/FormulaField.tsx +6 -1
  65. package/src/widgets/FullscreenFieldEditor.tsx +47 -258
  66. package/src/widgets/GeolocationField.tsx +29 -3
  67. package/src/widgets/GridField.test.tsx +226 -0
  68. package/src/widgets/GridField.tsx +114 -10
  69. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  70. package/src/widgets/ImageField.tsx +54 -4
  71. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  72. package/src/widgets/LookupField.tsx +8 -4
  73. package/src/widgets/MultiSelectField.tsx +56 -2
  74. package/src/widgets/OptionsEmptyState.tsx +16 -0
  75. package/src/widgets/RadioField.tsx +35 -3
  76. package/src/widgets/RatingField.tsx +15 -2
  77. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  78. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  79. package/src/widgets/RecordPickerDialog.tsx +64 -15
  80. package/src/widgets/RichTextField.tsx +1 -1
  81. package/src/widgets/SelectField.tsx +36 -5
  82. package/src/widgets/UserField.tsx +3 -2
  83. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  84. package/src/widgets/address-format.ts +120 -0
  85. package/src/widgets/file-size-guard.ts +55 -0
  86. package/src/widgets/nativeDateValue.test.ts +115 -0
  87. package/src/widgets/nativeDateValue.ts +112 -0
  88. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  89. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  90. package/src/widgets/toDomProps.ts +48 -19
  91. package/src/widgets/toHostGroupProps.ts +102 -0
  92. package/src/widgets/useFieldTranslation.ts +15 -33
  93. package/src/widgets/useRecordQuery.ts +30 -3
  94. package/dist/FieldEditWidget.test.d.ts +0 -8
  95. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  96. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  97. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  98. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  99. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  100. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  101. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  102. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  103. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  104. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  105. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  106. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  107. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  108. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  109. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  110. package/dist/coerce-safe-value.test.d.ts +0 -1
  111. package/dist/complex-widgets.test.d.ts +0 -1
  112. package/dist/datetime-widgets.test.d.ts +0 -1
  113. package/dist/field-type-coverage.test.d.ts +0 -1
  114. package/dist/standard-widgets.test.d.ts +0 -0
  115. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  116. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  117. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  118. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  119. package/dist/widgets/GridField.test.d.ts +0 -1
  120. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  121. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  122. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  123. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  124. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  125. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  126. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  127. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  128. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  129. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  130. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  131. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  132. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  133. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  134. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  135. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  136. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  137. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  138. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  139. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  140. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  141. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  142. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  143. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  144. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  145. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  146. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  147. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  148. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  149. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  150. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  151. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  152. package/dist/widgets/file-value.test.d.ts +0 -1
  153. package/dist/widgets/personDisplay.test.d.ts +0 -8
  154. package/dist/widgets/recentLookups.test.d.ts +0 -1
  155. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  156. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
@@ -0,0 +1,305 @@
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#4468 — every date branch threads the ACTIVE locale.
11
+ *
12
+ * ── The measured split ───────────────────────────────────────────────────
13
+ * On a `zh` console the same task row rendered `逾期 6 天` in one column and
14
+ * `In 3 days` in the next, and the datetime column read `8/11/2026 12:00 am`.
15
+ * Not a missing translation: the field labels around it were fully Chinese.
16
+ *
17
+ * The cause is that the date renderers had TWO locale channels and only one
18
+ * of them followed the user's language:
19
+ *
20
+ * - the OVERDUE phrase resolves through `useFieldTranslate()` → the ACTIVE
21
+ * UI language → `逾期 6 天`;
22
+ * - every `Intl` branch (relative past, relative future, the >±7d absolute
23
+ * fallback) took its tag from `useLocalization().locale` — the TENANT's
24
+ * regional default (ADR-0053), which is `undefined` on any workspace that
25
+ * has not configured one. `Intl` with `undefined` reads the MACHINE's
26
+ * locale, so those branches rendered en-US next to the Chinese one;
27
+ * - `DateTimeCellRenderer` passed no tag at all, hence `8/11/2026 12:00 am`.
28
+ *
29
+ * The one resolver is `useDisplayLocale()` (tenant locale → active UI
30
+ * language → `'en'`), which `@object-ui/i18n` already owns and whose own
31
+ * docstring claimed `DateCellRenderer` "already formats from this channel" —
32
+ * it did not. `'zh'` is a valid BCP-47 tag, so there is no `'zh'`→`zh-CN`
33
+ * mapping anywhere: `useDisplayLocale()` IS the single mapping point.
34
+ *
35
+ * ── Directions ───────────────────────────────────────────────────────────
36
+ * Reverting the renderers to `origin/main` and keeping this file turns every
37
+ * `zh session` case RED and leaves every `en session` case GREEN. The `en`
38
+ * cases are green on BOTH sides on purpose — that is the must-not-change
39
+ * half: the English output has to be byte-identical after the change, since
40
+ * `'en'` and the CI machine's `en-US` agree on all of these forms.
41
+ *
42
+ * `已逾期`/`Overdue Nd` is likewise green on both sides: it never used the
43
+ * broken channel, and this file pins that the fix did not disturb it.
44
+ */
45
+
46
+ import { describe, it, expect, afterEach } from 'vitest';
47
+ import React from 'react';
48
+ import { render, screen, cleanup } from '@testing-library/react';
49
+ import '@testing-library/jest-dom';
50
+ import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
51
+ import { DateCellRenderer, DateTimeCellRenderer } from '../index';
52
+ import { DateField } from '../widgets/DateField';
53
+ import { DateTimeField } from '../widgets/DateTimeField';
54
+ import { FormulaField } from '../widgets/FormulaField';
55
+ import { GridField } from '../widgets/GridField';
56
+
57
+ /**
58
+ * `n` days from today, pinned to local noon so a run that starts near
59
+ * midnight can never land the value on the neighbouring day.
60
+ */
61
+ function daysFromNow(n: number): string {
62
+ const d = new Date();
63
+ d.setDate(d.getDate() + n);
64
+ d.setHours(12, 0, 0, 0);
65
+ return d.toISOString();
66
+ }
67
+
68
+ /**
69
+ * A fixed instant, built from LOCAL parts so the rendered day/time is the
70
+ * same in every timezone the suite might run in. This is the exact value
71
+ * shape the card captured: `8/11/2026 12:00 am` on an en machine.
72
+ */
73
+ const FIXED_INSTANT = new Date(2026, 7, 11, 0, 0, 0).toISOString();
74
+
75
+ /**
76
+ * A session: the UI language the user picked, plus the tenant's regional
77
+ * default (usually absent — the state the card was measured in).
78
+ *
79
+ * `persistLanguage={false}` keeps each case on its own language instead of
80
+ * inheriting whatever the previous one wrote to `localStorage`.
81
+ */
82
+ function renderSession(
83
+ language: string,
84
+ node: React.ReactNode,
85
+ tenantLocale?: string,
86
+ ) {
87
+ return render(
88
+ <I18nProvider
89
+ config={{ defaultLanguage: language, detectBrowserLanguage: false }}
90
+ persistLanguage={false}
91
+ >
92
+ <LocalizationProvider value={{ locale: tenantLocale }}>{node}</LocalizationProvider>
93
+ </I18nProvider>,
94
+ );
95
+ }
96
+
97
+ const dateField = (name: string) => ({ type: 'date', name }) as any;
98
+
99
+ afterEach(() => cleanup());
100
+
101
+ describe('zh session — every date branch renders Chinese (objectui#4468)', () => {
102
+ it('future-relative: the branch the card caught as "In 3 days"', () => {
103
+ renderSession('zh', <DateCellRenderer value={daysFromNow(3)} field={dateField('end_date')} />);
104
+ expect(screen.getByText('3天后')).toBeInTheDocument();
105
+ expect(screen.queryByText('In 3 days')).not.toBeInTheDocument();
106
+ });
107
+
108
+ it('past-relative, non-due: the branch the card caught as "6 days ago"', () => {
109
+ renderSession('zh', <DateCellRenderer value={daysFromNow(-6)} field={dateField('start_date')} />);
110
+ expect(screen.getByText('6天前')).toBeInTheDocument();
111
+ expect(screen.queryByText('6 days ago')).not.toBeInTheDocument();
112
+ });
113
+
114
+ it('near-today: today / tomorrow / yesterday', () => {
115
+ renderSession('zh', <DateCellRenderer value={daysFromNow(0)} field={dateField('start_date')} />);
116
+ expect(screen.getByText('今天')).toBeInTheDocument();
117
+ cleanup();
118
+
119
+ renderSession('zh', <DateCellRenderer value={daysFromNow(1)} field={dateField('start_date')} />);
120
+ expect(screen.getByText('明天')).toBeInTheDocument();
121
+ cleanup();
122
+
123
+ renderSession('zh', <DateCellRenderer value={daysFromNow(-1)} field={dateField('start_date')} />);
124
+ expect(screen.getByText('昨天')).toBeInTheDocument();
125
+ });
126
+
127
+ it('absolute fallback beyond the ±7-day window', () => {
128
+ const value = daysFromNow(30);
129
+ // The formatter drops the year when it is the current one, so the
130
+ // expectation is built the same way rather than hardcoding a month that
131
+ // depends on the day this suite runs.
132
+ const d = new Date(value);
133
+ const sameYear = d.getFullYear() === new Date().getFullYear();
134
+ const expected = d.toLocaleDateString('zh', {
135
+ year: sameYear ? undefined : 'numeric',
136
+ month: 'short',
137
+ day: 'numeric',
138
+ });
139
+ const enForm = d.toLocaleDateString('en', {
140
+ year: sameYear ? undefined : 'numeric',
141
+ month: 'short',
142
+ day: 'numeric',
143
+ });
144
+
145
+ renderSession('zh', <DateCellRenderer value={value} field={dateField('start_date')} />);
146
+ expect(screen.getByText(expected)).toBeInTheDocument();
147
+ expect(screen.queryByText(enForm)).not.toBeInTheDocument();
148
+ });
149
+
150
+ it('absolute datetime: the branch the card caught as "8/11/2026 12:00 am"', () => {
151
+ const { container } = renderSession(
152
+ 'zh',
153
+ <DateTimeCellRenderer value={FIXED_INSTANT} field={{ type: 'datetime', name: 'created_at' } as any} />,
154
+ );
155
+ expect(container.textContent).toContain('2026/8/11');
156
+ expect(container.textContent).toContain('上午12:00');
157
+ expect(container.textContent).not.toContain('8/11/2026');
158
+ expect(container.textContent).not.toContain('12:00 am');
159
+ });
160
+
161
+ it('read-only DateField / DateTimeField widgets', () => {
162
+ const { container } = renderSession(
163
+ 'zh',
164
+ <DateField value={FIXED_INSTANT} onChange={() => {}} field={dateField('start_date')} readonly />,
165
+ );
166
+ expect(container.textContent).toContain('2026/8/11');
167
+ cleanup();
168
+
169
+ const dt = renderSession(
170
+ 'zh',
171
+ <DateTimeField
172
+ value={FIXED_INSTANT}
173
+ onChange={() => {}}
174
+ field={{ type: 'datetime', name: 'created_at' } as any}
175
+ readonly
176
+ />,
177
+ );
178
+ expect(dt.container.textContent).toContain('2026/8/11');
179
+ });
180
+
181
+ /**
182
+ * The sub-grid (`GridField`) read-only face. Its cases live here rather than
183
+ * beside the rest of `widgets/GridField.test.tsx`: mounting an `I18nProvider`
184
+ * in that file changes what its later PROVIDER-LESS renders resolve, and one
185
+ * of them asserts a translated `aria-label`.
186
+ */
187
+ it('the sub-grid read-only table', () => {
188
+ const temporalField = {
189
+ columns: [
190
+ { field: 'merchant', label: 'Merchant', type: 'text' as const },
191
+ { field: 'incurred_on', label: 'Incurred On', type: 'date' as const },
192
+ ],
193
+ } as any;
194
+ renderSession(
195
+ 'zh',
196
+ <GridField
197
+ value={[{ merchant: 'Chipotle', incurred_on: '2026-06-17T00:00:00.000Z' }]}
198
+ onChange={() => {}}
199
+ field={temporalField}
200
+ readonly
201
+ />,
202
+ );
203
+ const table = screen.getByTestId('line-items-readonly');
204
+ expect(table.textContent).toContain('2026/6/17');
205
+ expect(table.textContent).not.toContain('6/17/2026');
206
+ });
207
+
208
+ it('a formula field returning a date', () => {
209
+ const { container } = renderSession(
210
+ 'zh',
211
+ <FormulaField
212
+ value={FIXED_INSTANT}
213
+ onChange={() => {}}
214
+ field={{ type: 'formula', name: 'computed_on', return_type: 'date' } as any}
215
+ />,
216
+ );
217
+ expect(container.textContent).toContain('2026/8/11');
218
+ expect(container.textContent).not.toContain('8/11/2026');
219
+ });
220
+ });
221
+
222
+ describe('en session — output is byte-identical (must-not-change)', () => {
223
+ it('future-relative', () => {
224
+ renderSession('en', <DateCellRenderer value={daysFromNow(3)} field={dateField('end_date')} />);
225
+ expect(screen.getByText('In 3 days')).toBeInTheDocument();
226
+ });
227
+
228
+ it('past-relative, non-due', () => {
229
+ renderSession('en', <DateCellRenderer value={daysFromNow(-6)} field={dateField('start_date')} />);
230
+ expect(screen.getByText('6 days ago')).toBeInTheDocument();
231
+ });
232
+
233
+ it('near-today', () => {
234
+ renderSession('en', <DateCellRenderer value={daysFromNow(0)} field={dateField('start_date')} />);
235
+ expect(screen.getByText('Today')).toBeInTheDocument();
236
+ cleanup();
237
+
238
+ renderSession('en', <DateCellRenderer value={daysFromNow(1)} field={dateField('start_date')} />);
239
+ expect(screen.getByText('Tomorrow')).toBeInTheDocument();
240
+ cleanup();
241
+
242
+ renderSession('en', <DateCellRenderer value={daysFromNow(-1)} field={dateField('start_date')} />);
243
+ expect(screen.getByText('Yesterday')).toBeInTheDocument();
244
+ });
245
+
246
+ it('absolute datetime', () => {
247
+ const { container } = renderSession(
248
+ 'en',
249
+ <DateTimeCellRenderer value={FIXED_INSTANT} field={{ type: 'datetime', name: 'created_at' } as any} />,
250
+ );
251
+ expect(container.textContent).toContain('8/11/2026');
252
+ expect(container.textContent).toContain('12:00 am');
253
+ });
254
+
255
+ it('read-only DateField', () => {
256
+ const { container } = renderSession(
257
+ 'en',
258
+ <DateField value={FIXED_INSTANT} onChange={() => {}} field={dateField('start_date')} readonly />,
259
+ );
260
+ expect(container.textContent).toContain('8/11/2026');
261
+ });
262
+ });
263
+
264
+ describe('the already-localized overdue branch is undisturbed (green both sides)', () => {
265
+ it('resolves through the translate fn, not through Intl — zh', () => {
266
+ renderSession('zh', <DateCellRenderer value={daysFromNow(-6)} field={dateField('due_date')} />);
267
+ const el = screen.getByText('逾期 6 天');
268
+ expect(el).toBeInTheDocument();
269
+ expect(el.className).toMatch(/text-red-600/);
270
+ });
271
+
272
+ it('resolves through the translate fn, not through Intl — en', () => {
273
+ renderSession('en', <DateCellRenderer value={daysFromNow(-6)} field={dateField('due_date')} />);
274
+ expect(screen.getByText('Overdue 6d')).toBeInTheDocument();
275
+ });
276
+ });
277
+
278
+ describe('channel precedence is unchanged (green both sides)', () => {
279
+ /**
280
+ * `useDisplayLocale()` puts the TENANT locale above the UI language: an org
281
+ * that configured `en` means it, even for a user reading Chinese chrome.
282
+ * This is the one case the old code got right, and it must survive.
283
+ */
284
+ it('an explicit tenant locale outranks the active UI language', () => {
285
+ renderSession('zh', <DateCellRenderer value={daysFromNow(3)} field={dateField('end_date')} />, 'en');
286
+ expect(screen.getByText('In 3 days')).toBeInTheDocument();
287
+ expect(screen.queryByText('3天后')).not.toBeInTheDocument();
288
+ });
289
+
290
+ /**
291
+ * The third step of the precedence — the provider-less `'en'` last resort —
292
+ * is pinned in `DateCellRenderer.test.tsx`, deliberately NOT here.
293
+ *
294
+ * It cannot be measured in this file: `useObjectTranslation()` outside a
295
+ * provider reports `i18n.language` from react-i18next's GLOBAL instance, and
296
+ * every `I18nProvider` mounted above leaves that global on the language it
297
+ * was given. So a provider-less render placed after these cases resolves
298
+ * `'zh'` — which is the state react-i18next is in, not the fallback under
299
+ * test. Written here it would assert a fact about test ordering.
300
+ *
301
+ * `DateCellRenderer.test.tsx` mounts no provider at all, so its
302
+ * `6 days ago` / `Overdue 6d` / `Tomorrow` cases ARE that pin, and they hold
303
+ * for the real reason.
304
+ */
305
+ });
@@ -0,0 +1,158 @@
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#4332 — `formatCurrency` dropped a real cents digit.
11
+ *
12
+ * The symbol branch passed `minimumFractionDigits: 0` against a
13
+ * wholeness-switched `maximumFractionDigits`, handing Intl the RANGE `[0, 2]`.
14
+ * Intl then emits the shortest representation in range, so a genuine cents
15
+ * value of `.50` printed as `.5`: `$1,234.5`, `$19.9`, `$0.5` — money on a
16
+ * record page reading as a data error rather than a formatting one.
17
+ *
18
+ * The fix is one width for both bounds (`isWhole ? 0 : 2`), which also makes
19
+ * the function's three branches agree: the no-currency branch already routed
20
+ * through `formatNumber` (both bounds = the width), and the bad-currency
21
+ * fallback already used `toFixed`. Only the symbol branch disagreed.
22
+ *
23
+ * What is pinned here, and why each half matters:
24
+ *
25
+ * 1. **Fractional amounts keep both cents digits** — the defect.
26
+ * 2. **Whole amounts still drop `.00`** — the OTHER half of the wholeness
27
+ * switch, which the doc comment promises just as explicitly ("Salesforce
28
+ * convention: `$1,234.50` keeps cents; `$1,234` does not") and which
29
+ * objectui#4033 independently pinned through the renderer. Widening the
30
+ * minimum to a constant 2 would have "fixed" this card by breaking that
31
+ * one, so it is a control, not a decoration.
32
+ * 3. **The branches that were already correct are unchanged** — no-currency,
33
+ * bad-currency fallback, and `formatCompactCurrency` (a different function
34
+ * with its own `maximumFractionDigits: 1` compact policy).
35
+ */
36
+
37
+ import { describe, it, expect } from 'vitest';
38
+ import React from 'react';
39
+ import { render, screen } from '@testing-library/react';
40
+ import '@testing-library/jest-dom';
41
+ import { LocalizationProvider } from '@object-ui/i18n';
42
+ import {
43
+ formatCurrency,
44
+ formatCompactCurrency,
45
+ formatNumber,
46
+ CurrencyCellRenderer,
47
+ } from '../index';
48
+
49
+ describe('formatCurrency — a fractional amount keeps both cents digits (objectui#4332)', () => {
50
+ it("renders the card's fixture as the doc comment promises", () => {
51
+ expect(formatCurrency(1234.5, 'USD', 'en-US')).toBe('$1,234.50');
52
+ });
53
+
54
+ it.each([
55
+ [19.9, '$19.90'],
56
+ [0.5, '$0.50'],
57
+ [1234.5, '$1,234.50'],
58
+ ])('an amount ending in a zero cent digit keeps it: %s', (value, expected) => {
59
+ // The issue's impact list — every price whose cents end in 0 was truncated
60
+ // by one digit (`$19.9`, `$0.5`).
61
+ expect(formatCurrency(value as number, 'USD', 'en-US')).toBe(expected as string);
62
+ });
63
+
64
+ it('keeps the sign in front of a truncated-then-restored amount', () => {
65
+ expect(formatCurrency(-1234.5, 'USD', 'en-US')).toBe('-$1,234.50');
66
+ });
67
+
68
+ it('a fractional amount that ROUNDS to zero cents still shows them', () => {
69
+ // Sub-cent precision: `1234.001` is not whole, so it keeps cents and
70
+ // rounds into them. Before the fix the `[0, 2]` range rounded to `.00` and
71
+ // then trimmed it away, printing a bare `$1,234` for a value that is NOT
72
+ // the whole number that spelling claims. Deliberate: wholeness — not the
73
+ // rounded output — decides whether cents are shown.
74
+ expect(formatCurrency(1234.001, 'USD', 'en-US')).toBe('$1,234.00');
75
+ });
76
+
77
+ it('still caps at two digits (the maximum is unchanged)', () => {
78
+ expect(formatCurrency(1234.567, 'USD', 'en-US')).toBe('$1,234.57');
79
+ expect(formatCurrency(1234.56, 'USD', 'en-US')).toBe('$1,234.56');
80
+ });
81
+
82
+ it('follows the active locale while doing it (objectui#4033 is not disturbed)', () => {
83
+ // de-DE: dot grouping, comma decimal mark, TRAILING symbol separated by
84
+ // U+00A0. Matched loosely on the separator (written as an escape, never a
85
+ // raw byte) so an ICU spacing change cannot fail the digits this pins.
86
+ expect(formatCurrency(1234.5, 'EUR', 'de-DE')).toMatch(/^1\.234,50\s*€$/);
87
+ });
88
+ });
89
+
90
+ describe('formatCurrency — CONTROL: the whole-number half of the switch is untouched', () => {
91
+ it.each([
92
+ [1234, '$1,234'],
93
+ [5000000, '$5,000,000'],
94
+ [0, '$0'],
95
+ ])('a whole amount still drops `.00`: %s', (value, expected) => {
96
+ // The doc comment promises this as explicitly as it promises the cents,
97
+ // and objectui#4033 pinned it through `CurrencyCellRenderer`. A constant
98
+ // `minimumFractionDigits: 2` would turn all three of these into `.00`.
99
+ expect(formatCurrency(value as number, 'USD', 'en-US')).toBe(expected as string);
100
+ });
101
+
102
+ it('a non-finite amount is unaffected (it is not "whole")', () => {
103
+ expect(formatCurrency(Number.NaN, 'USD', 'en-US')).toBe('$NaN');
104
+ });
105
+ });
106
+
107
+ describe('formatCurrency — CONTROL: the branches that were already correct', () => {
108
+ it('the no-currency branch is unchanged — it never had the defect', () => {
109
+ // It routes through `formatNumber`, which sets BOTH bounds to the width it
110
+ // is given, so it already rendered `1,234.50` while the symbol branch
111
+ // rendered `$1,234.5`. That disagreement is what the fix removes.
112
+ expect(formatCurrency(1234.5, undefined, 'en-US')).toBe('1,234.50');
113
+ expect(formatCurrency(1234.5, undefined, 'en-US')).toBe(formatNumber(1234.5, 2, 'en-US'));
114
+ // A whole amount with no currency is still money and keeps its separators
115
+ // (objectui#4033: `formatNumber` passes no `scale`, so the ordinal
116
+ // no-grouping policy cannot fire here).
117
+ expect(formatCurrency(5000000, undefined, 'en-US')).toBe('5,000,000');
118
+ });
119
+
120
+ it('the bad-currency fallback is unchanged — it always used toFixed', () => {
121
+ // `Intl` throws `RangeError: Invalid currency code` for a non-ISO-4217
122
+ // code, and `formatDisplayNumber`'s locale retry re-throws it rather than
123
+ // swallowing it, so this lands in `formatCurrency`'s own catch.
124
+ expect(formatCurrency(1234.5, 'NOT_A_CODE', 'en-US')).toBe('NOT_A_CODE 1234.50');
125
+ });
126
+
127
+ it('formatCompactCurrency is a different function and keeps its own policy', () => {
128
+ // Compact notation caps at ONE fraction digit and strips a trailing `.0`;
129
+ // it never had the `[0, max]` range, and this card does not touch it.
130
+ expect(formatCompactCurrency(1234.5, 'USD', 'en-US')).toBe('$1.2K');
131
+ expect(formatCompactCurrency(150000, 'USD', 'en-US')).toBe('$150K');
132
+ });
133
+ });
134
+
135
+ describe('CurrencyCellRenderer — the user-visible surface (objectui#4332)', () => {
136
+ const renderCurrency = (value: unknown, field: Record<string, unknown>, locale?: string) =>
137
+ render(
138
+ <LocalizationProvider value={{ locale }}>
139
+ <CurrencyCellRenderer value={value as any} field={{ type: 'currency', ...field } as any} />
140
+ </LocalizationProvider>,
141
+ );
142
+
143
+ it('a grid cell shows the cents digit the amount actually has', () => {
144
+ renderCurrency(1234.5, { currency: 'USD' }, 'en-US');
145
+ expect(screen.getByText('$1,234.50')).toBeInTheDocument();
146
+ });
147
+
148
+ it('CONTROL: a declared `scale` is inert on the currency cell path', () => {
149
+ // Measured, not assumed: `CurrencyCellRenderer` reads `currency` and never
150
+ // `scale`, and `formatCurrency` takes no scale argument — on this path
151
+ // `scale` is neither a display width nor (money always groups) a grouping
152
+ // input. So a currency field declaring `scale: 0` renders exactly like one
153
+ // declaring nothing, before and after this card. Pinned so that a future
154
+ // change making `scale` load-bearing here has to say so out loud.
155
+ renderCurrency(1234.5, { currency: 'USD', scale: 0 }, 'en-US');
156
+ expect(screen.getByText('$1,234.50')).toBeInTheDocument();
157
+ });
158
+ });