@object-ui/fields 17.5.0 → 17.6.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 (229) hide show
  1. package/CHANGELOG.md +714 -0
  2. package/dist/FieldEditWidget.d.ts +16 -2
  3. package/dist/field-type-alias.d.ts +16 -0
  4. package/dist/index.cjs +4 -4
  5. package/dist/index.css +27 -9
  6. package/dist/index.d.ts +425 -72
  7. package/dist/index.js +2530 -2228
  8. package/dist/widgets/AddressField.d.ts +3 -3
  9. package/dist/widgets/AutoNumberField.d.ts +1 -1
  10. package/dist/widgets/AvatarField.d.ts +1 -1
  11. package/dist/widgets/BooleanField.d.ts +1 -1
  12. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/DateField.d.ts +1 -1
  18. package/dist/widgets/DateTimeField.d.ts +1 -1
  19. package/dist/widgets/EmailField.d.ts +1 -1
  20. package/dist/widgets/FileField.d.ts +18 -2
  21. package/dist/widgets/FilterConditionField.d.ts +28 -1
  22. package/dist/widgets/FormulaField.d.ts +1 -1
  23. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  24. package/dist/widgets/GeolocationField.d.ts +1 -1
  25. package/dist/widgets/GridField.d.ts +56 -4
  26. package/dist/widgets/ImageField.d.ts +1 -1
  27. package/dist/widgets/LocationField.d.ts +1 -1
  28. package/dist/widgets/LookupField.d.ts +1 -1
  29. package/dist/widgets/MasterDetailField.d.ts +1 -1
  30. package/dist/widgets/MultiSelectField.d.ts +1 -1
  31. package/dist/widgets/NumberField.d.ts +1 -1
  32. package/dist/widgets/ObjectField.d.ts +1 -1
  33. package/dist/widgets/ObjectRefField.d.ts +1 -1
  34. package/dist/widgets/OptionsEmptyState.d.ts +13 -7
  35. package/dist/widgets/PasswordField.d.ts +1 -1
  36. package/dist/widgets/PercentField.d.ts +1 -1
  37. package/dist/widgets/PhoneField.d.ts +1 -1
  38. package/dist/widgets/QRCodeField.d.ts +1 -1
  39. package/dist/widgets/RadioField.d.ts +1 -1
  40. package/dist/widgets/RatingField.d.ts +1 -1
  41. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  42. package/dist/widgets/RecordPickerDialog.d.ts +2 -4
  43. package/dist/widgets/RichTextField.d.ts +37 -1
  44. package/dist/widgets/SelectField.d.ts +1 -1
  45. package/dist/widgets/SignatureField.d.ts +32 -2
  46. package/dist/widgets/SliderField.d.ts +37 -2
  47. package/dist/widgets/SummaryField.d.ts +1 -1
  48. package/dist/widgets/TagsField.d.ts +1 -1
  49. package/dist/widgets/TextAreaField.d.ts +6 -2
  50. package/dist/widgets/TextField.d.ts +1 -1
  51. package/dist/widgets/TimeField.d.ts +1 -1
  52. package/dist/widgets/UrlField.d.ts +1 -1
  53. package/dist/widgets/UserField.d.ts +1 -1
  54. package/dist/widgets/VectorField.d.ts +1 -1
  55. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  56. package/dist/widgets/address-format.d.ts +27 -7
  57. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  58. package/dist/widgets/richTextDisplay.d.ts +80 -0
  59. package/dist/widgets/toHostGroupProps.d.ts +119 -21
  60. package/dist/widgets/types.d.ts +8 -3
  61. package/package.json +9 -10
  62. package/dist/widgets/CharacterCount.d.ts +0 -56
  63. package/src/FieldEditWidget.test.ts +0 -133
  64. package/src/FieldEditWidget.tsx +0 -207
  65. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  66. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  67. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  68. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  69. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  70. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  71. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  72. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  73. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  74. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  75. package/src/__tests__/cell-truncation.test.tsx +0 -96
  76. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  77. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  78. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  79. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  80. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  81. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  82. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  83. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  84. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  85. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  86. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  87. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  88. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  89. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  90. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  91. package/src/__tests__/registry-collision.test.ts +0 -91
  92. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  93. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  94. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  95. package/src/__tests__/validation-feedback.test.tsx +0 -361
  96. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  97. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  98. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  99. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  100. package/src/coerce-safe-value.test.ts +0 -34
  101. package/src/complex-widgets.test.tsx +0 -924
  102. package/src/currency.ts +0 -144
  103. package/src/datetime-widgets.test.tsx +0 -185
  104. package/src/field-type-alias.multiple.test.ts +0 -81
  105. package/src/field-type-alias.ts +0 -156
  106. package/src/field-type-coverage.test.ts +0 -67
  107. package/src/index.css +0 -44
  108. package/src/index.tsx +0 -2745
  109. package/src/standard-widgets.test.tsx +0 -364
  110. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  111. package/src/widgets/AddressField.tsx +0 -182
  112. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  113. package/src/widgets/AutoNumberField.tsx +0 -18
  114. package/src/widgets/AvatarField.tsx +0 -117
  115. package/src/widgets/BooleanField.tsx +0 -86
  116. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  117. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  118. package/src/widgets/CharacterCount.tsx +0 -263
  119. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  120. package/src/widgets/CheckboxesField.tsx +0 -169
  121. package/src/widgets/CodeField.tsx +0 -41
  122. package/src/widgets/ColorField.tsx +0 -53
  123. package/src/widgets/CurrencyField.tsx +0 -135
  124. package/src/widgets/DateField.tsx +0 -40
  125. package/src/widgets/DateTimeField.tsx +0 -46
  126. package/src/widgets/EmailField.tsx +0 -37
  127. package/src/widgets/FileField.tsx +0 -512
  128. package/src/widgets/FilterConditionField.tsx +0 -454
  129. package/src/widgets/FormulaField.tsx +0 -38
  130. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  131. package/src/widgets/GeolocationField.tsx +0 -193
  132. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  133. package/src/widgets/GridField.test.tsx +0 -632
  134. package/src/widgets/GridField.tsx +0 -1070
  135. package/src/widgets/ImageCropperDialog.tsx +0 -245
  136. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  137. package/src/widgets/ImageField.tsx +0 -305
  138. package/src/widgets/ImageLightbox.test.tsx +0 -52
  139. package/src/widgets/ImageLightbox.tsx +0 -108
  140. package/src/widgets/LocationField.tsx +0 -57
  141. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  142. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  143. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  144. package/src/widgets/LookupField.idField.test.tsx +0 -61
  145. package/src/widgets/LookupField.tsx +0 -1295
  146. package/src/widgets/MarkdownContent.tsx +0 -19
  147. package/src/widgets/MasterDetailField.tsx +0 -131
  148. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  149. package/src/widgets/MultiSelectField.tsx +0 -196
  150. package/src/widgets/NumberField.tsx +0 -49
  151. package/src/widgets/ObjectField.tsx +0 -94
  152. package/src/widgets/ObjectRefField.tsx +0 -121
  153. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  154. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  155. package/src/widgets/OptionsEmptyState.tsx +0 -99
  156. package/src/widgets/PasswordField.tsx +0 -49
  157. package/src/widgets/PeoplePicker.test.tsx +0 -360
  158. package/src/widgets/PeoplePicker.tsx +0 -579
  159. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  160. package/src/widgets/PercentField.tsx +0 -95
  161. package/src/widgets/PersonRow.tsx +0 -117
  162. package/src/widgets/PhoneField.tsx +0 -37
  163. package/src/widgets/QRCodeField.tsx +0 -103
  164. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  165. package/src/widgets/RadioField.tsx +0 -143
  166. package/src/widgets/RatingField.tsx +0 -94
  167. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  168. package/src/widgets/RecipientPickerField.tsx +0 -188
  169. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  170. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  171. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  172. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  173. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  174. package/src/widgets/RichTextField.tsx +0 -176
  175. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  176. package/src/widgets/SelectField.tsx +0 -193
  177. package/src/widgets/SelectionTray.tsx +0 -110
  178. package/src/widgets/SignatureField.tsx +0 -140
  179. package/src/widgets/SliderField.tsx +0 -39
  180. package/src/widgets/SummaryField.tsx +0 -31
  181. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  182. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  183. package/src/widgets/TagsField.tsx +0 -88
  184. package/src/widgets/TextAreaField.tsx +0 -213
  185. package/src/widgets/TextField.tsx +0 -45
  186. package/src/widgets/TimeField.tsx +0 -31
  187. package/src/widgets/UrlField.tsx +0 -46
  188. package/src/widgets/UserField.tsx +0 -59
  189. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  190. package/src/widgets/VectorField.tsx +0 -29
  191. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  192. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  193. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  194. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  195. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  196. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  197. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  198. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  199. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  200. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  201. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  202. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  203. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  204. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  205. package/src/widgets/address-format.ts +0 -120
  206. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  207. package/src/widgets/deriveLookupColumns.ts +0 -108
  208. package/src/widgets/file-size-guard.ts +0 -55
  209. package/src/widgets/file-value.test.ts +0 -230
  210. package/src/widgets/file-value.ts +0 -246
  211. package/src/widgets/nativeDateValue.test.ts +0 -115
  212. package/src/widgets/nativeDateValue.ts +0 -112
  213. package/src/widgets/openNativePicker.ts +0 -18
  214. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  215. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  216. package/src/widgets/personDisplay.test.ts +0 -113
  217. package/src/widgets/personDisplay.ts +0 -92
  218. package/src/widgets/recentLookups.test.ts +0 -45
  219. package/src/widgets/recentLookups.ts +0 -47
  220. package/src/widgets/toDomProps.ts +0 -204
  221. package/src/widgets/toHostGroupProps.ts +0 -102
  222. package/src/widgets/types.ts +0 -242
  223. package/src/widgets/useCascadingOptions.ts +0 -46
  224. package/src/widgets/useFieldTranslation.ts +0 -132
  225. package/src/widgets/useRecordQuery.test.ts +0 -174
  226. package/src/widgets/useRecordQuery.ts +0 -291
  227. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  228. package/src/widgets/useUploadingSignal.ts +0 -20
  229. package/src/withFieldCarrier.tsx +0 -75
