@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,632 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
- import React from 'react';
4
- import { UploadProvider } from '@object-ui/providers';
5
- import { GridField, LineItemsField, sumColumn, lookupAutofillPatch } from './GridField';
6
-
7
- const columns = [
8
- { field: 'description', label: 'Description', type: 'text' as const },
9
- { field: 'amount', label: 'Amount', type: 'currency' as const },
10
- ];
11
-
12
- const field = { columns, total_field: 'amount' } as any;
13
-
14
- describe('GridField / LineItemsField — editable line items', () => {
15
- it('is exported under both names', () => {
16
- expect(LineItemsField).toBe(GridField);
17
- });
18
-
19
- it('renders a column header per config and an empty hint', () => {
20
- render(<GridField value={[]} onChange={() => {}} field={field} />);
21
- expect(screen.getByText('Description')).toBeTruthy();
22
- expect(screen.getByText('Amount')).toBeTruthy();
23
- expect(screen.getByRole('button', { name: /Add line/i })).toBeTruthy();
24
- });
25
-
26
- it('Add line appends a blank row keyed by columns', () => {
27
- const onChange = vi.fn();
28
- render(<GridField value={[]} onChange={onChange} field={field} />);
29
- fireEvent.click(screen.getByRole('button', { name: /Add line/i }));
30
- expect(onChange).toHaveBeenCalledWith([{ description: null, amount: null }]);
31
- });
32
-
33
- describe('column chooser (defaultHidden columns)', () => {
34
- const withHidden = {
35
- columns: [
36
- { field: 'title', label: 'Title', type: 'text' as const, required: true },
37
- { field: 'amount', label: 'Amount', type: 'currency' as const },
38
- { field: 'notes', label: 'Notes', type: 'text' as const, defaultHidden: true },
39
- ],
40
- } as any;
41
-
42
- it('hides defaultHidden columns by default but keeps required/visible ones', () => {
43
- render(<GridField value={[]} onChange={() => {}} field={withHidden} />);
44
- expect(screen.getByText('Title')).toBeTruthy();
45
- expect(screen.getByText('Amount')).toBeTruthy();
46
- expect(screen.queryByText('Notes')).toBeNull(); // collapsed into the chooser
47
- expect(screen.getByTestId('line-items-columns')).toBeTruthy();
48
- });
49
-
50
- it('reveals an optional column when toggled in the chooser', () => {
51
- render(<GridField value={[]} onChange={() => {}} field={withHidden} />);
52
- fireEvent.click(screen.getByTestId('line-items-columns'));
53
- fireEvent.click(screen.getByLabelText('Notes'));
54
- expect(screen.getAllByText('Notes').length).toBeGreaterThan(0);
55
- });
56
-
57
- it('shows no chooser when there are no optional columns', () => {
58
- render(<GridField value={[]} onChange={() => {}} field={field} />);
59
- expect(screen.queryByTestId('line-items-columns')).toBeNull();
60
- });
61
- });
62
-
63
- describe('list mode (displayMode="list" — form-factor for fat children)', () => {
64
- const listField = {
65
- columns: [
66
- { field: 'title', label: 'Title', type: 'text' as const, required: true },
67
- { field: 'status', label: 'Status', type: 'select' as const, options: [{ label: 'To Do', value: 'todo' }] },
68
- ],
69
- } as any;
70
-
71
- it('renders rows read-only (no cell inputs) and an Add button', () => {
72
- const onAdd = vi.fn();
73
- render(
74
- <GridField
75
- value={[{ title: 'Ship it', status: 'todo' }]}
76
- onChange={() => {}}
77
- field={listField}
78
- displayMode="list"
79
- onRowExpand={() => {}}
80
- onAdd={onAdd}
81
- />,
82
- );
83
- // Read-only display: the status renders its option label, not a combobox.
84
- expect(screen.getByText('To Do')).toBeTruthy();
85
- expect(screen.queryByLabelText('Title')).toBeNull(); // no editable input
86
- expect(screen.getByRole('button', { name: /Open row/i })).toBeTruthy(); // per-row edit
87
- });
88
-
89
- it('Add calls onAdd (host opens the full form) instead of inserting a blank row', () => {
90
- const onAdd = vi.fn();
91
- const onChange = vi.fn();
92
- render(
93
- <GridField value={[]} onChange={onChange} field={listField} displayMode="list" onRowExpand={() => {}} onAdd={onAdd} />,
94
- );
95
- fireEvent.click(screen.getByTestId('line-items-add'));
96
- expect(onAdd).toHaveBeenCalledTimes(1);
97
- expect(onChange).not.toHaveBeenCalled(); // did NOT insert a blank inline row
98
- });
99
- });
100
-
101
- it('editing a text cell emits the raw string', () => {
102
- const onChange = vi.fn();
103
- render(<GridField value={[{ description: '', amount: null }]} onChange={onChange} field={field} />);
104
- // [0] = the data row ([1] would be the always-present trailing ghost row).
105
- fireEvent.change(screen.getAllByLabelText('Description')[0], { target: { value: 'Taxi' } });
106
- expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', amount: null }]);
107
- });
108
-
109
- it('editing a currency cell coerces to a number', () => {
110
- const onChange = vi.fn();
111
- render(<GridField value={[{ description: 'Taxi', amount: null }]} onChange={onChange} field={field} />);
112
- fireEvent.change(screen.getAllByLabelText('Amount')[0], { target: { value: '42.5' } });
113
- expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', amount: 42.5 }]);
114
- });
115
-
116
- /**
117
- * objectui#3566 — the sub-grid face of #3127. `<input type="date">` accepts
118
- * exactly `YYYY-MM-DD` and rejects every other shape SILENTLY: the attribute
119
- * still lands in the DOM, so the value looks present in the markup, while
120
- * `.value` reads back `''` and the cell paints its empty placeholder. Since
121
- * the API hands back `2026-06-17T00:00:00.000Z` for a `date` field, every
122
- * date cell in an inline child grid rendered blank on a row that has a value.
123
- * Asserting `.value` (never `getAttribute('value')`) is the only way to see
124
- * this bug at all.
125
- */
126
- describe('date columns echo the stored value (#3566)', () => {
127
- const dateField = {
128
- columns: [
129
- { field: 'description', label: 'Description', type: 'text' as const },
130
- { field: 'incurred_on', label: 'Incurred On', type: 'date' as const },
131
- ],
132
- } as any;
133
-
134
- // Both spellings the API may hand back for a `date` field. The bare form
135
- // must survive VERBATIM — re-parsing it as UTC midnight and reading local
136
- // calendar components back out would move it to the 16th anywhere west of
137
- // Greenwich, which is the off-by-one this fix must not introduce.
138
- it.each(['2026-06-17', '2026-06-17T00:00:00.000Z'])(
139
- 'shows the stored calendar day for %s instead of an empty cell',
140
- (stored) => {
141
- render(
142
- <GridField value={[{ description: 'Taxi', incurred_on: stored }]} onChange={() => {}} field={dateField} />,
143
- );
144
- const cell = screen.getAllByLabelText('Incurred On')[0] as HTMLInputElement;
145
- expect(cell.type).toBe('date');
146
- expect(cell.value).toBe('2026-06-17');
147
- },
148
- );
149
-
150
- it('leaves an empty date cell empty', () => {
151
- render(<GridField value={[{ description: 'Taxi', incurred_on: null }]} onChange={() => {}} field={dateField} />);
152
- expect((screen.getAllByLabelText('Incurred On')[0] as HTMLInputElement).value).toBe('');
153
- });
154
-
155
- it('writes back the control\'s own YYYY-MM-DD, unchanged by the read fix', () => {
156
- // No paired conversion on the write side: `onChange` hands over exactly
157
- // what `<input type="date">` produces, matching DateField's contract. A
158
- // conversion here would put read and write on different bases.
159
- const onChange = vi.fn();
160
- render(
161
- <GridField
162
- value={[{ description: 'Taxi', incurred_on: '2026-06-17T00:00:00.000Z' }]}
163
- onChange={onChange}
164
- field={dateField}
165
- />,
166
- );
167
- fireEvent.change(screen.getAllByLabelText('Incurred On')[0], { target: { value: '2026-06-18' } });
168
- expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', incurred_on: '2026-06-18' }]);
169
- });
170
- });
171
-
172
- /**
173
- * objectui#3569 — the type collapse behind #3566's symptom. `datetime` and
174
- * `time` used to be mapped onto the ONE `date` column type, so a datetime
175
- * cell rendered `<input type="date">`. That is not merely an under-render:
176
- * the control emits a bare `YYYY-MM-DD`, so a user who only re-picked the
177
- * DAY silently wrote the record's time component out of existence. The
178
- * falsifiable claim of these tests is therefore the TIME COMPONENT, not the
179
- * control's markup.
180
- *
181
- * Local-wall-clock expectations are computed here from `Date` rather than
182
- * hard-coded, so the suite asserts the same thing in every timezone (and the
183
- * day-shift arithmetic is done in LOCAL parts, so it is DST-safe too).
184
- */
185
- describe('datetime / time columns get their own control (#3569)', () => {
186
- const pad2 = (n: number) => String(n).padStart(2, '0');
187
- /** The local wall clock an `<input type="datetime-local">` must show for `iso`. */
188
- const localInputValue = (iso: string | Date) => {
189
- const d = iso instanceof Date ? iso : new Date(iso);
190
- return (
191
- `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` +
192
- `T${pad2(d.getHours())}:${pad2(d.getMinutes())}`
193
- );
194
- };
195
-
196
- const temporalField = {
197
- columns: [
198
- { field: 'merchant', label: 'Merchant', type: 'text' as const },
199
- { field: 'incurred_on', label: 'Incurred On', type: 'date' as const },
200
- { field: 'incurred_at', label: 'Incurred At', type: 'datetime' as const },
201
- { field: 'started_at', label: 'Started At', type: 'time' as const },
202
- ],
203
- } as any;
204
-
205
- const STORED_ISO = '2026-06-17T14:30:00.000Z';
206
-
207
- it('renders a datetime column as datetime-local, echoing the stored instant', () => {
208
- render(
209
- <GridField
210
- value={[{ merchant: 'Chipotle', incurred_at: STORED_ISO }]}
211
- onChange={() => {}}
212
- field={temporalField}
213
- />,
214
- );
215
- const cell = screen.getAllByLabelText('Incurred At')[0] as HTMLInputElement;
216
- expect(cell.type).toBe('datetime-local');
217
- expect(cell.value).toBe(localInputValue(STORED_ISO));
218
- });
219
-
220
- it('does NOT collapse the datetime column onto the date control', () => {
221
- render(
222
- <GridField
223
- value={[{ merchant: 'Chipotle', incurred_on: '2026-06-17', incurred_at: STORED_ISO }]}
224
- onChange={() => {}}
225
- field={temporalField}
226
- />,
227
- );
228
- // The sibling `date` column keeps its own control — the two are distinct
229
- // now, which is exactly what was lost before.
230
- expect((screen.getAllByLabelText('Incurred On')[0] as HTMLInputElement).type).toBe('date');
231
- expect((screen.getAllByLabelText('Incurred At')[0] as HTMLInputElement).type).toBe('datetime-local');
232
- });
233
-
234
- /** THE regression: editing only the day must not destroy the time. */
235
- it('keeps the time component when only the day is edited', () => {
236
- const onChange = vi.fn();
237
- render(
238
- <GridField
239
- value={[{ merchant: 'Chipotle', incurred_at: STORED_ISO }]}
240
- onChange={onChange}
241
- field={temporalField}
242
- />,
243
- );
244
- const cell = screen.getAllByLabelText('Incurred At')[0] as HTMLInputElement;
245
-
246
- // What a user does to correct a date: move the day forward one, leave
247
- // the wall clock alone.
248
- const before = new Date(STORED_ISO);
249
- const nextDay = new Date(before);
250
- nextDay.setDate(nextDay.getDate() + 1);
251
- fireEvent.change(cell, { target: { value: localInputValue(nextDay) } });
252
-
253
- const emitted = onChange.mock.calls[0][0][0].incurred_at;
254
- const after = new Date(emitted);
255
- expect(after.getHours()).toBe(before.getHours());
256
- expect(after.getMinutes()).toBe(before.getMinutes());
257
- // Round-tripped as ISO-8601 (`fromDateTimeInputValue`), NOT the control's
258
- // naive zone-less string — read and write share one basis.
259
- expect(emitted).toBe(nextDay.toISOString());
260
- });
261
-
262
- it('leaves an empty datetime cell empty', () => {
263
- render(
264
- <GridField value={[{ merchant: 'Chipotle', incurred_at: null }]} onChange={() => {}} field={temporalField} />,
265
- );
266
- expect((screen.getAllByLabelText('Incurred At')[0] as HTMLInputElement).value).toBe('');
267
- });
268
-
269
- it('renders a time column as a time control and round-trips HH:mm verbatim', () => {
270
- const onChange = vi.fn();
271
- render(
272
- <GridField
273
- value={[{ merchant: 'Chipotle', started_at: '14:30' }]}
274
- onChange={onChange}
275
- field={temporalField}
276
- />,
277
- );
278
- const cell = screen.getAllByLabelText('Started At')[0] as HTMLInputElement;
279
- expect(cell.type).toBe('time');
280
- expect(cell.value).toBe('14:30');
281
- // A `time` value is a zone-less wall clock — stored exactly as typed, the
282
- // same contract `TimeField` follows. No conversion in either direction.
283
- fireEvent.change(cell, { target: { value: '09:15' } });
284
- expect(onChange).toHaveBeenCalledWith([{ merchant: 'Chipotle', started_at: '09:15' }]);
285
- });
286
-
287
- /**
288
- * The read-only faces the issue calls out: `displayText()` and the
289
- * read-only table both fell through to `String(value)`, printing the raw
290
- * `2026-06-17T00:00:00.000Z` on screen. Neither could be fixed before the
291
- * collapse was undone — with one column type the renderer had no way to
292
- * know whether to show a day or a day plus a time.
293
- */
294
- describe('read-only display formats each temporal type as itself', () => {
295
- const row = { merchant: 'Chipotle', incurred_on: '2026-06-17T00:00:00.000Z', incurred_at: STORED_ISO, started_at: '14:30' };
296
- // Spelled `'en'` rather than bare `toLocaleDateString()` (objectui#4468):
297
- // the bare call reads the MACHINE's locale, so these pins agreed with the
298
- // widget only by the accident of a CI runner set to en-US — and they
299
- // would have kept agreeing with it after the widget started following the
300
- // session locale, which is the bug this suite has to be able to see.
301
- // `'en'` is what `useDisplayLocale()` resolves to with no provider.
302
- const expectedDay = new Date(2026, 5, 17).toLocaleDateString('en');
303
- const dt = new Date(STORED_ISO);
304
- const expectedInstant = `${dt.toLocaleDateString('en')} ${dt.toLocaleTimeString('en')}`;
305
-
306
- it('formats date / datetime / time in the read-only table', () => {
307
- render(<GridField value={[row]} onChange={() => {}} field={temporalField} readonly />);
308
- const table = screen.getByTestId('line-items-readonly');
309
- expect(table.textContent).toContain(expectedDay);
310
- expect(table.textContent).toContain(expectedInstant);
311
- expect(table.textContent).toContain('14:30');
312
- // The raw stored ISO must never reach the screen.
313
- expect(table.textContent).not.toContain('2026-06-17T00:00:00.000Z');
314
- expect(table.textContent).not.toContain(STORED_ISO);
315
- });
316
-
317
- it('formats them the same way in list display mode', () => {
318
- const { container } = render(
319
- <GridField value={[row]} onChange={() => {}} field={temporalField} displayMode="list" onRowExpand={() => {}} />,
320
- );
321
- expect(container.textContent).toContain(expectedDay);
322
- expect(container.textContent).toContain(expectedInstant);
323
- expect(container.textContent).not.toContain(STORED_ISO);
324
- });
325
-
326
- it('shows a value it cannot parse rather than "Invalid Date"', () => {
327
- render(
328
- <GridField value={[{ merchant: 'X', incurred_at: 'not-a-date' }]} onChange={() => {}} field={temporalField} readonly />,
329
- );
330
- expect(screen.getByTestId('line-items-readonly').textContent).toContain('not-a-date');
331
- });
332
-
333
- // The `zh` half of this — the sub-grid following the SESSION locale
334
- // (objectui#4468) — lives in `__tests__/date-locale-channel.test.tsx`,
335
- // not here. Mounting an `I18nProvider` anywhere in THIS file changes what
336
- // the provider-less renders further down resolve, and the file-columns
337
- // chip test reads a translated `aria-label`; keeping the provider in its
338
- // own file keeps that coupling out of the way.
339
- });
340
- });
341
-
342
- describe('trailing ghost row (start-with-one + auto-append)', () => {
343
- it('renders a trailing empty row so an empty grid still has one input line', () => {
344
- render(<GridField value={[]} onChange={() => {}} field={field} />);
345
- // No "No items" empty-state in grid mode — the ghost row IS the first line.
346
- expect(screen.getByText('Description')).toBeTruthy();
347
- expect(screen.getAllByLabelText('Description')).toHaveLength(1); // just the ghost
348
- });
349
-
350
- it('typing in the ghost row materialises a new row (no Add click needed)', () => {
351
- const onChange = vi.fn();
352
- render(<GridField value={[{ description: 'A', amount: 1 }]} onChange={onChange} field={field} />);
353
- const inputs = screen.getAllByLabelText('Description');
354
- expect(inputs).toHaveLength(2); // data row + ghost
355
- fireEvent.change(inputs[1], { target: { value: 'B' } }); // type in the ghost
356
- expect(onChange).toHaveBeenCalledWith([
357
- { description: 'A', amount: 1 },
358
- { description: 'B', amount: null },
359
- ]);
360
- });
361
- });
362
-
363
- describe('computed columns (amount = qty × unit_price)', () => {
364
- const computedField = {
365
- columns: [
366
- { field: 'product', label: 'Product', type: 'text' as const },
367
- { field: 'quantity', label: 'Qty', type: 'number' as const },
368
- { field: 'unit_price', label: 'Unit Price', type: 'currency' as const },
369
- { field: 'amount', label: 'Amount', type: 'currency' as const, computed: true, expr: 'record.quantity * record.unit_price', scale: 2 },
370
- ],
371
- total_field: 'amount',
372
- } as any;
373
-
374
- it('renders a computed column read-only (no input) and recomputes on edit', () => {
375
- const onChange = vi.fn();
376
- render(<GridField value={[{ product: 'Widget', quantity: 3, unit_price: 10, amount: 30 }]} onChange={onChange} field={computedField} />);
377
- // Amount is display-only — there is no editable Amount cell.
378
- expect(screen.queryByLabelText('Amount')).toBeNull();
379
- // Editing quantity recomputes amount in the emitted row.
380
- fireEvent.change(screen.getAllByLabelText('Qty')[0], { target: { value: '4' } });
381
- expect(onChange).toHaveBeenCalledWith([{ product: 'Widget', quantity: 4, unit_price: 10, amount: 40 }]);
382
- });
383
-
384
- it('shows a dash for a computed cell whose inputs are blank', () => {
385
- render(<GridField value={[{ product: 'Widget', quantity: null, unit_price: null, amount: null }]} onChange={() => {}} field={computedField} />);
386
- // The computed amount cell reads "—" until its inputs exist.
387
- expect(screen.getAllByText('—').length).toBeGreaterThan(0);
388
- });
389
- });
390
-
391
- describe('keyboard navigation', () => {
392
- it('Enter moves focus to the same column in the next row', () => {
393
- render(<GridField value={[{ description: 'A', amount: 1 }, { description: 'B', amount: 2 }]} onChange={() => {}} field={field} />);
394
- const row0 = screen.getAllByLabelText('Description')[0];
395
- row0.focus();
396
- fireEvent.keyDown(row0, { key: 'Enter' });
397
- expect(document.activeElement).toBe(screen.getAllByLabelText('Description')[1]);
398
- });
399
- });
400
-
401
- it('removing a row emits the array without it', () => {
402
- const onChange = vi.fn();
403
- render(
404
- <GridField
405
- value={[{ description: 'A', amount: 1 }, { description: 'B', amount: 2 }]}
406
- onChange={onChange}
407
- field={field}
408
- />,
409
- );
410
- const removeButtons = screen.getAllByLabelText('Remove row');
411
- fireEvent.click(removeButtons[0]);
412
- expect(onChange).toHaveBeenCalledWith([{ description: 'B', amount: 2 }]);
413
- });
414
-
415
- it('row action buttons are always visible in grid mode (not hover-revealed)', () => {
416
- render(
417
- <GridField
418
- value={[{ description: 'A', amount: 1 }]}
419
- onChange={() => {}}
420
- field={field}
421
- />,
422
- );
423
- // Grid-mode rows previously gated these behind opacity-0/group-hover, so they
424
- // were invisible until hover and unreachable on touch — they must always show.
425
- expect(screen.getByLabelText('Remove row').className).not.toContain('opacity-0');
426
- expect(screen.getByTestId('line-items-duplicate-0').className).not.toContain('opacity-0');
427
- });
428
-
429
- it('readonly mode shows values and a summed total footer', () => {
430
- render(
431
- <GridField
432
- value={[{ description: 'A', amount: 10 }, { description: 'B', amount: 20 }]}
433
- onChange={() => {}}
434
- field={field}
435
- readonly
436
- />,
437
- );
438
- expect(screen.getByTestId('line-items-readonly')).toBeTruthy();
439
- expect(screen.getByText('Total')).toBeTruthy();
440
- expect(screen.getByText('30')).toBeTruthy();
441
- });
442
-
443
- describe('lookupAutofillPatch (item typeahead auto-fill)', () => {
444
- const cols = [
445
- { field: 'product', type: 'lookup' as const, reference: 'product' },
446
- { field: 'description', type: 'text' as const },
447
- { field: 'quantity', type: 'number' as const },
448
- { field: 'unit_price', type: 'currency' as const },
449
- { field: 'amount', type: 'currency' as const, computed: true, expr: 'record.quantity * record.unit_price' },
450
- ];
451
- const product = { value: 'p1', label: 'Widget A', name: 'Widget A', description: 'Standard widget', unit_price: 29.99, sku: 'WIDGET-A' };
452
-
453
- it('sets the FK id and copies same-named sibling fields from the record', () => {
454
- const patch = lookupAutofillPatch(cols, cols[0], product);
455
- expect(patch).toEqual({ product: 'p1', description: 'Standard widget', unit_price: 29.99 });
456
- // quantity (not on the record) and computed amount are left to the row/compute.
457
- expect(patch).not.toHaveProperty('quantity');
458
- expect(patch).not.toHaveProperty('amount');
459
- });
460
-
461
- it('copies only the FK id when autofill is disabled', () => {
462
- const patch = lookupAutofillPatch(cols, { ...cols[0], autofill: false }, product);
463
- expect(patch).toEqual({ product: 'p1' });
464
- });
465
- });
466
-
467
- describe('P1 affordances (duplicate / validation)', () => {
468
- it('duplicates a row (id stripped) directly below the original', () => {
469
- const onChange = vi.fn();
470
- render(<GridField value={[{ id: 'r1', description: 'A', amount: 5 }]} onChange={onChange} field={field} />);
471
- fireEvent.click(screen.getByTestId('line-items-duplicate-0'));
472
- expect(onChange).toHaveBeenCalledWith([
473
- { id: 'r1', description: 'A', amount: 5 },
474
- { description: 'A', amount: 5 }, // copy without the id → persists as a new record
475
- ]);
476
- });
477
-
478
- it('flags a required, empty cell on a real row (not the ghost row)', () => {
479
- const reqField = { columns: [{ field: 'description', label: 'Description', type: 'text' as const, required: true }] } as any;
480
- render(<GridField value={[{ description: '' }]} onChange={() => {}} field={reqField} />);
481
- // The data row's required-empty cell is flagged...
482
- expect(screen.getByTestId('line-items-invalid-0-description')).toBeTruthy();
483
- // ...but the trailing ghost row (index 1) is not.
484
- expect(screen.queryByTestId('line-items-invalid-1-description')).toBeNull();
485
- });
486
- });
487
-
488
- describe('file columns (upload in a grid cell — #2360)', () => {
489
- const fileField = {
490
- columns: [
491
- { field: 'description', label: 'Description', type: 'text' as const },
492
- { field: 'receipt', label: 'Receipt', type: 'file' as const },
493
- ],
494
- } as any;
495
-
496
- it('renders a real upload control in the cell, not a text input', () => {
497
- render(<GridField value={[{ description: 'Taxi', receipt: null }]} onChange={() => {}} field={fileField} />);
498
- // One upload button per row (data row + ghost), backed by a native file input.
499
- expect(screen.getAllByRole('button', { name: 'Receipt' }).length).toBeGreaterThan(0);
500
- expect(document.querySelector('input[type="file"]')).toBeTruthy();
501
- expect(screen.queryByRole('textbox', { name: 'Receipt' })).toBeNull();
502
- });
503
-
504
- it('uploads a picked file and writes the file object into the row', async () => {
505
- const onChange = vi.fn();
506
- const adapter = {
507
- name: 'test',
508
- upload: async (f: File) => ({ url: 'https://cdn/receipt.png', name: f.name, size: f.size, mimeType: f.type }),
509
- };
510
- render(
511
- <UploadProvider adapter={adapter as any}>
512
- <GridField value={[{ description: 'Taxi', receipt: null }]} onChange={onChange} field={fileField} />
513
- </UploadProvider>,
514
- );
515
- const file = new File(['x'], 'receipt.png', { type: 'image/png' });
516
- const input = document.querySelectorAll('input[type="file"]')[0] as HTMLInputElement;
517
- fireEvent.change(input, { target: { files: [file] } });
518
- await waitFor(() => expect(onChange).toHaveBeenCalled());
519
- expect(onChange).toHaveBeenCalledWith([
520
- {
521
- description: 'Taxi',
522
- receipt: {
523
- name: 'receipt.png',
524
- original_name: 'receipt.png',
525
- size: file.size,
526
- mime_type: 'image/png',
527
- url: 'https://cdn/receipt.png',
528
- },
529
- },
530
- ]);
531
- });
532
-
533
- it('shows an uploaded file as a removable chip', () => {
534
- const onChange = vi.fn();
535
- render(
536
- <GridField
537
- value={[{ description: 'Taxi', receipt: { name: 'receipt.png', mime_type: 'image/png' } }]}
538
- onChange={onChange}
539
- field={fileField}
540
- />,
541
- );
542
- expect(screen.getByText('receipt.png')).toBeTruthy();
543
- fireEvent.click(screen.getByLabelText('Remove receipt.png'));
544
- expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', receipt: null }]);
545
- });
546
-
547
- it('passes the column accept list to the native picker', () => {
548
- const withAccept = {
549
- columns: [{ field: 'receipt', label: 'Receipt', type: 'file' as const, accept: ['image/*', '.pdf'] }],
550
- } as any;
551
- render(<GridField value={[]} onChange={() => {}} field={withAccept} />);
552
- const input = document.querySelector('input[type="file"]') as HTMLInputElement;
553
- expect(input.getAttribute('accept')).toBe('image/*,.pdf');
554
- });
555
-
556
- it('renders the file name read-only in readonly mode', () => {
557
- render(
558
- <GridField
559
- value={[{ description: 'Taxi', receipt: { name: 'receipt.png' } }]}
560
- onChange={() => {}}
561
- field={fileField}
562
- readonly
563
- />,
564
- );
565
- expect(screen.getByText('receipt.png')).toBeTruthy();
566
- expect(document.querySelector('input[type="file"]')).toBeNull();
567
- });
568
- });
569
-
570
- it('sumColumn ignores blanks and NaN', () => {
571
- expect(sumColumn([{ amount: 1 }, { amount: 2 }, { amount: null }], 'amount')).toBe(3);
572
- });
573
-
574
- describe('parent-scoped conditional rules (B2 follow-up — "paid invoice → lock lines")', () => {
575
- const lockField = {
576
- columns: [
577
- { field: 'product', label: 'Product', type: 'text' as const },
578
- { field: 'qty', label: 'Qty', type: 'number' as const, readonlyWhen: "parent.status == 'paid'" },
579
- { field: 'unit_price', label: 'Unit Price', type: 'currency' as const, readonlyWhen: "parent.status == 'paid'" },
580
- ],
581
- } as any;
582
-
583
- it('leaves cells editable when the parent rule is FALSE', () => {
584
- render(
585
- <GridField
586
- value={[{ product: 'Widget', qty: 2, unit_price: 10 }]}
587
- onChange={() => {}}
588
- field={lockField}
589
- contextRecord={{ status: 'draft' }}
590
- />,
591
- );
592
- expect((screen.getAllByLabelText('Qty')[0] as HTMLInputElement).disabled).toBe(false);
593
- expect((screen.getAllByLabelText('Unit Price')[0] as HTMLInputElement).disabled).toBe(false);
594
- });
595
-
596
- it('locks cells whose readonlyWhen references the parent header', () => {
597
- render(
598
- <GridField
599
- value={[{ product: 'Widget', qty: 2, unit_price: 10 }]}
600
- onChange={() => {}}
601
- field={lockField}
602
- contextRecord={{ status: 'paid' }}
603
- />,
604
- );
605
- // The header is paid → quantity / unit price lock; product (no rule) stays editable.
606
- expect((screen.getAllByLabelText('Qty')[0] as HTMLInputElement).disabled).toBe(true);
607
- expect((screen.getAllByLabelText('Unit Price')[0] as HTMLInputElement).disabled).toBe(true);
608
- expect((screen.getAllByLabelText('Product')[0] as HTMLInputElement).disabled).toBe(false);
609
- });
610
-
611
- it('re-evaluates per row, mixing the parent header with row data', () => {
612
- const rowRule = {
613
- columns: [
614
- { field: 'qty', label: 'Qty', type: 'number' as const },
615
- // Locks only when the header is paid AND this row is already invoiced.
616
- { field: 'note', label: 'Note', type: 'text' as const, readonlyWhen: "parent.status == 'paid' && record.invoiced == true" },
617
- ],
618
- } as any;
619
- render(
620
- <GridField
621
- value={[{ qty: 1, note: 'a', invoiced: true }, { qty: 2, note: 'b', invoiced: false }]}
622
- onChange={() => {}}
623
- field={rowRule}
624
- contextRecord={{ status: 'paid' }}
625
- />,
626
- );
627
- const notes = screen.getAllByLabelText('Note') as HTMLInputElement[];
628
- expect(notes[0].disabled).toBe(true); // invoiced row → locked
629
- expect(notes[1].disabled).toBe(false); // not-yet-invoiced row → editable
630
- });
631
- });
632
- });