@object-ui/fields 17.3.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 (265) hide show
  1. package/CHANGELOG.md +1655 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +23 -3
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +35 -1
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.css +651 -0
  8. package/dist/index.d.ts +474 -85
  9. package/dist/index.js +5418 -5023
  10. package/dist/widgets/AddressField.d.ts +4 -12
  11. package/dist/widgets/AutoNumberField.d.ts +1 -1
  12. package/dist/widgets/AvatarField.d.ts +1 -1
  13. package/dist/widgets/BooleanField.d.ts +1 -1
  14. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  15. package/dist/widgets/CheckboxesField.d.ts +1 -1
  16. package/dist/widgets/CodeField.d.ts +1 -1
  17. package/dist/widgets/ColorField.d.ts +1 -1
  18. package/dist/widgets/CurrencyField.d.ts +1 -1
  19. package/dist/widgets/DateField.d.ts +1 -1
  20. package/dist/widgets/DateTimeField.d.ts +1 -1
  21. package/dist/widgets/EmailField.d.ts +1 -1
  22. package/dist/widgets/FileField.d.ts +18 -2
  23. package/dist/widgets/FilterConditionField.d.ts +28 -1
  24. package/dist/widgets/FormulaField.d.ts +1 -1
  25. package/dist/widgets/FullscreenFieldEditor.d.ts +49 -152
  26. package/dist/widgets/GeolocationField.d.ts +1 -1
  27. package/dist/widgets/GridField.d.ts +68 -6
  28. package/dist/widgets/ImageField.d.ts +1 -1
  29. package/dist/widgets/LocationField.d.ts +1 -1
  30. package/dist/widgets/LookupField.d.ts +1 -1
  31. package/dist/widgets/MasterDetailField.d.ts +1 -1
  32. package/dist/widgets/MultiSelectField.d.ts +1 -1
  33. package/dist/widgets/NumberField.d.ts +1 -1
  34. package/dist/widgets/ObjectField.d.ts +1 -1
  35. package/dist/widgets/ObjectRefField.d.ts +1 -1
  36. package/dist/widgets/OptionsEmptyState.d.ts +21 -1
  37. package/dist/widgets/PasswordField.d.ts +1 -1
  38. package/dist/widgets/PercentField.d.ts +1 -1
  39. package/dist/widgets/PhoneField.d.ts +1 -1
  40. package/dist/widgets/QRCodeField.d.ts +1 -1
  41. package/dist/widgets/RadioField.d.ts +1 -1
  42. package/dist/widgets/RatingField.d.ts +1 -1
  43. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  44. package/dist/widgets/RecordPickerDialog.d.ts +34 -10
  45. package/dist/widgets/RichTextField.d.ts +37 -1
  46. package/dist/widgets/SelectField.d.ts +26 -6
  47. package/dist/widgets/SignatureField.d.ts +32 -2
  48. package/dist/widgets/SliderField.d.ts +37 -2
  49. package/dist/widgets/SummaryField.d.ts +1 -1
  50. package/dist/widgets/TagsField.d.ts +1 -1
  51. package/dist/widgets/TextAreaField.d.ts +6 -2
  52. package/dist/widgets/TextField.d.ts +1 -1
  53. package/dist/widgets/TimeField.d.ts +1 -1
  54. package/dist/widgets/UrlField.d.ts +1 -1
  55. package/dist/widgets/UserField.d.ts +1 -1
  56. package/dist/widgets/VectorField.d.ts +1 -1
  57. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  58. package/dist/widgets/address-format.d.ts +88 -0
  59. package/dist/widgets/file-size-guard.d.ts +41 -0
  60. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  61. package/dist/widgets/nativeDateValue.d.ts +37 -0
  62. package/dist/widgets/richTextDisplay.d.ts +80 -0
  63. package/dist/widgets/toDomProps.d.ts +30 -10
  64. package/dist/widgets/toHostGroupProps.d.ts +189 -0
  65. package/dist/widgets/types.d.ts +8 -3
  66. package/dist/widgets/useRecordQuery.d.ts +7 -1
  67. package/package.json +17 -14
  68. package/dist/FieldEditWidget.test.d.ts +0 -8
  69. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  70. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  71. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  72. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  73. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  74. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  75. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  76. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  77. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  78. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  79. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  80. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  81. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  82. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  83. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  84. package/dist/coerce-safe-value.test.d.ts +0 -1
  85. package/dist/complex-widgets.test.d.ts +0 -1
  86. package/dist/datetime-widgets.test.d.ts +0 -1
  87. package/dist/field-type-coverage.test.d.ts +0 -1
  88. package/dist/standard-widgets.test.d.ts +0 -0
  89. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  90. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  91. package/dist/widgets/CharacterCount.d.ts +0 -56
  92. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  93. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  94. package/dist/widgets/GridField.test.d.ts +0 -1
  95. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  96. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  97. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  98. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  99. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  100. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  101. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  102. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  103. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  104. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  105. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  106. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  107. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  108. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  109. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  110. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  111. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  112. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  113. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  114. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  115. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  116. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  117. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  118. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  119. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  120. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  121. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  122. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  123. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  124. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  125. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  126. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  127. package/dist/widgets/file-value.test.d.ts +0 -1
  128. package/dist/widgets/personDisplay.test.d.ts +0 -8
  129. package/dist/widgets/recentLookups.test.d.ts +0 -1
  130. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  131. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
  132. package/src/FieldEditWidget.test.ts +0 -133
  133. package/src/FieldEditWidget.tsx +0 -191
  134. package/src/__tests__/DateCellRenderer.test.tsx +0 -73
  135. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  136. package/src/__tests__/cell-truncation.test.tsx +0 -96
  137. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  138. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  139. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  140. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  141. package/src/__tests__/registry-collision.test.ts +0 -91
  142. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  143. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  144. package/src/__tests__/validation-feedback.test.tsx +0 -361
  145. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  146. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  147. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -598
  148. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  149. package/src/coerce-safe-value.test.ts +0 -34
  150. package/src/complex-widgets.test.tsx +0 -908
  151. package/src/currency.ts +0 -14
  152. package/src/datetime-widgets.test.tsx +0 -93
  153. package/src/field-type-alias.ts +0 -100
  154. package/src/field-type-coverage.test.ts +0 -67
  155. package/src/index.tsx +0 -2503
  156. package/src/standard-widgets.test.tsx +0 -357
  157. package/src/widgets/AddressField.tsx +0 -132
  158. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -92
  159. package/src/widgets/AutoNumberField.tsx +0 -18
  160. package/src/widgets/AvatarField.tsx +0 -117
  161. package/src/widgets/BooleanField.tsx +0 -52
  162. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  163. package/src/widgets/CapabilityMultiSelectField.tsx +0 -229
  164. package/src/widgets/CharacterCount.tsx +0 -263
  165. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  166. package/src/widgets/CheckboxesField.tsx +0 -136
  167. package/src/widgets/CodeField.tsx +0 -41
  168. package/src/widgets/ColorField.tsx +0 -53
  169. package/src/widgets/CurrencyField.tsx +0 -95
  170. package/src/widgets/DateField.tsx +0 -31
  171. package/src/widgets/DateTimeField.tsx +0 -37
  172. package/src/widgets/EmailField.tsx +0 -37
  173. package/src/widgets/FileField.tsx +0 -494
  174. package/src/widgets/FilterConditionField.tsx +0 -454
  175. package/src/widgets/FormulaField.tsx +0 -33
  176. package/src/widgets/FullscreenFieldEditor.tsx +0 -271
  177. package/src/widgets/GeolocationField.tsx +0 -167
  178. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  179. package/src/widgets/GridField.test.tsx +0 -406
  180. package/src/widgets/GridField.tsx +0 -966
  181. package/src/widgets/ImageCropperDialog.tsx +0 -245
  182. package/src/widgets/ImageField.tsx +0 -255
  183. package/src/widgets/ImageLightbox.test.tsx +0 -52
  184. package/src/widgets/ImageLightbox.tsx +0 -108
  185. package/src/widgets/LocationField.tsx +0 -57
  186. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  187. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  188. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  189. package/src/widgets/LookupField.idField.test.tsx +0 -61
  190. package/src/widgets/LookupField.tsx +0 -1291
  191. package/src/widgets/MarkdownContent.tsx +0 -19
  192. package/src/widgets/MasterDetailField.tsx +0 -131
  193. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  194. package/src/widgets/MultiSelectField.tsx +0 -142
  195. package/src/widgets/NumberField.tsx +0 -49
  196. package/src/widgets/ObjectField.tsx +0 -94
  197. package/src/widgets/ObjectRefField.tsx +0 -121
  198. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  199. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  200. package/src/widgets/OptionsEmptyState.tsx +0 -83
  201. package/src/widgets/PasswordField.tsx +0 -49
  202. package/src/widgets/PeoplePicker.test.tsx +0 -360
  203. package/src/widgets/PeoplePicker.tsx +0 -579
  204. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  205. package/src/widgets/PercentField.tsx +0 -95
  206. package/src/widgets/PersonRow.tsx +0 -117
  207. package/src/widgets/PhoneField.tsx +0 -37
  208. package/src/widgets/QRCodeField.tsx +0 -103
  209. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  210. package/src/widgets/RadioField.tsx +0 -111
  211. package/src/widgets/RatingField.tsx +0 -81
  212. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  213. package/src/widgets/RecipientPickerField.tsx +0 -188
  214. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  215. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  216. package/src/widgets/RecordPickerDialog.tsx +0 -1316
  217. package/src/widgets/RichTextField.tsx +0 -176
  218. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  219. package/src/widgets/SelectField.tsx +0 -162
  220. package/src/widgets/SelectionTray.tsx +0 -110
  221. package/src/widgets/SignatureField.tsx +0 -140
  222. package/src/widgets/SliderField.tsx +0 -39
  223. package/src/widgets/SummaryField.tsx +0 -31
  224. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  225. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  226. package/src/widgets/TagsField.tsx +0 -88
  227. package/src/widgets/TextAreaField.tsx +0 -213
  228. package/src/widgets/TextField.tsx +0 -45
  229. package/src/widgets/TimeField.tsx +0 -31
  230. package/src/widgets/UrlField.tsx +0 -46
  231. package/src/widgets/UserField.tsx +0 -58
  232. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  233. package/src/widgets/VectorField.tsx +0 -29
  234. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  235. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -142
  236. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  237. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  238. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  239. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  240. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  241. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  242. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  243. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  244. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  245. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  246. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  247. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  248. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  249. package/src/widgets/deriveLookupColumns.ts +0 -108
  250. package/src/widgets/file-value.test.ts +0 -230
  251. package/src/widgets/file-value.ts +0 -246
  252. package/src/widgets/openNativePicker.ts +0 -18
  253. package/src/widgets/personDisplay.test.ts +0 -113
  254. package/src/widgets/personDisplay.ts +0 -92
  255. package/src/widgets/recentLookups.test.ts +0 -45
  256. package/src/widgets/recentLookups.ts +0 -47
  257. package/src/widgets/toDomProps.ts +0 -175
  258. package/src/widgets/types.ts +0 -242
  259. package/src/widgets/useCascadingOptions.ts +0 -46
  260. package/src/widgets/useFieldTranslation.ts +0 -150
  261. package/src/widgets/useRecordQuery.test.ts +0 -174
  262. package/src/widgets/useRecordQuery.ts +0 -264
  263. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  264. package/src/widgets/useUploadingSignal.ts +0 -20
  265. package/src/withFieldCarrier.tsx +0 -75