@@ -1,305 +0,0 @@
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
- });
@@ -1,169 +0,0 @@
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
- * The SDUI path delivers field metadata on ONE key — objectui#3233.
11
- *
12
- * `SchemaRenderer` hands every registered component the authored node as
13
- * `schema`. That key is the universal SDUI contract (`element:*`, `page:*`,
14
- * `object-grid`, `ReportRenderer` …) and is NOT retired. What was retired is
15
- * `schema` on the *field-widget* contract: a widget used to receive the node
16
- * under `schema` here and the metadata under `field` from the form renderer,
17
- * which is why ~30 widgets resolved their config as `field || schema`.
18
- *
19
- * The translation now happens once, at the registration seam
20
- * (`withFieldCarrier`), so downstream `field` is the only carrier. That is only
21
- * correct if the node reaches the widget UNCHANGED — "swap the key, don't drop
22
- * the cargo". The first test proves it by OBJECT IDENTITY: a spy sits where the
23
- * registry entry sits, records the object `SchemaRenderer` passed as `schema`,
24
- * and the widget's `field` must be that exact object. (Identity against the
25
- * authored literal would be wrong to assert — `SchemaRenderer` shallow-copies
26
- * the node while evaluating expressions, so the object it delivers is its own.)
27
- *
28
- * The last test pins that the REAL registration path goes through the adapter
29
- * at all: an adapter nobody applied would leave every SDUI-hosted widget
30
- * reading `undefined`, which is exactly the silent failure this convergence is
31
- * accused of risking.
32
- *
33
- * The sibling half of the invariant — the form path through
34
- * `renderFieldComponent` — is pinned in `packages/components/src/renderers/
35
- * form/__tests__/form-field-carrier.test.tsx`.
36
- */
37
-
38
- import React, { Suspense } from 'react';
39
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
40
- import { render, screen, cleanup } from '@testing-library/react';
41
- import { ComponentRegistry } from '@object-ui/core';
42
- import { SchemaRenderer } from '@object-ui/react';
43
-
44
- import { withFieldCarrier } from '../withFieldCarrier';
45
- import { registerField } from '../index';
46
- // Module scope, not a hook: `registerField('boolean')` registers
47
- // `React.lazy(() => import('./widgets/BooleanField'))`, and under a saturated
48
- // transform pipeline a first `import()` can outlive RTL's 1000ms budget. The
49
- // specifier matches `fieldWidgetMap`'s exactly, so ESM's cache makes that
50
- // factory resolve immediately (AGENTS.md 测试纪律 / objectui#3010).
51
- import '../widgets/BooleanField';
52
-
53
- /**
54
- * Capture holder, not bare module variables: `react-hooks/globals` forbids
55
- * REASSIGNING an outer binding from a component body, which is what a probe
56
- * has to do. Mutating a property of a stable object is the repo's usual shape
57
- * for this (see plugin-detail's `RelatedList` probes).
58
- */
59
- const captured: { props: Record<string, any> | null; node: unknown } = {
60
- props: null,
61
- node: null,
62
- };
63
-
64
- function CarrierProbe(props: any) {
65
- captured.props = props;
66
- return <div data-testid="carrier-probe">{props.field?.label ?? 'NO FIELD'}</div>;
67
- }
68
-
69
- const AdaptedProbe = withFieldCarrier(CarrierProbe);
70
-
71
- /**
72
- * Sits exactly where the registry entry sits, so it sees the host's props
73
- * verbatim, then forwards them to the adapter under test. This is what makes
74
- * the identity assertion possible: `captured.node` and the widget's `field`
75
- * are observed within a single render pass.
76
- */
77
- function CarrierSpy(props: any) {
78
- captured.node = props.schema;
79
- return <AdaptedProbe {...props} />;
80
- }
81
-
82
- beforeEach(() => {
83
- captured.props = null;
84
- captured.node = null;
85
- ComponentRegistry.register('carrierprobe', CarrierSpy, {
86
- namespace: 'field',
87
- skipFallback: true,
88
- });
89
- });
90
-
91
- afterEach(() => {
92
- cleanup();
93
- vi.restoreAllMocks();
94
- });
95
-
96
- describe('SDUI path — the authored node arrives as `field` (objectui#3233)', () => {
97
- it('hands the widget the very object it used to read through `schema`', () => {
98
- const node = {
99
- type: 'field:carrierprobe',
100
- name: 'city',
101
- label: 'City',
102
- placeholder: 'Where?',
103
- options: [{ label: 'Beijing', value: 'bj' }],
104
- };
105
-
106
- render(<SchemaRenderer schema={node as any} />);
107
-
108
- expect(screen.getByTestId('carrier-probe')).toHaveTextContent('City');
109
- expect(captured.props).not.toBeNull();
110
- expect(captured.node).toBeTruthy();
111
- // The payload-equivalence proof: same object, by reference. A
112
- // reconstruction would still render "City" while quietly dropping whatever
113
- // the copy did not know to carry.
114
- expect(captured.props!.field).toBe(captured.node);
115
- // …and it really is the whole authored node, not a narrowed projection.
116
- expect(captured.props!.field.name).toBe('city');
117
- expect(captured.props!.field.placeholder).toBe('Where?');
118
- expect(captured.props!.field.options).toEqual([{ label: 'Beijing', value: 'bj' }]);
119
- });
120
-
121
- it('consumes `schema` rather than forwarding it to the widget', () => {
122
- // Key ABSENCE, not `undefined`. Two reasons it must not be forwarded: a
123
- // widget could resolve `field || schema` again (the second contract
124
- // survives in practice while the type claims it is gone), and widgets
125
- // spread their leftover props onto DOM nodes, where an object-valued
126
- // `schema` attribute is a React warning.
127
- render(
128
- <SchemaRenderer schema={{ type: 'field:carrierprobe', name: 'city', label: 'City' } as any} />,
129
- );
130
-
131
- expect(captured.props).not.toBeNull();
132
- expect('schema' in captured.props!).toBe(false);
133
- });
134
-
135
- it('lets an explicit `field` win when a host supplies both', () => {
136
- // The form renderer passes `field` (resolved metadata) and never `schema`.
137
- // If some host ever passed both, the resolved metadata is the better
138
- // answer — the raw node is the fallback, not the override.
139
- const node = { type: 'field:carrierprobe', name: 'from-node', label: 'From node' };
140
- const metadata = { name: 'from-field', label: 'From field' };
141
-
142
- render(<AdaptedProbe schema={node} field={metadata} />);
143
-
144
- expect(captured.props!.field).toBe(metadata);
145
- });
146
-
147
- it('applies the adapter on the REAL registration path (`registerField`)', async () => {
148
- // The seam is only worth anything if `registerField` goes through it.
149
- // `BooleanField` reads `widget` / `name` off its metadata carrier and
150
- // nowhere else: `widget: 'checkbox'` selects the Checkbox over the Switch,
151
- // and `name` becomes the control's id. Both are node keys, so seeing them
152
- // honoured here proves the node reached `field` end to end — through the
153
- // real registry entry, the real SchemaRenderer, and the real widget.
154
- registerField('boolean');
155
-
156
- render(
157
- <Suspense fallback={<div>loading</div>}>
158
- <SchemaRenderer
159
- schema={
160
- { type: 'field:boolean', name: 'is_active', label: 'Active', widget: 'checkbox' } as any
161
- }
162
- />
163
- </Suspense>,
164
- );
165
-
166
- const control = await screen.findByRole('checkbox');
167
- expect(control).toHaveAttribute('id', 'is_active');
168
- });
169
- });
@@ -1,158 +0,0 @@
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
- });