@object-ui/fields 17.5.0 → 17.7.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 (249) hide show
  1. package/CHANGELOG.md +6150 -0
  2. package/README.md +108 -8
  3. package/dist/FieldEditWidget.d.ts +70 -8
  4. package/dist/coerceToSafeValue.d.ts +54 -0
  5. package/dist/currency.d.ts +1 -1
  6. package/dist/field-type-alias.d.ts +16 -0
  7. package/dist/index.cjs +5 -5
  8. package/dist/index.css +53 -11
  9. package/dist/index.d.ts +653 -128
  10. package/dist/index.js +4275 -3171
  11. package/dist/widgets/AddressField.d.ts +3 -3
  12. package/dist/widgets/AutoNumberField.d.ts +1 -1
  13. package/dist/widgets/AvatarField.d.ts +20 -4
  14. package/dist/widgets/BooleanField.d.ts +2 -2
  15. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  16. package/dist/widgets/CheckboxesField.d.ts +1 -1
  17. package/dist/widgets/CodeField.d.ts +1 -1
  18. package/dist/widgets/ColorField.d.ts +1 -1
  19. package/dist/widgets/CurrencyField.d.ts +1 -1
  20. package/dist/widgets/DateField.d.ts +2 -2
  21. package/dist/widgets/DateTimeField.d.ts +2 -2
  22. package/dist/widgets/EmailField.d.ts +1 -1
  23. package/dist/widgets/FileField.d.ts +18 -2
  24. package/dist/widgets/FilterConditionField.d.ts +43 -2
  25. package/dist/widgets/FormulaField.d.ts +1 -1
  26. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  27. package/dist/widgets/GeolocationField.d.ts +10 -4
  28. package/dist/widgets/GridField.d.ts +102 -86
  29. package/dist/widgets/ImageField.d.ts +1 -1
  30. package/dist/widgets/LocationField.d.ts +31 -4
  31. package/dist/widgets/LookupField.d.ts +9 -1
  32. package/dist/widgets/MasterDetailField.d.ts +1 -1
  33. package/dist/widgets/MultiSelectField.d.ts +1 -1
  34. package/dist/widgets/NumberField.d.ts +2 -2
  35. package/dist/widgets/ObjectField.d.ts +10 -3
  36. package/dist/widgets/ObjectRefField.d.ts +1 -1
  37. package/dist/widgets/OptionsEmptyState.d.ts +38 -8
  38. package/dist/widgets/PasswordField.d.ts +15 -1
  39. package/dist/widgets/PeoplePicker.d.ts +17 -2
  40. package/dist/widgets/PercentField.d.ts +3 -2
  41. package/dist/widgets/PersonRow.d.ts +13 -2
  42. package/dist/widgets/PhoneField.d.ts +1 -1
  43. package/dist/widgets/QRCodeField.d.ts +1 -1
  44. package/dist/widgets/RadioField.d.ts +1 -1
  45. package/dist/widgets/RatingField.d.ts +1 -1
  46. package/dist/widgets/RecipientPickerField.d.ts +44 -1
  47. package/dist/widgets/RecordPickerDialog.d.ts +33 -16
  48. package/dist/widgets/RichTextField.d.ts +37 -1
  49. package/dist/widgets/SelectField.d.ts +1 -1
  50. package/dist/widgets/SelectionTray.d.ts +13 -2
  51. package/dist/widgets/SignatureField.d.ts +32 -2
  52. package/dist/widgets/SliderField.d.ts +37 -2
  53. package/dist/widgets/SummaryField.d.ts +1 -1
  54. package/dist/widgets/TagsField.d.ts +1 -1
  55. package/dist/widgets/TextAreaField.d.ts +6 -2
  56. package/dist/widgets/TextField.d.ts +2 -2
  57. package/dist/widgets/TimeField.d.ts +2 -2
  58. package/dist/widgets/UrlField.d.ts +1 -1
  59. package/dist/widgets/UserField.d.ts +1 -1
  60. package/dist/widgets/VectorField.d.ts +1 -1
  61. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  62. package/dist/widgets/address-format.d.ts +27 -7
  63. package/dist/widgets/file-affordance.d.ts +33 -0
  64. package/dist/widgets/file-size-guard.d.ts +5 -23
  65. package/dist/widgets/file-value.d.ts +38 -9
  66. package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
  67. package/dist/widgets/nativeDateValue.d.ts +9 -27
  68. package/dist/widgets/number-format.d.ts +137 -0
  69. package/dist/widgets/numberBadInput.d.ts +135 -0
  70. package/dist/widgets/personDisplay.d.ts +22 -4
  71. package/dist/widgets/richTextDisplay.d.ts +116 -0
  72. package/dist/widgets/toDomProps.d.ts +10 -1
  73. package/dist/widgets/toHostGroupProps.d.ts +172 -21
  74. package/dist/widgets/toHostProps.d.ts +105 -0
  75. package/dist/widgets/types.d.ts +19 -6
  76. package/dist/widgets/upload-error-message.d.ts +6 -0
  77. package/dist/widgets/uploadingScope.d.ts +142 -0
  78. package/dist/widgets/useCascadingOptions.d.ts +3 -2
  79. package/dist/widgets/useRecordQuery.d.ts +4 -2
  80. package/dist/widgets/useUploadingSignal.d.ts +8 -0
  81. package/package.json +11 -13
  82. package/dist/widgets/CharacterCount.d.ts +0 -56
  83. package/src/FieldEditWidget.test.ts +0 -133
  84. package/src/FieldEditWidget.tsx +0 -207
  85. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  86. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  87. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  88. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  89. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  90. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  91. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  92. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  93. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  94. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  95. package/src/__tests__/cell-truncation.test.tsx +0 -96
  96. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  97. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  98. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  99. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  100. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  101. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  102. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  103. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  104. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  105. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  106. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  107. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  108. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  109. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  110. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  111. package/src/__tests__/registry-collision.test.ts +0 -91
  112. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  113. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  114. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  115. package/src/__tests__/validation-feedback.test.tsx +0 -361
  116. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  117. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  118. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  119. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  120. package/src/coerce-safe-value.test.ts +0 -34
  121. package/src/complex-widgets.test.tsx +0 -924
  122. package/src/currency.ts +0 -144
  123. package/src/datetime-widgets.test.tsx +0 -185
  124. package/src/field-type-alias.multiple.test.ts +0 -81
  125. package/src/field-type-alias.ts +0 -156
  126. package/src/field-type-coverage.test.ts +0 -67
  127. package/src/index.css +0 -44
  128. package/src/index.tsx +0 -2745
  129. package/src/standard-widgets.test.tsx +0 -364
  130. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  131. package/src/widgets/AddressField.tsx +0 -182
  132. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  133. package/src/widgets/AutoNumberField.tsx +0 -18
  134. package/src/widgets/AvatarField.tsx +0 -117
  135. package/src/widgets/BooleanField.tsx +0 -86
  136. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  137. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  138. package/src/widgets/CharacterCount.tsx +0 -263
  139. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  140. package/src/widgets/CheckboxesField.tsx +0 -169
  141. package/src/widgets/CodeField.tsx +0 -41
  142. package/src/widgets/ColorField.tsx +0 -53
  143. package/src/widgets/CurrencyField.tsx +0 -135
  144. package/src/widgets/DateField.tsx +0 -40
  145. package/src/widgets/DateTimeField.tsx +0 -46
  146. package/src/widgets/EmailField.tsx +0 -37
  147. package/src/widgets/FileField.tsx +0 -512
  148. package/src/widgets/FilterConditionField.tsx +0 -454
  149. package/src/widgets/FormulaField.tsx +0 -38
  150. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  151. package/src/widgets/GeolocationField.tsx +0 -193
  152. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  153. package/src/widgets/GridField.test.tsx +0 -632
  154. package/src/widgets/GridField.tsx +0 -1070
  155. package/src/widgets/ImageCropperDialog.tsx +0 -245
  156. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  157. package/src/widgets/ImageField.tsx +0 -305
  158. package/src/widgets/ImageLightbox.test.tsx +0 -52
  159. package/src/widgets/ImageLightbox.tsx +0 -108
  160. package/src/widgets/LocationField.tsx +0 -57
  161. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  162. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  163. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  164. package/src/widgets/LookupField.idField.test.tsx +0 -61
  165. package/src/widgets/LookupField.tsx +0 -1295
  166. package/src/widgets/MarkdownContent.tsx +0 -19
  167. package/src/widgets/MasterDetailField.tsx +0 -131
  168. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  169. package/src/widgets/MultiSelectField.tsx +0 -196
  170. package/src/widgets/NumberField.tsx +0 -49
  171. package/src/widgets/ObjectField.tsx +0 -94
  172. package/src/widgets/ObjectRefField.tsx +0 -121
  173. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  174. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  175. package/src/widgets/OptionsEmptyState.tsx +0 -99
  176. package/src/widgets/PasswordField.tsx +0 -49
  177. package/src/widgets/PeoplePicker.test.tsx +0 -360
  178. package/src/widgets/PeoplePicker.tsx +0 -579
  179. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  180. package/src/widgets/PercentField.tsx +0 -95
  181. package/src/widgets/PersonRow.tsx +0 -117
  182. package/src/widgets/PhoneField.tsx +0 -37
  183. package/src/widgets/QRCodeField.tsx +0 -103
  184. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  185. package/src/widgets/RadioField.tsx +0 -143
  186. package/src/widgets/RatingField.tsx +0 -94
  187. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  188. package/src/widgets/RecipientPickerField.tsx +0 -188
  189. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  190. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  191. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  192. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  193. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  194. package/src/widgets/RichTextField.tsx +0 -176
  195. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  196. package/src/widgets/SelectField.tsx +0 -193
  197. package/src/widgets/SelectionTray.tsx +0 -110
  198. package/src/widgets/SignatureField.tsx +0 -140
  199. package/src/widgets/SliderField.tsx +0 -39
  200. package/src/widgets/SummaryField.tsx +0 -31
  201. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  202. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  203. package/src/widgets/TagsField.tsx +0 -88
  204. package/src/widgets/TextAreaField.tsx +0 -213
  205. package/src/widgets/TextField.tsx +0 -45
  206. package/src/widgets/TimeField.tsx +0 -31
  207. package/src/widgets/UrlField.tsx +0 -46
  208. package/src/widgets/UserField.tsx +0 -59
  209. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  210. package/src/widgets/VectorField.tsx +0 -29
  211. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  212. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  213. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  214. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  215. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  216. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  217. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  218. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  219. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  220. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  221. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  222. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  223. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  224. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  225. package/src/widgets/address-format.ts +0 -120
  226. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  227. package/src/widgets/deriveLookupColumns.ts +0 -108
  228. package/src/widgets/file-size-guard.ts +0 -55
  229. package/src/widgets/file-value.test.ts +0 -230
  230. package/src/widgets/file-value.ts +0 -246
  231. package/src/widgets/nativeDateValue.test.ts +0 -115
  232. package/src/widgets/nativeDateValue.ts +0 -112
  233. package/src/widgets/openNativePicker.ts +0 -18
  234. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  235. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  236. package/src/widgets/personDisplay.test.ts +0 -113
  237. package/src/widgets/personDisplay.ts +0 -92
  238. package/src/widgets/recentLookups.test.ts +0 -45
  239. package/src/widgets/recentLookups.ts +0 -47
  240. package/src/widgets/toDomProps.ts +0 -204
  241. package/src/widgets/toHostGroupProps.ts +0 -102
  242. package/src/widgets/types.ts +0 -242
  243. package/src/widgets/useCascadingOptions.ts +0 -46
  244. package/src/widgets/useFieldTranslation.ts +0 -132
  245. package/src/widgets/useRecordQuery.test.ts +0 -174
  246. package/src/widgets/useRecordQuery.ts +0 -291
  247. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  248. package/src/widgets/useUploadingSignal.ts +0 -20
  249. package/src/withFieldCarrier.tsx +0 -75