@@ -1,406 +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
- describe('trailing ghost row (start-with-one + auto-append)', () => {
117
- it('renders a trailing empty row so an empty grid still has one input line', () => {
118
- render(<GridField value={[]} onChange={() => {}} field={field} />);
119
- // No "No items" empty-state in grid mode — the ghost row IS the first line.
120
- expect(screen.getByText('Description')).toBeTruthy();
121
- expect(screen.getAllByLabelText('Description')).toHaveLength(1); // just the ghost
122
- });
123
-
124
- it('typing in the ghost row materialises a new row (no Add click needed)', () => {
125
- const onChange = vi.fn();
126
- render(<GridField value={[{ description: 'A', amount: 1 }]} onChange={onChange} field={field} />);
127
- const inputs = screen.getAllByLabelText('Description');
128
- expect(inputs).toHaveLength(2); // data row + ghost
129
- fireEvent.change(inputs[1], { target: { value: 'B' } }); // type in the ghost
130
- expect(onChange).toHaveBeenCalledWith([
131
- { description: 'A', amount: 1 },
132
- { description: 'B', amount: null },
133
- ]);
134
- });
135
- });
136
-
137
- describe('computed columns (amount = qty × unit_price)', () => {
138
- const computedField = {
139
- columns: [
140
- { field: 'product', label: 'Product', type: 'text' as const },
141
- { field: 'quantity', label: 'Qty', type: 'number' as const },
142
- { field: 'unit_price', label: 'Unit Price', type: 'currency' as const },
143
- { field: 'amount', label: 'Amount', type: 'currency' as const, computed: true, expr: 'record.quantity * record.unit_price', scale: 2 },
144
- ],
145
- total_field: 'amount',
146
- } as any;
147
-
148
- it('renders a computed column read-only (no input) and recomputes on edit', () => {
149
- const onChange = vi.fn();
150
- render(<GridField value={[{ product: 'Widget', quantity: 3, unit_price: 10, amount: 30 }]} onChange={onChange} field={computedField} />);
151
- // Amount is display-only — there is no editable Amount cell.
152
- expect(screen.queryByLabelText('Amount')).toBeNull();
153
- // Editing quantity recomputes amount in the emitted row.
154
- fireEvent.change(screen.getAllByLabelText('Qty')[0], { target: { value: '4' } });
155
- expect(onChange).toHaveBeenCalledWith([{ product: 'Widget', quantity: 4, unit_price: 10, amount: 40 }]);
156
- });
157
-
158
- it('shows a dash for a computed cell whose inputs are blank', () => {
159
- render(<GridField value={[{ product: 'Widget', quantity: null, unit_price: null, amount: null }]} onChange={() => {}} field={computedField} />);
160
- // The computed amount cell reads "—" until its inputs exist.
161
- expect(screen.getAllByText('—').length).toBeGreaterThan(0);
162
- });
163
- });
164
-
165
- describe('keyboard navigation', () => {
166
- it('Enter moves focus to the same column in the next row', () => {
167
- render(<GridField value={[{ description: 'A', amount: 1 }, { description: 'B', amount: 2 }]} onChange={() => {}} field={field} />);
168
- const row0 = screen.getAllByLabelText('Description')[0];
169
- row0.focus();
170
- fireEvent.keyDown(row0, { key: 'Enter' });
171
- expect(document.activeElement).toBe(screen.getAllByLabelText('Description')[1]);
172
- });
173
- });
174
-
175
- it('removing a row emits the array without it', () => {
176
- const onChange = vi.fn();
177
- render(
178
- <GridField
179
- value={[{ description: 'A', amount: 1 }, { description: 'B', amount: 2 }]}
180
- onChange={onChange}
181
- field={field}
182
- />,
183
- );
184
- const removeButtons = screen.getAllByLabelText('Remove row');
185
- fireEvent.click(removeButtons[0]);
186
- expect(onChange).toHaveBeenCalledWith([{ description: 'B', amount: 2 }]);
187
- });
188
-
189
- it('row action buttons are always visible in grid mode (not hover-revealed)', () => {
190
- render(
191
- <GridField
192
- value={[{ description: 'A', amount: 1 }]}
193
- onChange={() => {}}
194
- field={field}
195
- />,
196
- );
197
- // Grid-mode rows previously gated these behind opacity-0/group-hover, so they
198
- // were invisible until hover and unreachable on touch — they must always show.
199
- expect(screen.getByLabelText('Remove row').className).not.toContain('opacity-0');
200
- expect(screen.getByTestId('line-items-duplicate-0').className).not.toContain('opacity-0');
201
- });
202
-
203
- it('readonly mode shows values and a summed total footer', () => {
204
- render(
205
- <GridField
206
- value={[{ description: 'A', amount: 10 }, { description: 'B', amount: 20 }]}
207
- onChange={() => {}}
208
- field={field}
209
- readonly
210
- />,
211
- );
212
- expect(screen.getByTestId('line-items-readonly')).toBeTruthy();
213
- expect(screen.getByText('Total')).toBeTruthy();
214
- expect(screen.getByText('30')).toBeTruthy();
215
- });
216
-
217
- describe('lookupAutofillPatch (item typeahead auto-fill)', () => {
218
- const cols = [
219
- { field: 'product', type: 'lookup' as const, reference: 'product' },
220
- { field: 'description', type: 'text' as const },
221
- { field: 'quantity', type: 'number' as const },
222
- { field: 'unit_price', type: 'currency' as const },
223
- { field: 'amount', type: 'currency' as const, computed: true, expr: 'record.quantity * record.unit_price' },
224
- ];
225
- const product = { value: 'p1', label: 'Widget A', name: 'Widget A', description: 'Standard widget', unit_price: 29.99, sku: 'WIDGET-A' };
226
-
227
- it('sets the FK id and copies same-named sibling fields from the record', () => {
228
- const patch = lookupAutofillPatch(cols, cols[0], product);
229
- expect(patch).toEqual({ product: 'p1', description: 'Standard widget', unit_price: 29.99 });
230
- // quantity (not on the record) and computed amount are left to the row/compute.
231
- expect(patch).not.toHaveProperty('quantity');
232
- expect(patch).not.toHaveProperty('amount');
233
- });
234
-
235
- it('copies only the FK id when autofill is disabled', () => {
236
- const patch = lookupAutofillPatch(cols, { ...cols[0], autofill: false }, product);
237
- expect(patch).toEqual({ product: 'p1' });
238
- });
239
- });
240
-
241
- describe('P1 affordances (duplicate / validation)', () => {
242
- it('duplicates a row (id stripped) directly below the original', () => {
243
- const onChange = vi.fn();
244
- render(<GridField value={[{ id: 'r1', description: 'A', amount: 5 }]} onChange={onChange} field={field} />);
245
- fireEvent.click(screen.getByTestId('line-items-duplicate-0'));
246
- expect(onChange).toHaveBeenCalledWith([
247
- { id: 'r1', description: 'A', amount: 5 },
248
- { description: 'A', amount: 5 }, // copy without the id → persists as a new record
249
- ]);
250
- });
251
-
252
- it('flags a required, empty cell on a real row (not the ghost row)', () => {
253
- const reqField = { columns: [{ field: 'description', label: 'Description', type: 'text' as const, required: true }] } as any;
254
- render(<GridField value={[{ description: '' }]} onChange={() => {}} field={reqField} />);
255
- // The data row's required-empty cell is flagged...
256
- expect(screen.getByTestId('line-items-invalid-0-description')).toBeTruthy();
257
- // ...but the trailing ghost row (index 1) is not.
258
- expect(screen.queryByTestId('line-items-invalid-1-description')).toBeNull();
259
- });
260
- });
261
-
262
- describe('file columns (upload in a grid cell — #2360)', () => {
263
- const fileField = {
264
- columns: [
265
- { field: 'description', label: 'Description', type: 'text' as const },
266
- { field: 'receipt', label: 'Receipt', type: 'file' as const },
267
- ],
268
- } as any;
269
-
270
- it('renders a real upload control in the cell, not a text input', () => {
271
- render(<GridField value={[{ description: 'Taxi', receipt: null }]} onChange={() => {}} field={fileField} />);
272
- // One upload button per row (data row + ghost), backed by a native file input.
273
- expect(screen.getAllByRole('button', { name: 'Receipt' }).length).toBeGreaterThan(0);
274
- expect(document.querySelector('input[type="file"]')).toBeTruthy();
275
- expect(screen.queryByRole('textbox', { name: 'Receipt' })).toBeNull();
276
- });
277
-
278
- it('uploads a picked file and writes the file object into the row', async () => {
279
- const onChange = vi.fn();
280
- const adapter = {
281
- name: 'test',
282
- upload: async (f: File) => ({ url: 'https://cdn/receipt.png', name: f.name, size: f.size, mimeType: f.type }),
283
- };
284
- render(
285
- <UploadProvider adapter={adapter as any}>
286
- <GridField value={[{ description: 'Taxi', receipt: null }]} onChange={onChange} field={fileField} />
287
- </UploadProvider>,
288
- );
289
- const file = new File(['x'], 'receipt.png', { type: 'image/png' });
290
- const input = document.querySelectorAll('input[type="file"]')[0] as HTMLInputElement;
291
- fireEvent.change(input, { target: { files: [file] } });
292
- await waitFor(() => expect(onChange).toHaveBeenCalled());
293
- expect(onChange).toHaveBeenCalledWith([
294
- {
295
- description: 'Taxi',
296
- receipt: {
297
- name: 'receipt.png',
298
- original_name: 'receipt.png',
299
- size: file.size,
300
- mime_type: 'image/png',
301
- url: 'https://cdn/receipt.png',
302
- },
303
- },
304
- ]);
305
- });
306
-
307
- it('shows an uploaded file as a removable chip', () => {
308
- const onChange = vi.fn();
309
- render(
310
- <GridField
311
- value={[{ description: 'Taxi', receipt: { name: 'receipt.png', mime_type: 'image/png' } }]}
312
- onChange={onChange}
313
- field={fileField}
314
- />,
315
- );
316
- expect(screen.getByText('receipt.png')).toBeTruthy();
317
- fireEvent.click(screen.getByLabelText('Remove receipt.png'));
318
- expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', receipt: null }]);
319
- });
320
-
321
- it('passes the column accept list to the native picker', () => {
322
- const withAccept = {
323
- columns: [{ field: 'receipt', label: 'Receipt', type: 'file' as const, accept: ['image/*', '.pdf'] }],
324
- } as any;
325
- render(<GridField value={[]} onChange={() => {}} field={withAccept} />);
326
- const input = document.querySelector('input[type="file"]') as HTMLInputElement;
327
- expect(input.getAttribute('accept')).toBe('image/*,.pdf');
328
- });
329
-
330
- it('renders the file name read-only in readonly mode', () => {
331
- render(
332
- <GridField
333
- value={[{ description: 'Taxi', receipt: { name: 'receipt.png' } }]}
334
- onChange={() => {}}
335
- field={fileField}
336
- readonly
337
- />,
338
- );
339
- expect(screen.getByText('receipt.png')).toBeTruthy();
340
- expect(document.querySelector('input[type="file"]')).toBeNull();
341
- });
342
- });
343
-
344
- it('sumColumn ignores blanks and NaN', () => {
345
- expect(sumColumn([{ amount: 1 }, { amount: 2 }, { amount: null }], 'amount')).toBe(3);
346
- });
347
-
348
- describe('parent-scoped conditional rules (B2 follow-up — "paid invoice → lock lines")', () => {
349
- const lockField = {
350
- columns: [
351
- { field: 'product', label: 'Product', type: 'text' as const },
352
- { field: 'qty', label: 'Qty', type: 'number' as const, readonlyWhen: "parent.status == 'paid'" },
353
- { field: 'unit_price', label: 'Unit Price', type: 'currency' as const, readonlyWhen: "parent.status == 'paid'" },
354
- ],
355
- } as any;
356
-
357
- it('leaves cells editable when the parent rule is FALSE', () => {
358
- render(
359
- <GridField
360
- value={[{ product: 'Widget', qty: 2, unit_price: 10 }]}
361
- onChange={() => {}}
362
- field={lockField}
363
- contextRecord={{ status: 'draft' }}
364
- />,
365
- );
366
- expect((screen.getAllByLabelText('Qty')[0] as HTMLInputElement).disabled).toBe(false);
367
- expect((screen.getAllByLabelText('Unit Price')[0] as HTMLInputElement).disabled).toBe(false);
368
- });
369
-
370
- it('locks cells whose readonlyWhen references the parent header', () => {
371
- render(
372
- <GridField
373
- value={[{ product: 'Widget', qty: 2, unit_price: 10 }]}
374
- onChange={() => {}}
375
- field={lockField}
376
- contextRecord={{ status: 'paid' }}
377
- />,
378
- );
379
- // The header is paid → quantity / unit price lock; product (no rule) stays editable.
380
- expect((screen.getAllByLabelText('Qty')[0] as HTMLInputElement).disabled).toBe(true);
381
- expect((screen.getAllByLabelText('Unit Price')[0] as HTMLInputElement).disabled).toBe(true);
382
- expect((screen.getAllByLabelText('Product')[0] as HTMLInputElement).disabled).toBe(false);
383
- });
384
-
385
- it('re-evaluates per row, mixing the parent header with row data', () => {
386
- const rowRule = {
387
- columns: [
388
- { field: 'qty', label: 'Qty', type: 'number' as const },
389
- // Locks only when the header is paid AND this row is already invoiced.
390
- { field: 'note', label: 'Note', type: 'text' as const, readonlyWhen: "parent.status == 'paid' && record.invoiced == true" },
391
- ],
392
- } as any;
393
- render(
394
- <GridField
395
- value={[{ qty: 1, note: 'a', invoiced: true }, { qty: 2, note: 'b', invoiced: false }]}
396
- onChange={() => {}}
397
- field={rowRule}
398
- contextRecord={{ status: 'paid' }}
399
- />,
400
- );
401
- const notes = screen.getAllByLabelText('Note') as HTMLInputElement[];
402
- expect(notes[0].disabled).toBe(true); // invoiced row → locked
403
- expect(notes[1].disabled).toBe(false); // not-yet-invoiced row → editable
404
- });
405
- });
406
- });