@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,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
- });