@@ -1,166 +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#4037 (migrated from objectstack#5019): a `Field.address` value read
11
- * as stringified JSON on the record detail page — `{"street":"中策路 1 号",…}` —
12
- * because the DISPLAY registry mapped `address` to `JsonCellRenderer`, while
13
- * `location` next to it in the same field group had a real formatter. The
14
- * create/edit dialog rendered the same field correctly, so the gap was
15
- * display-side only.
16
- *
17
- * Every case below resolves its renderer through `getCellRenderer` — the
18
- * display resolver the detail page itself calls — rather than importing
19
- * `AddressCellRenderer` directly, so these tests fail on the JSON signature if
20
- * the registry entry ever regresses, not merely if the formatter changes.
21
- */
22
-
23
- import { describe, it, expect } from 'vitest';
24
- import React from 'react';
25
- import { render, screen } from '@testing-library/react';
26
- import '@testing-library/jest-dom';
27
- import { getCellRenderer, resolveCellRendererType } from '../index';
28
-
29
- /** Resolve + render exactly the way `DetailSection` builds a read-mode value. */
30
- function renderThroughDisplayRegistry(type: string, value: unknown) {
31
- const Renderer = getCellRenderer(resolveCellRendererType({ type }) || type);
32
- return render(<Renderer value={value as any} field={{ type } as any} />);
33
- }
34
-
35
- /** The stringified-object signature this issue is about. */
36
- function expectNoJsonSignature(text: string | null) {
37
- expect(text).not.toMatch(/[{}]/);
38
- expect(text).not.toMatch(/"(street|city|state|postalCode|country)"/);
39
- expect(text).not.toMatch(/undefined/);
40
- }
41
-
42
- /** The record from the issue's evidence table. */
43
- const ISSUE_ADDRESS = {
44
- street: '中策路 1 号',
45
- city: '杭州',
46
- state: '浙江',
47
- postalCode: '310000',
48
- country: '中国',
49
- };
50
-
51
- describe('address display renderer (objectui#4037)', () => {
52
- it('renders a populated address as a formatted line, not stringified JSON', () => {
53
- const { container } = renderThroughDisplayRegistry('address', ISSUE_ADDRESS);
54
- expectNoJsonSignature(container.textContent);
55
- expect(
56
- screen.getByText('中策路 1 号, 杭州, 浙江 310000, 中国'),
57
- ).toBeInTheDocument();
58
- });
59
-
60
- it('resolves `address` through the display registry the way it resolves `location`', () => {
61
- // The issue's decisive comparison: one address field beside one location
62
- // field, same record, same page. Both must be formatted; neither may fall
63
- // through to object stringification.
64
- const address = renderThroughDisplayRegistry('address', ISSUE_ADDRESS);
65
- const location = renderThroughDisplayRegistry('location', { lat: 30.2741, lng: 120.1551 });
66
- expectNoJsonSignature(address.container.textContent);
67
- expectNoJsonSignature(location.container.textContent);
68
- expect(location.container.textContent).toContain('30.2741');
69
- });
70
-
71
- it('carries the full value: every authored sub-field appears', () => {
72
- const { container } = renderThroughDisplayRegistry('address', ISSUE_ADDRESS);
73
- for (const part of Object.values(ISSUE_ADDRESS)) {
74
- expect(container.textContent).toContain(part);
75
- }
76
- });
77
- });
78
-
79
- describe('address display renderer — partial values render gracefully', () => {
80
- it('drops missing parts instead of leaving dangling separators', () => {
81
- const { container } = renderThroughDisplayRegistry('address', {
82
- street: '中策路 1 号',
83
- city: '杭州',
84
- });
85
- expect(screen.getByText('中策路 1 号, 杭州')).toBeInTheDocument();
86
- expectNoJsonSignature(container.textContent);
87
- expect(container.textContent).not.toMatch(/,\s*$/);
88
- expect(container.textContent).not.toMatch(/,\s*,/);
89
- });
90
-
91
- it('renders a street-only address as just the street', () => {
92
- renderThroughDisplayRegistry('address', { street: '中策路 1 号' });
93
- expect(screen.getByText('中策路 1 号')).toBeInTheDocument();
94
- });
95
-
96
- it('keeps state and postal code in one group, either alone', () => {
97
- const stateOnly = renderThroughDisplayRegistry('address', { city: 'SF', state: 'CA' });
98
- expect(stateOnly.container.textContent).toBe('SF, CA');
99
- const postalOnly = renderThroughDisplayRegistry('address', { city: 'SF', postalCode: '94102' });
100
- expect(postalOnly.container.textContent).toBe('SF, 94102');
101
- const both = renderThroughDisplayRegistry('address', {
102
- city: 'SF',
103
- state: 'CA',
104
- postalCode: '94102',
105
- });
106
- expect(both.container.textContent).toBe('SF, CA 94102');
107
- });
108
-
109
- it('ignores whitespace-only parts rather than spacing over them', () => {
110
- const { container } = renderThroughDisplayRegistry('address', {
111
- street: ' ',
112
- city: '杭州',
113
- country: '',
114
- });
115
- expect(container.textContent).toBe('杭州');
116
- });
117
-
118
- it('reads a legacy `zipCode` postal code (objectstack#5143 data)', () => {
119
- // Records written by builds up to 17.0.0-rc.1 stored the postal code under
120
- // `zipCode`. The input widget reads it; the detail page must not drop it.
121
- const { container } = renderThroughDisplayRegistry('address', {
122
- city: 'San Francisco',
123
- state: 'CA',
124
- zipCode: '94102',
125
- });
126
- expect(container.textContent).toBe('San Francisco, CA 94102');
127
- });
128
- });
129
-
130
- describe('address display renderer — nothing is silently swallowed', () => {
131
- it('shows the empty placeholder for a null value and for `{}`', () => {
132
- const nullValue = renderThroughDisplayRegistry('address', null);
133
- expect(nullValue.container.querySelector('[data-slot="empty-value"]')).not.toBeNull();
134
- const emptyObject = renderThroughDisplayRegistry('address', {});
135
- expect(emptyObject.container.querySelector('[data-slot="empty-value"]')).not.toBeNull();
136
- });
137
-
138
- it('keeps an unrecognized shape visible as JSON rather than blanking it', () => {
139
- // Strictly better than today, never worse: a value the formatter cannot
140
- // read retains the current compact-JSON rendering instead of disappearing.
141
- const { container } = renderThroughDisplayRegistry('address', { unexpected: 'data' });
142
- expect(container.textContent).toContain('unexpected');
143
- });
144
-
145
- it('passes a plain string address straight through', () => {
146
- renderThroughDisplayRegistry('address', '中策路 1 号, 杭州');
147
- expect(screen.getByText('中策路 1 号, 杭州')).toBeInTheDocument();
148
- });
149
- });
150
-
151
- describe('address display renderer — controls (unchanged surfaces)', () => {
152
- it('leaves `location` and `geolocation` formatting untouched', () => {
153
- const location = renderThroughDisplayRegistry('location', { lat: 30.2741, lng: 120.1551 });
154
- expect(location.container.textContent).toBe('30.2741, 120.1551');
155
- const geo = renderThroughDisplayRegistry('geolocation', { latitude: 30.2741, longitude: 120.1551 });
156
- expect(geo.container.textContent).toBe('30.2741, 120.1551');
157
- });
158
-
159
- it('leaves genuinely structural types stringifying as JSON', () => {
160
- // `address` moved out of the JSON bucket; `json` / `object` stay in it.
161
- for (const type of ['json', 'object', 'composite', 'record']) {
162
- const { container } = renderThroughDisplayRegistry(type, { a: 1 });
163
- expect(container.textContent).toBe('{"a":1}');
164
- }
165
- });
166
- });
@@ -1,191 +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#4361, second path — `CurrencyField`'s `precision`.
11
- *
12
- * `formatAmount` passes the field's `precision` to BOTH `Intl` bounds, and the
13
- * widget defaulted that precision to a literal `2`. So a JPY field that
14
- * declared no precision rendered `¥1,234.50` for the same reason
15
- * `formatCurrency` did, one layer up.
16
- *
17
- * The PM ruling on this card (issue #4361, claim comment) splits it in two,
18
- * and both halves are pinned here:
19
- *
20
- * 1. **An explicitly authored `precision` WINS.** It is authored metadata and
21
- * this repo's convention is that authored metadata keeps priority. A JPY
22
- * field declaring `precision: 2` still renders `¥1,234.50` — whether that
23
- * combination should be REJECTED at publish time is a spec question filed
24
- * upstream (contract-first), not something the renderer decides by
25
- * overriding the author.
26
- * 2. **An ABSENT `precision` derives from the currency** instead of falling
27
- * back to 2.
28
- *
29
- * MEASURED, per the ruling's stop condition — is "absent" distinguishable from
30
- * "authored 2" by the time the renderer sees it? Yes:
31
- *
32
- * - `CurrencyFieldMetadata.precision` is `precision?: number` in
33
- * `packages/types/src/field-types.ts` — optional, no default;
34
- * - the field-level key is `z.ZodOptional<z.ZodNumber>` in
35
- * `@objectstack/spec` (no `.default()`), so a parsed field metadata object
36
- * does NOT carry a materialized 2;
37
- * - the only `.default(2)` in the spec's currency surface is on
38
- * `CurrencyConfigSchema.precision` — a DIFFERENT key on a different object
39
- * (the `currencyConfig` block), which this widget never reads; and
40
- * - the `?? 2` itself lived in the widget, at `CurrencyField.tsx`.
41
- *
42
- * So the renderer holds an `undefined` it can act on, and this half lands.
43
- *
44
- * The derived precision is the widget's ONE precision, so it reaches the edit
45
- * affordances too (`step`, and the blur rounding) — pinned below. Leaving
46
- * those at 2 would have produced a JPY field that displays whole yen but
47
- * offers a 0.01 spinner step and rounds typed input to 1234.56 yen.
48
- */
49
-
50
- import { describe, it, expect, vi } from 'vitest';
51
- import React from 'react';
52
- import { render, screen, fireEvent } from '@testing-library/react';
53
- import '@testing-library/jest-dom';
54
- import { LocalizationProvider } from '@object-ui/i18n';
55
- import { CurrencyField } from '../widgets/CurrencyField';
56
-
57
- /**
58
- * ICU separates a currency CODE from the amount with U+00A0 (`KWD` + NBSP +
59
- * `1.500`), while a SYMBOL is flush against it (`¥1,235`). `textContent` is
60
- * raw, so normalizing keeps these pins about the DIGIT COUNT this card is
61
- * about rather than about ICU's spacing. Written as an escape, never a pasted
62
- * byte — a raw NBSP in source is invisible to readers and unfindable by grep.
63
- */
64
- const normalizeNbsp = (s: string | null) => (s ?? '').replace(/\u00a0/g, ' ');
65
-
66
- const renderField = (
67
- value: number | null,
68
- field: Record<string, unknown>,
69
- opts: { readonly?: boolean; locale?: string; tenantCurrency?: string; onChange?: (v: any) => void } = {},
70
- ) =>
71
- render(
72
- <LocalizationProvider value={{ locale: opts.locale ?? 'en-US', currency: opts.tenantCurrency }}>
73
- <CurrencyField
74
- value={value as any}
75
- onChange={opts.onChange ?? vi.fn()}
76
- field={{ type: 'currency', ...field } as any}
77
- readonly={opts.readonly}
78
- />
79
- </LocalizationProvider>,
80
- );
81
-
82
- describe('CurrencyField — an ABSENT precision derives from the currency (objectui#4361)', () => {
83
- it('a JPY field that declares no precision shows no cents', () => {
84
- const { container } = renderField(1234.5, { currency: 'JPY' }, { readonly: true });
85
- expect(normalizeNbsp(container.textContent)).toBe('¥1,235');
86
- });
87
-
88
- it('a KWD field that declares no precision shows all three digits', () => {
89
- const { container } = renderField(1.5, { currency: 'KWD' }, { readonly: true });
90
- expect(normalizeNbsp(container.textContent)).toBe('KWD 1.500');
91
- });
92
-
93
- it('the tenant default currency reaches the derivation too (ADR-0053)', () => {
94
- const { container } = renderField(1234.5, {}, { readonly: true, tenantCurrency: 'JPY' });
95
- expect(normalizeNbsp(container.textContent)).toBe('¥1,235');
96
- });
97
-
98
- it('`currencyConfig.defaultCurrency` reaches it as well', () => {
99
- const { container } = renderField(
100
- 1.5,
101
- { currencyConfig: { defaultCurrency: 'KWD' } },
102
- { readonly: true },
103
- );
104
- expect(normalizeNbsp(container.textContent)).toBe('KWD 1.500');
105
- });
106
- });
107
-
108
- describe('CurrencyField — an AUTHORED precision still wins (authored metadata keeps priority)', () => {
109
- it('a JPY field declaring `precision: 2` keeps its two digits', () => {
110
- // The renderer does not overrule the author. Whether publish-time
111
- // validation should reject this combination is the upstream spec question
112
- // filed alongside this card — deliberately NOT decided here.
113
- const { container } = renderField(1234.5, { currency: 'JPY', precision: 2 }, { readonly: true });
114
- expect(normalizeNbsp(container.textContent)).toBe('¥1,234.50');
115
- });
116
-
117
- it('an authored `precision: 0` on a 2-digit currency still wins', () => {
118
- // The other direction, so the pin cannot be satisfied by "authored wins
119
- // only when it asks for MORE digits".
120
- const { container } = renderField(1234.5, { currency: 'USD', precision: 0 }, { readonly: true });
121
- expect(normalizeNbsp(container.textContent)).toBe('$1,235');
122
- });
123
-
124
- it('an authored `precision: 3` on JPY wins', () => {
125
- const { container } = renderField(1234.5, { currency: 'JPY', precision: 3 }, { readonly: true });
126
- expect(normalizeNbsp(container.textContent)).toBe('¥1,234.500');
127
- });
128
- });
129
-
130
- describe('CurrencyField — CONTROL: the 2-digit and no-currency cases are unchanged', () => {
131
- it('a USD field with no declared precision renders exactly as before', () => {
132
- // Derivation and the old literal agree at 2, which is the whole point of
133
- // "byte-identical for USD".
134
- const { container } = renderField(1234.5, { currency: 'USD' }, { readonly: true });
135
- expect(normalizeNbsp(container.textContent)).toBe('$1,234.50');
136
- });
137
-
138
- it('a USD field declaring `precision: 2` renders exactly as before', () => {
139
- const { container } = renderField(1234.56, { currency: 'USD', precision: 2 }, { readonly: true });
140
- expect(normalizeNbsp(container.textContent)).toBe('$1,234.56');
141
- });
142
-
143
- it('a field with NO currency keeps the literal 2 — nothing to derive from', () => {
144
- const { container } = renderField(1234.5, {}, { readonly: true });
145
- expect(normalizeNbsp(container.textContent)).toBe('1,234.50');
146
- });
147
-
148
- it('a null value is still the empty marker, not a formatted zero', () => {
149
- const { container } = renderField(null, { currency: 'JPY' }, { readonly: true });
150
- expect(container.textContent).not.toContain('¥');
151
- });
152
- });
153
-
154
- describe('CurrencyField — the derived precision is the widget\'s ONE precision', () => {
155
- it('a JPY field with no declared precision offers a whole-unit spinner step', () => {
156
- renderField(1234, { currency: 'JPY' });
157
- expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '1');
158
- });
159
-
160
- it('a KWD field with no declared precision offers a three-digit step', () => {
161
- renderField(1, { currency: 'KWD' });
162
- expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0.001');
163
- });
164
-
165
- it('CONTROL: USD is still 0.01, authored or derived', () => {
166
- const { unmount } = renderField(1, { currency: 'USD' });
167
- expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0.01');
168
- unmount();
169
- renderField(1, { currency: 'JPY', precision: 2 });
170
- expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0.01');
171
- });
172
-
173
- it('blur rounds a typed amount to the currency\'s own unit', () => {
174
- const onChange = vi.fn();
175
- renderField(null, { currency: 'JPY' }, { onChange });
176
- const input = screen.getByRole('spinbutton');
177
- fireEvent.change(input, { target: { value: '1234.56' } });
178
- fireEvent.blur(input, { target: { value: '1234.56' } });
179
- // Before: 1234.56 yen — a sub-unit amount the currency cannot express.
180
- expect(onChange).toHaveBeenLastCalledWith(1235);
181
- });
182
-
183
- it('CONTROL: blur on a USD field still rounds to cents', () => {
184
- const onChange = vi.fn();
185
- renderField(null, { currency: 'USD' }, { onChange });
186
- const input = screen.getByRole('spinbutton');
187
- fireEvent.change(input, { target: { value: '1234.567' } });
188
- fireEvent.blur(input, { target: { value: '1234.567' } });
189
- expect(onChange).toHaveBeenLastCalledWith(1234.57);
190
- });
191
- });
@@ -1,198 +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#4414 — the edit-mode currency adornment has ONE channel.
11
- *
12
- * Before this card `CurrencyField.tsx` carried the same one-entry fact twice: a
13
- * `CURRENCY_SYMBOLS` map that nothing read, and, two lines below it, the live
14
- * `currency === 'USD' ? '$' : currency` ternary. The PM ruling (issue #4414,
15
- * claim comment) says one channel survives, and that `Intl` — which already
16
- * carries this knowledge — is the candidate to MEASURE first.
17
- *
18
- * Measured, and it fits: `Intl.NumberFormat(locale, { style: 'currency',
19
- * currency }).formatToParts()` has a `currency` part, and that part is exactly
20
- * the string the widget's OWN readonly branch already renders, because the
21
- * readonly branch formats through `formatDisplayNumber(value, { locale,
22
- * currency })` — the same `Intl` call. So the adornment was the last hand copy,
23
- * and the widget disagreed with itself: at `en`, readonly JPY rendered
24
- * `¥1,235` while the edit adornment beside it said `JPY`.
25
- *
26
- * The one place this helper deliberately DIVERGES from its sibling
27
- * `currencyFractionDigits()`: the digit count is locale-independent (measured
28
- * across eight locales in `currency.ts`), so that probe drops the locale. The
29
- * SYMBOL is not — measured on node 22 / full-ICU:
30
- *
31
- * USD -> "$" (en, en-US, de-DE, ja-JP) | "US$" (en-GB, zh-CN) | "$US" (fr-FR)
32
- * JPY -> "¥" (en, de-DE) | "¥" (ja-JP) | "JP¥" (en-GB, zh-CN) | "JPY" (fr-FR)
33
- *
34
- * so `currencySymbol()` takes the display locale as a parameter and caches on
35
- * the PAIR. Keying the cache on the code alone would let whichever locale asked
36
- * first answer for every locale after it — pinned below, because that is a
37
- * silent wrong-answer bug and not a hypothetical.
38
- */
39
-
40
- import { describe, it, expect, vi } from 'vitest';
41
- import React from 'react';
42
- import { render } from '@testing-library/react';
43
- import '@testing-library/jest-dom';
44
- import { LocalizationProvider } from '@object-ui/i18n';
45
- import { CurrencyField } from '../widgets/CurrencyField';
46
- import { currencySymbol } from '../currency';
47
-
48
- /**
49
- * ICU separates a currency CODE from the amount with U+00A0 and sets a SYMBOL
50
- * flush against it; normalizing keeps the readonly-agreement pins about the
51
- * SYMBOL rather than about ICU's spacing. Written as an escape, never a pasted
52
- * byte.
53
- */
54
- const normalizeNbsp = (s: string | null) => (s ?? '').replace(/\u00a0/g, ' ');
55
-
56
- const renderField = (
57
- field: Record<string, unknown>,
58
- opts: { readonly?: boolean; locale?: string; tenantCurrency?: string } = {},
59
- ) =>
60
- render(
61
- <LocalizationProvider value={{ locale: opts.locale ?? 'en', currency: opts.tenantCurrency }}>
62
- <CurrencyField
63
- value={1234.5}
64
- onChange={vi.fn()}
65
- field={{ type: 'currency', ...field } as any}
66
- readonly={opts.readonly}
67
- />
68
- </LocalizationProvider>,
69
- );
70
-
71
- /** The edit-mode prefix adornment: the only `span` inside the wrapper. */
72
- const adornment = (container: HTMLElement) =>
73
- container.querySelector('div.relative > span')?.textContent ?? null;
74
-
75
- describe('CurrencyField adornment — USD is byte-identical at the display-locale default (objectui#4414)', () => {
76
- it("`en` — useDisplayLocale()'s own last resort — still renders exactly `$`", () => {
77
- const { container } = renderField({ currency: 'USD' });
78
- expect(adornment(container)).toBe('$');
79
- });
80
-
81
- it('`en-US` still renders exactly `$`', () => {
82
- const { container } = renderField({ currency: 'USD' }, { locale: 'en-US' });
83
- expect(adornment(container)).toBe('$');
84
- });
85
-
86
- it('the tenant default currency reaches the adornment too (ADR-0053)', () => {
87
- const { container } = renderField({}, { tenantCurrency: 'USD' });
88
- expect(adornment(container)).toBe('$');
89
- });
90
-
91
- it('a field with NO currency still renders no adornment and no left padding', () => {
92
- const { container } = renderField({});
93
- expect(adornment(container)).toBeNull();
94
- expect(container.querySelector('input')).not.toHaveClass('pl-8');
95
- });
96
-
97
- it('a field WITH a currency still gets the left padding that clears the adornment', () => {
98
- const { container } = renderField({ currency: 'USD' });
99
- expect(container.querySelector('input')).toHaveClass('pl-8');
100
- });
101
- });
102
-
103
- describe('CurrencyField adornment — the DOCUMENTED non-USD change: a real symbol replaces the bare code', () => {
104
- // Before #4414 every one of these rendered the three-letter ISO code, because
105
- // the ternary had exactly one entry. This is the deliberate improvement the
106
- // ruling allowed for, not a side effect — and it is what the widget's own
107
- // readonly branch has been rendering all along.
108
- it.each([
109
- ['EUR', '€'],
110
- ['JPY', '¥'],
111
- ['GBP', '£'],
112
- ['INR', '₹'],
113
- ['KRW', '₩'],
114
- ])('%s now renders %s instead of the code', (currency, symbol) => {
115
- const { container } = renderField({ currency });
116
- expect(adornment(container)).toBe(symbol);
117
- });
118
- });
119
-
120
- describe('CurrencyField adornment — currencies CLDR has no symbol for keep the code, byte-identical', () => {
121
- // ICU answers with the code itself for these, which is exactly what the
122
- // ternary produced. Nothing changes, and that is worth pinning: it is the
123
- // half of the ruling's "symbol-vs-code nuance" that survives the change.
124
- it.each(['KWD', 'BHD', 'CHF', 'ISK', 'CLP'])('%s still renders its bare code', (currency) => {
125
- const { container } = renderField({ currency });
126
- expect(adornment(container)).toBe(currency);
127
- });
128
- });
129
-
130
- describe('CurrencyField adornment — it agrees with the SAME widget\'s readonly rendering', () => {
131
- // The real invariant behind this card. The readonly branch already formats
132
- // through the display locale, so before #4414 the widget contradicted itself
133
- // for every currency with a symbol.
134
- it.each([
135
- ['en', 'JPY'],
136
- ['en', 'EUR'],
137
- ['en-GB', 'USD'],
138
- ['fr-FR', 'USD'],
139
- ['ja-JP', 'JPY'],
140
- ['de-DE', 'USD'],
141
- ['en', 'KWD'],
142
- ])('%s / %s — the readonly text contains the adornment string', (locale, currency) => {
143
- const edit = renderField({ currency }, { locale });
144
- const symbol = adornment(edit.container);
145
- expect(symbol).toBeTruthy();
146
- const view = renderField({ currency }, { locale, readonly: true });
147
- expect(normalizeNbsp(view.container.textContent)).toContain(symbol as string);
148
- });
149
- });
150
-
151
- describe('currencySymbol() — the symbol is locale-dependent, so the locale is a parameter', () => {
152
- it('USD is not one string across locales', () => {
153
- expect(currencySymbol('USD', 'en')).toBe('$');
154
- expect(currencySymbol('USD', 'fr-FR')).toBe('$US');
155
- expect(currencySymbol('USD', 'en-GB')).toBe('US$');
156
- });
157
-
158
- it('JPY at ja-JP is the FULLWIDTH yen sign, a different codepoint from `en`', () => {
159
- // U+FFE5 vs U+00A5 — visually near-identical, so written as escapes.
160
- expect(currencySymbol('JPY', 'en')).toBe('\u00a5');
161
- expect(currencySymbol('JPY', 'ja-JP')).toBe('\uffe5');
162
- expect(currencySymbol('JPY', 'ja-JP')).not.toBe(currencySymbol('JPY', 'en'));
163
- });
164
-
165
- it('the cache is keyed on the PAIR — the first locale to ask must not answer for the rest', () => {
166
- // The bug this pin exists for: copying `currencyFractionDigits`' code-only
167
- // cache key would make these two calls return the same string, whichever
168
- // ran first, for the rest of the process.
169
- const first = currencySymbol('GBP', 'en-GB');
170
- const second = currencySymbol('GBP', 'ar-KW');
171
- expect(first).toBe('£');
172
- expect(second).toBe('UK£');
173
- // …and re-asking returns the cached value, not the other locale's.
174
- expect(currencySymbol('GBP', 'en-GB')).toBe('£');
175
- });
176
- });
177
-
178
- describe('currencySymbol() — neither a bad code nor a bad locale may escape as a throw', () => {
179
- it('an invalid currency code falls back to the code as given', () => {
180
- // `Intl` throws `RangeError: Invalid currency code` for these. The fallback
181
- // is the code itself, which is both what the ternary produced and what
182
- // `formatAmount`'s own bad-code branch renders beside it.
183
- expect(currencySymbol('not-a-code', 'en')).toBe('not-a-code');
184
- expect(currencySymbol('USDD', 'en')).toBe('USDD');
185
- });
186
-
187
- it('a malformed locale tag falls back to the code rather than crashing the widget', () => {
188
- // `Intl` throws `RangeError: Incorrect locale information provided` for
189
- // `en_US` (underscore) — the failure mode `currencyFractionDigits` dodged
190
- // by dropping the locale entirely, which this helper cannot do.
191
- expect(currencySymbol('USD', 'en_US')).toBe('USD');
192
- });
193
-
194
- it('the widget renders the code instead of throwing when the code is junk', () => {
195
- const { container } = renderField({ currency: 'not-a-code' });
196
- expect(adornment(container)).toBe('not-a-code');
197
- });
198
- });
@@ -1,82 +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
- import { describe, it, expect } from 'vitest';
10
- import React from 'react';
11
- import { render, screen } from '@testing-library/react';
12
- import '@testing-library/jest-dom';
13
- import { DateCellRenderer, formatRelativeDate } from '../index';
14
-
15
- function daysAgo(n: number): string {
16
- const d = new Date();
17
- d.setDate(d.getDate() - n);
18
- return d.toISOString();
19
- }
20
-
21
- describe('formatRelativeDate', () => {
22
- it('renders "Overdue Nd" only when dueLike is set', () => {
23
- expect(formatRelativeDate(daysAgo(3), { dueLike: true })).toBe('Overdue 3d');
24
- });
25
-
26
- it('renders neutral "N days ago" for a past date that is not due-like', () => {
27
- expect(formatRelativeDate(daysAgo(3))).toBe('3 days ago');
28
- expect(formatRelativeDate(daysAgo(3), { dueLike: false })).toBe('3 days ago');
29
- });
30
-
31
- it('localizes the near-today window per locale (framework#3040)', () => {
32
- expect(formatRelativeDate(daysAgo(0), { locale: 'zh-CN' })).toBe('今天');
33
- expect(formatRelativeDate(daysAgo(-1), { locale: 'zh-CN' })).toBe('明天');
34
- expect(formatRelativeDate(daysAgo(1), { locale: 'zh-CN' })).toBe('昨天');
35
- expect(formatRelativeDate(daysAgo(-3), { locale: 'zh-CN' })).toBe('3天后');
36
- expect(formatRelativeDate(daysAgo(3), { locale: 'zh-CN' })).toBe('3天前');
37
- // English output is sentence-cased.
38
- expect(formatRelativeDate(daysAgo(-1))).toBe('Tomorrow');
39
- expect(formatRelativeDate(daysAgo(-3))).toBe('In 3 days');
40
- });
41
-
42
- it('resolves the overdue phrase through the i18n translate fn when provided', () => {
43
- const t = (key: string, params?: Record<string, unknown>) =>
44
- key === 'fields.relativeDate.overdue' ? `逾期 ${params?.count} 天` : key;
45
- expect(formatRelativeDate(daysAgo(3), { dueLike: true, t })).toBe('逾期 3 天');
46
- // A t() that misses (returns the key) keeps the English fallback.
47
- expect(formatRelativeDate(daysAgo(3), { dueLike: true, t: (k) => k })).toBe('Overdue 3d');
48
- });
49
-
50
- it('degrades to English when the locale tag is invalid', () => {
51
- expect(formatRelativeDate(daysAgo(3), { locale: 'not a locale' })).toBe('3 days ago');
52
- });
53
- });
54
-
55
- /**
56
- * Nothing in this file mounts an `I18nProvider`, and that is load-bearing
57
- * rather than incidental (objectui#4468): these cases are the pin on
58
- * `useDisplayLocale()`'s LAST resort — the concrete `'en'` a renderer resolves
59
- * to when neither a tenant locale nor an active UI language exists. The
60
- * session-locale cases live in `date-locale-channel.test.tsx`; mounting a
61
- * provider here would leave react-i18next's global instance on that language
62
- * and quietly turn these into assertions about test ordering.
63
- */
64
- describe('DateCellRenderer', () => {
65
- it('does not label a past start_date as "Overdue" (regression: field-role-agnostic formatter)', () => {
66
- render(<DateCellRenderer value={daysAgo(6)} field={{ type: 'date', name: 'start_date' } as any} />);
67
- expect(screen.getByText('6 days ago')).toBeInTheDocument();
68
- expect(screen.queryByText(/Overdue/)).not.toBeInTheDocument();
69
- });
70
-
71
- it('labels a past due_date as "Overdue Nd" and colors it red', () => {
72
- render(<DateCellRenderer value={daysAgo(6)} field={{ type: 'date', name: 'due_date' } as any} />);
73
- const el = screen.getByText('Overdue 6d');
74
- expect(el).toBeInTheDocument();
75
- expect(el.className).toMatch(/text-red-600/);
76
- });
77
-
78
- it('respects an explicit dueLike:true override regardless of field name', () => {
79
- render(<DateCellRenderer value={daysAgo(2)} field={{ type: 'date', name: 'end_date', dueLike: true } as any} />);
80
- expect(screen.getByText('Overdue 2d')).toBeInTheDocument();
81
- });
82
- });