@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,966 +0,0 @@
1
- import React, { useCallback, useRef } from 'react';
2
- import { FieldWidgetComponentProps } from './types';
3
- import {
4
- cn,
5
- Button,
6
- Input,
7
- Select,
8
- SelectContent,
9
- SelectItem,
10
- SelectTrigger,
11
- SelectValue,
12
- Popover,
13
- PopoverTrigger,
14
- PopoverContent,
15
- Checkbox,
16
- Label,
17
- } from '@object-ui/components';
18
- import { Plus, Trash2, SlidersHorizontal, Maximize2, Copy, GripVertical } from 'lucide-react';
19
- import { resolveFieldRuleState } from '@object-ui/core';
20
- import { LookupField } from './LookupField';
21
- import { FileCell } from './FileField';
22
-
23
- /**
24
- * GridField / LineItemsField — editable child-grid ("line items") widget.
25
- *
26
- * A controlled component: `value` is an array of row objects, `onChange`
27
- * receives the next array. It renders one editable cell per configured
28
- * column, supports add / delete row, and shows a running total of a numeric
29
- * column. This is the renderer for the `field:grid` widget and the cell
30
- * engine behind the master-detail subform (see ADR-0001).
31
- *
32
- * Column config (a subset of `GridColumnDefinition`):
33
- * { field, label?, type?, options?, width?, required?, prefix?, step? }
34
- * type ∈ 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup' | 'file'
35
- *
36
- * Field-level config (from `GridFieldMetadata`):
37
- * columns, min_rows, max_rows, allow_add, allow_delete, total_field
38
- */
39
-
40
- export interface GridColumn {
41
- field: string;
42
- label?: string;
43
- type?: 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup' | 'file';
44
- options?: Array<{ label: string; value: string }>;
45
- width?: number;
46
- required?: boolean;
47
- prefix?: string;
48
- step?: number;
49
- /** For `type: 'lookup'` — the referenced object and label/id fields. */
50
- reference?: string;
51
- displayField?: string;
52
- idField?: string;
53
- /** Multi-value column: multi-record lookup, or multi-file upload cell. */
54
- multiple?: boolean;
55
- /** For `type: 'file'` — accepted MIME types / extensions for the picker
56
- * (e.g. `['image/*', '.pdf']`). Omit to accept anything. */
57
- accept?: string[];
58
- /**
59
- * Hidden from the grid by default but revealable via the column chooser.
60
- * Set by `deriveColumns` for fields beyond the default-visible budget — the
61
- * data is NOT dropped (it's just collapsed, like Odoo's `optional` columns /
62
- * Salesforce column personalization), so business-critical fields stay
63
- * reachable. Required columns are never default-hidden.
64
- */
65
- defaultHidden?: boolean;
66
- /**
67
- * A computed (read-only) column whose value is derived live from sibling
68
- * cells via {@link expr} — e.g. an invoice line's `amount = quantity *
69
- * unit_price`. The grid renders it read-only, recomputes it as the row's
70
- * inputs change, and writes the result back into the row so it persists
71
- * (and any running total reflects it). The classic spreadsheet pattern used
72
- * by QuickBooks / Stripe / NetSuite line grids — nobody types the amount.
73
- */
74
- computed?: boolean;
75
- /** Arithmetic expression for a {@link computed} column. Supports `+ - * / %`,
76
- * parentheses, numeric literals and field refs (`record.qty` or bare `qty`). */
77
- expr?: string;
78
- /** Decimal places to round a computed numeric/currency result to. */
79
- scale?: number;
80
- /** For `type: 'lookup'` — when a record is picked, copy its fields into any
81
- * sibling columns of the same name (e.g. a product's unit_price/description).
82
- * On by default for lookup columns; set `false` to disable the auto-fill. */
83
- autofill?: boolean;
84
- /**
85
- * CEL predicate: when TRUE for this row, the cell is **read-only** (B2 field
86
- * rules, generalized to grid cells). Evaluated per row against the row as
87
- * `record` plus the header as `parent` (so a line locks when
88
- * `parent.status == 'paid'` *or* on an intra-row condition like
89
- * `record.kind == 'auto'`). Client-side UX; fails open (stays editable).
90
- */
91
- readonlyWhen?: string | { dialect?: string; source: string };
92
- /**
93
- * CEL predicate: when TRUE for this row, the cell is **required** (flagged
94
- * inline-invalid while empty). Same `record` + `parent` scope as
95
- * {@link readonlyWhen}.
96
- */
97
- requiredWhen?: string | { dialect?: string; source: string };
98
- }
99
-
100
- type Row = Record<string, any>;
101
-
102
- const isNumeric = (t?: string) => t === 'number' || t === 'currency';
103
-
104
- /** Comfortable minimum widths per column type so cells never get crushed; the
105
- * grid scrolls horizontally instead. Authors can still pin a `width`. */
106
- const MIN_WIDTH_BY_TYPE: Record<string, number> = {
107
- text: 160,
108
- select: 132,
109
- lookup: 168,
110
- number: 104,
111
- currency: 116,
112
- date: 150,
113
- file: 168,
114
- };
115
- const minWidthFor = (c: GridColumn): number => c.width ?? MIN_WIDTH_BY_TYPE[c.type ?? 'text'] ?? 132;
116
-
117
- /**
118
- * Per-column sizing. Text columns flex to absorb slack (so the description
119
- * never gets crushed), while numeric / select / date / lookup columns get a
120
- * fixed sensible width — the role-based sizing every enterprise line grid uses
121
- * (Qty stays narrow, Description stays wide). Authors can still pin `width`.
122
- */
123
- function widthStyle(c: GridColumn): React.CSSProperties {
124
- if (c.width) return { width: c.width, minWidth: c.width };
125
- if ((c.type ?? 'text') === 'text') return { minWidth: 180 }; // flex, no fixed width
126
- const w = MIN_WIDTH_BY_TYPE[c.type ?? 'text'] ?? 132;
127
- return { width: w, minWidth: w };
128
- }
129
-
130
- // ── Safe arithmetic evaluator for computed columns ──────────────────────────
131
- // A tiny recursive-descent parser over `+ - * / %`, parentheses, numeric
132
- // literals and field references (`record.qty` or bare `qty`). Deliberately NOT
133
- // eval()/Function() — only arithmetic, no code execution. Returns a finite
134
- // number, or null when the expression is unparseable or any referenced cell is
135
- // blank/non-numeric (so a computed amount reads "—" until its inputs exist).
136
- type Tok = { t: 'num' | 'id' | 'op' | 'lp' | 'rp'; v: string };
137
-
138
- function tokenize(s: string): Tok[] | null {
139
- const toks: Tok[] = [];
140
- let i = 0;
141
- while (i < s.length) {
142
- const c = s[i];
143
- if (c === ' ' || c === '\t' || c === '\n') { i++; continue; }
144
- if ((c >= '0' && c <= '9') || c === '.') {
145
- let j = i + 1;
146
- while (j < s.length && ((s[j] >= '0' && s[j] <= '9') || s[j] === '.')) j++;
147
- toks.push({ t: 'num', v: s.slice(i, j) }); i = j; continue;
148
- }
149
- if (/[a-zA-Z_]/.test(c)) {
150
- let j = i + 1;
151
- while (j < s.length && /[a-zA-Z0-9_.]/.test(s[j])) j++;
152
- toks.push({ t: 'id', v: s.slice(i, j) }); i = j; continue;
153
- }
154
- if (c === '+' || c === '-' || c === '*' || c === '/' || c === '%') { toks.push({ t: 'op', v: c }); i++; continue; }
155
- if (c === '(') { toks.push({ t: 'lp', v: c }); i++; continue; }
156
- if (c === ')') { toks.push({ t: 'rp', v: c }); i++; continue; }
157
- return null; // unsupported character → bail (treat as non-computable)
158
- }
159
- return toks;
160
- }
161
-
162
- /** Resolve an identifier (`record.quantity` / `quantity`) to a numeric cell. */
163
- function resolveRef(id: string, row: Row): number | null {
164
- const field = id.startsWith('record.') ? id.slice('record.'.length) : id;
165
- const raw = row?.[field];
166
- if (raw === null || raw === undefined || raw === '') return null;
167
- const n = Number(raw);
168
- return Number.isFinite(n) ? n : null;
169
- }
170
-
171
- /** Evaluate an arithmetic `expr` against `row`. null when blank/unparseable. */
172
- export function evalArith(expr: string, row: Row): number | null {
173
- const toks = tokenize(expr);
174
- if (!toks || toks.length === 0) return null;
175
- let pos = 0;
176
- let bad = false;
177
- const peek = () => toks[pos];
178
- const next = () => toks[pos++];
179
-
180
- function factor(): number {
181
- const tk = peek();
182
- if (!tk) { bad = true; return NaN; }
183
- if (tk.t === 'op' && tk.v === '-') { next(); return -factor(); }
184
- if (tk.t === 'op' && tk.v === '+') { next(); return factor(); }
185
- if (tk.t === 'num') { next(); return Number(tk.v); }
186
- if (tk.t === 'id') { next(); const v = resolveRef(tk.v, row); if (v === null) { bad = true; return NaN; } return v; }
187
- if (tk.t === 'lp') {
188
- next();
189
- const v = expression();
190
- const close = next();
191
- if (!close || close.t !== 'rp') bad = true;
192
- return v;
193
- }
194
- bad = true; return NaN;
195
- }
196
- function term(): number {
197
- let v = factor();
198
- while (peek() && peek().t === 'op' && (peek().v === '*' || peek().v === '/' || peek().v === '%')) {
199
- const op = next().v;
200
- const r = factor();
201
- v = op === '*' ? v * r : op === '/' ? v / r : v % r;
202
- }
203
- return v;
204
- }
205
- function expression(): number {
206
- let v = term();
207
- while (peek() && peek().t === 'op' && (peek().v === '+' || peek().v === '-')) {
208
- const op = next().v;
209
- const r = term();
210
- v = op === '+' ? v + r : v - r;
211
- }
212
- return v;
213
- }
214
- const result = expression();
215
- if (bad || pos !== toks.length || !Number.isFinite(result)) return null;
216
- return result;
217
- }
218
-
219
- /**
220
- * Recompute every {@link GridColumn.computed} cell in `row` from its sibling
221
- * inputs, returning a new row. Called after each edit so computed columns and
222
- * the running total stay live, and so the computed value persists in the batch.
223
- */
224
- /**
225
- * Build the row patch for a lookup-cell selection: set the FK column to the
226
- * chosen record's id, and (unless the column opts out with `autofill: false`)
227
- * copy any of the record's fields whose names match a sibling column — the
228
- * catalog-typeahead behaviour (pick a product → its unit_price/description fill
229
- * in). Skips the lookup column itself and computed columns. Pure + exported so
230
- * it is unit-testable independent of the picker UI.
231
- */
232
- export function lookupAutofillPatch(columns: GridColumn[], col: GridColumn, record: any): Row {
233
- const patch: Row = { [col.field]: record?.value ?? record?.id ?? record?._id };
234
- if (col.autofill !== false && record && typeof record === 'object') {
235
- for (const other of columns) {
236
- if (other.field === col.field || other.computed || other.type === 'lookup') continue;
237
- const v = record[other.field];
238
- if (v !== undefined && v !== null && v !== '') patch[other.field] = v;
239
- }
240
- }
241
- return patch;
242
- }
243
-
244
- export function computeRow(columns: GridColumn[], row: Row): Row {
245
- const computedCols = columns.filter((c) => c.computed && c.expr);
246
- if (computedCols.length === 0) return row;
247
- const next = { ...row };
248
- for (const c of computedCols) {
249
- const v = evalArith(c.expr!, next);
250
- if (v === null) { next[c.field] = null; continue; }
251
- const scale = c.scale ?? (c.type === 'currency' ? 2 : undefined);
252
- next[c.field] = scale != null ? Number(v.toFixed(scale)) : v;
253
- }
254
- return next;
255
- }
256
-
257
- /** Read-only display text for a cell in list mode (select → option label,
258
- * currency/number → formatted, empty → em dash). Lookups render separately. */
259
- function displayText(c: GridColumn, value: any): string {
260
- if (value === null || value === undefined || value === '') return '—';
261
- if (c.type === 'file') {
262
- const files = Array.isArray(value) ? value : [value];
263
- if (files.length === 0) return '—';
264
- if (files.length > 1) return `${files.length} files`;
265
- const f = files[0];
266
- return typeof f === 'string' ? f : f?.name || f?.original_name || 'File';
267
- }
268
- if (c.type === 'select' && Array.isArray(c.options)) {
269
- const opt = c.options.find((o) => String(o.value) === String(value));
270
- return opt ? opt.label : String(value);
271
- }
272
- if (isNumeric(c.type)) {
273
- const n = Number(value);
274
- if (Number.isFinite(n)) return c.type === 'currency' ? `${c.prefix || '¥'}${n.toLocaleString()}` : n.toLocaleString();
275
- }
276
- if (Array.isArray(value)) return value.join(', ');
277
- return String(value);
278
- }
279
-
280
- function coerce(type: string | undefined, raw: string): any {
281
- if (isNumeric(type)) {
282
- if (raw === '' || raw == null) return null;
283
- const n = Number(raw);
284
- return Number.isNaN(n) ? raw : n;
285
- }
286
- return raw;
287
- }
288
-
289
- /** Sum a numeric column across rows (ignoring blanks/NaN). */
290
- export function sumColumn(rows: Row[], field: string): number {
291
- if (!Array.isArray(rows)) return 0;
292
- return rows.reduce((acc, r) => {
293
- const v = Number(r?.[field]);
294
- return acc + (Number.isFinite(v) ? v : 0);
295
- }, 0);
296
- }
297
-
298
- export function GridField({
299
- value,
300
- onChange,
301
- field,
302
- readonly,
303
- disabled,
304
- className,
305
- onRowExpand,
306
- displayMode,
307
- onAdd,
308
- ...props
309
- }: FieldWidgetComponentProps<Row[]> & {
310
- /** When provided, each row shows an "expand" button that opens the row in a
311
- * full form (the host — e.g. MasterDetailForm — renders the drawer/modal and
312
- * writes the edited values back). Lets a "fat" child be edited in a real form
313
- * while the grid stays a quick at-a-glance editor. */
314
- onRowExpand?: (rowIndex: number) => void;
315
- /** 'grid' (default) = editable cells; 'list' = read-only rows whose primary
316
- * action is per-row edit (via `onRowExpand`) and whose Add opens a new row
317
- * in the full form (via `onAdd`). The form-factor for "fat" children. */
318
- displayMode?: 'grid' | 'list';
319
- /** In 'list' mode, "Add" calls this (host opens the full form for a new row)
320
- * instead of inserting a blank inline row. */
321
- onAdd?: () => void;
322
- /** The header/parent record, bound as `parent` when evaluating a column's
323
- * `readonlyWhen` / `requiredWhen` CEL predicate — so a line cell can react to
324
- * the header (`parent.status == 'paid'`). Supplied by MasterDetailForm. */
325
- contextRecord?: Record<string, unknown>;
326
- }) {
327
- const cfg = (field || {}) as any;
328
- const allColumns: GridColumn[] = cfg.columns || [];
329
- const rows: Row[] = Array.isArray(value) ? value : [];
330
- const contextRecord = props.contextRecord;
331
-
332
- // Per-cell CEL rule state (B2 in grids). A column with no readonlyWhen/
333
- // requiredWhen resolves to its static flags (cheap fast-path — no engine
334
- // call). Otherwise evaluate against the row (`record`) + header (`parent`).
335
- const cellRules = useCallback(
336
- (c: GridColumn, row: Row): { readonly: boolean; required: boolean } => {
337
- if (!c.readonlyWhen && !c.requiredWhen) {
338
- return { readonly: false, required: !!c.required };
339
- }
340
- const s = resolveFieldRuleState(
341
- { readonlyWhen: c.readonlyWhen, requiredWhen: c.requiredWhen },
342
- (row || {}) as Record<string, unknown>,
343
- { required: !!c.required, readonly: false },
344
- undefined,
345
- contextRecord ? { parent: contextRecord } : undefined,
346
- );
347
- return { readonly: s.readonly, required: s.required };
348
- },
349
- [contextRecord],
350
- );
351
- // List mode: rows are read-only at-a-glance; editing happens in the full form.
352
- const isList = displayMode === 'list' && !readonly;
353
-
354
- // Column visibility — a curated default-visible set with the rest revealable
355
- // via the column chooser (mainstream "personalize columns" pattern). Required
356
- // columns are always visible; nothing is ever silently dropped.
357
- const [extraShown, setExtraShown] = React.useState<Set<string>>(() => new Set());
358
- const optionalColumns = allColumns.filter((c) => c.defaultHidden && !c.required);
359
- const columns: GridColumn[] = allColumns.filter(
360
- (c) => !c.defaultHidden || c.required || extraShown.has(c.field),
361
- );
362
- const toggleColumn = useCallback((fieldName: string) => {
363
- setExtraShown((prev) => {
364
- const next = new Set(prev);
365
- if (next.has(fieldName)) next.delete(fieldName);
366
- else next.add(fieldName);
367
- return next;
368
- });
369
- }, []);
370
-
371
- const allowAdd = cfg.allow_add !== false && !readonly && !disabled;
372
- const allowDelete = cfg.allow_delete !== false && !readonly && !disabled;
373
- const allowDuplicate = cfg.allow_duplicate !== false && allowAdd;
374
- // Per-row "expand to full form" (mainstream hybrid: quick grid + rich form).
375
- const showExpand = typeof onRowExpand === 'function' && !readonly;
376
- // Enterprise line grids (NetSuite/SAP/Salesforce) show a line-number column.
377
- const showLineNumbers = cfg.show_line_numbers !== false;
378
- const minRows: number = cfg.min_rows ?? 0;
379
- const maxRows: number | undefined = cfg.max_rows;
380
- const totalField: string | undefined =
381
- cfg.total_field || cfg.amount_field || cfg.amountField;
382
- // When set, the row's order is persisted by stamping `row[sortField] = index`
383
- // on every change — so drag-reorder survives a reload (the app adds a numeric
384
- // position field and lists sort by it). Without it, reorder is order-of-entry.
385
- const sortField: string | undefined = cfg.sort_field;
386
- // Drag-to-reorder is on for editable grids (off in read-only / list mode).
387
- const allowReorder = cfg.reorderable !== false && !readonly && !disabled;
388
-
389
- const emit = useCallback(
390
- (next: Row[]) => {
391
- onChange?.(sortField ? next.map((r, i) => ({ ...r, [sortField]: i })) : next);
392
- },
393
- [onChange, sortField],
394
- );
395
-
396
- const blankRow = useCallback((): Row => {
397
- const blank: Row = {};
398
- for (const c of columns) blank[c.field] = null;
399
- return blank;
400
- }, [columns]);
401
-
402
- /**
403
- * Merge a field patch into a row. `rowIdx === rows.length` targets the
404
- * trailing "ghost" row (always-present empty line) — editing it materialises
405
- * a new real row, so the user never has to click "Add line" to keep entering.
406
- * Computed columns are recomputed for the touched row on every change.
407
- */
408
- const applyPatch = useCallback(
409
- (rowIdx: number, patch: Row) => {
410
- const isGhost = rowIdx >= rows.length;
411
- if (isGhost) {
412
- if (maxRows != null && rows.length >= maxRows) return;
413
- emit([...rows, computeRow(columns, { ...blankRow(), ...patch })]);
414
- return;
415
- }
416
- emit(rows.map((r, i) => (i === rowIdx ? computeRow(columns, { ...r, ...patch }) : r)));
417
- },
418
- [rows, columns, maxRows, blankRow, emit],
419
- );
420
-
421
- const applyCell = useCallback(
422
- (rowIdx: number, field: string, value: any) => applyPatch(rowIdx, { [field]: value }),
423
- [applyPatch],
424
- );
425
-
426
- /**
427
- * A lookup cell selection — set the FK id and auto-fill any sibling cells
428
- * whose column name matches a field on the chosen record (and isn't itself a
429
- * lookup/computed column). The catalog-typeahead behaviour every invoicing
430
- * tool has: pick a product → its unit_price / description drop into the row.
431
- * Opt out per column with `autofill: false`.
432
- */
433
- const applyLookupSelection = useCallback(
434
- (rowIdx: number, col: GridColumn, record: any) => {
435
- applyPatch(rowIdx, lookupAutofillPatch(columns, col, record));
436
- },
437
- [columns, applyPatch],
438
- );
439
-
440
- /** Set a cell to an already-typed value (lookup ids, etc.) without coercion. */
441
- const setCellValue = useCallback(
442
- (rowIdx: number, field: string, value: any) => applyCell(rowIdx, field, value),
443
- [applyCell],
444
- );
445
-
446
- const setCell = useCallback(
447
- (rowIdx: number, col: GridColumn, raw: string) => {
448
- applyCell(rowIdx, col.field, coerce(col.type, raw));
449
- },
450
- [applyCell],
451
- );
452
-
453
- const addRow = useCallback(() => {
454
- if (maxRows != null && rows.length >= maxRows) return;
455
- emit([...rows, blankRow()]);
456
- }, [rows, blankRow, maxRows, emit]);
457
-
458
- // Keyboard navigation across cells (spreadsheet-style): the table is a focus
459
- // grid. Enter / ArrowUp / ArrowDown move between rows in the same column;
460
- // Tab / Shift-Tab fall through to the browser's natural row-major order
461
- // (and into the ever-present ghost row, so tabbing past the last cell starts
462
- // a new line). Cells carry data-cell="row-col" so we can target neighbours.
463
- const gridRef = useRef<HTMLTableElement>(null);
464
- const focusCell = useCallback((rowIdx: number, colIdx: number) => {
465
- const el = gridRef.current?.querySelector<HTMLElement>(`[data-cell="${rowIdx}-${colIdx}"]`);
466
- if (el) {
467
- el.focus();
468
- if (el instanceof HTMLInputElement) el.select();
469
- }
470
- }, []);
471
- const onCellKeyDown = useCallback(
472
- (e: React.KeyboardEvent, rowIdx: number, colIdx: number) => {
473
- if (e.key === 'ArrowDown' || e.key === 'Enter') {
474
- e.preventDefault();
475
- focusCell(rowIdx + 1, colIdx);
476
- } else if (e.key === 'ArrowUp') {
477
- e.preventDefault();
478
- if (rowIdx > 0) focusCell(rowIdx - 1, colIdx);
479
- }
480
- },
481
- [focusCell],
482
- );
483
-
484
- const removeRow = useCallback(
485
- (rowIdx: number) => {
486
- if (rows.length <= minRows) return;
487
- emit(rows.filter((_, i) => i !== rowIdx));
488
- },
489
- [rows, minRows, emit],
490
- );
491
-
492
- /** Duplicate a row (id stripped so the copy persists as a new record),
493
- * inserted directly below the original — handy for near-identical lines. */
494
- const duplicateRow = useCallback(
495
- (rowIdx: number) => {
496
- if (maxRows != null && rows.length >= maxRows) return;
497
- const src = rows[rowIdx];
498
- if (!src) return;
499
- const { id: _i, _id: _i2, recordId: _i3, ...copy } = src as any;
500
- const next = [...rows];
501
- next.splice(rowIdx + 1, 0, { ...copy });
502
- emit(next);
503
- },
504
- [rows, maxRows, emit],
505
- );
506
-
507
- /** Move a row from one position to another (drag-to-reorder). */
508
- const moveRow = useCallback(
509
- (from: number, to: number) => {
510
- if (from === to || from < 0 || to < 0 || from >= rows.length || to >= rows.length) return;
511
- const next = [...rows];
512
- const [moved] = next.splice(from, 1);
513
- next.splice(to, 0, moved);
514
- emit(next);
515
- },
516
- [rows, emit],
517
- );
518
- const dragIndex = useRef<number | null>(null);
519
-
520
- const hasRowActions = showExpand || allowDelete || allowDuplicate;
521
- const actionColWidth = ((showExpand ? 1 : 0) + (allowDuplicate ? 1 : 0) + (allowDelete ? 1 : 0)) * 34 + 12;
522
-
523
- const showTotal = !!totalField;
524
- const total = showTotal ? sumColumn(rows, totalField!) : 0;
525
- // Align the running total under the column it sums (not blindly under the
526
- // last column). The label sits right-aligned immediately to its left.
527
- const totalColIndex = showTotal ? Math.max(0, columns.findIndex((c) => c.field === totalField)) : -1;
528
-
529
- // Column chooser — reveal/hide the optional (default-hidden) columns. Only
530
- // rendered when there are optional columns to manage.
531
- const columnChooser = optionalColumns.length > 0 ? (
532
- <Popover>
533
- <PopoverTrigger asChild>
534
- <Button
535
- type="button"
536
- variant="ghost"
537
- size="sm"
538
- className="h-7 gap-1.5 text-xs text-muted-foreground"
539
- data-testid="line-items-columns"
540
- >
541
- <SlidersHorizontal className="h-3.5 w-3.5" />
542
- Columns
543
- {extraShown.size > 0 && (
544
- <span className="rounded-full bg-primary/10 px-1.5 text-[10px] font-medium text-primary">
545
- +{extraShown.size}
546
- </span>
547
- )}
548
- </Button>
549
- </PopoverTrigger>
550
- <PopoverContent align="end" className="w-56 p-2">
551
- <div className="px-1 pb-1.5 text-xs font-medium text-muted-foreground">Optional columns</div>
552
- <div className="max-h-64 space-y-0.5 overflow-y-auto">
553
- {optionalColumns.map((c) => {
554
- const id = `col-toggle-${c.field}`;
555
- return (
556
- <Label
557
- key={c.field}
558
- htmlFor={id}
559
- className="flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-1.5 text-sm font-normal hover:bg-muted"
560
- >
561
- <Checkbox
562
- id={id}
563
- checked={extraShown.has(c.field)}
564
- onCheckedChange={() => toggleColumn(c.field)}
565
- />
566
- <span className="truncate">{c.label || c.field}</span>
567
- </Label>
568
- );
569
- })}
570
- </div>
571
- </PopoverContent>
572
- </Popover>
573
- ) : null;
574
-
575
- // ── Read-only / view rendering ────────────────────────────────────────────
576
- if (readonly) {
577
- return (
578
- <div className={cn('space-y-2', className)}>
579
- {columnChooser && <div className="flex justify-end">{columnChooser}</div>}
580
- <div className="border border-border rounded-lg overflow-x-auto" data-testid="line-items-readonly">
581
- <table className="w-full text-sm">
582
- <thead className="bg-muted border-b border-border">
583
- <tr>
584
- {showLineNumbers && (
585
- <th className="w-10 px-2 py-2 text-right text-xs font-medium text-muted-foreground">#</th>
586
- )}
587
- {columns.map((c) => (
588
- <th
589
- key={c.field}
590
- style={{ minWidth: minWidthFor(c) }}
591
- className={cn(
592
- 'px-3 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap',
593
- isNumeric(c.type) ? 'text-right' : 'text-left',
594
- )}
595
- >
596
- {c.label || c.field}
597
- </th>
598
- ))}
599
- </tr>
600
- </thead>
601
- <tbody className="divide-y divide-border">
602
- {rows.length === 0 ? (
603
- <tr>
604
- <td
605
- colSpan={Math.max(columns.length + (showLineNumbers ? 1 : 0), 1)}
606
- className="px-3 py-6 text-center text-muted-foreground"
607
- >
608
- No items
609
- </td>
610
- </tr>
611
- ) : (
612
- rows.map((row, rowIdx) => (
613
- <tr key={rowIdx}>
614
- {showLineNumbers && (
615
- <td className="px-2 py-2 text-right text-muted-foreground tabular-nums">{rowIdx + 1}</td>
616
- )}
617
- {columns.map((c) => (
618
- <td
619
- key={c.field}
620
- className={cn('px-3 py-2 text-foreground', isNumeric(c.type) && 'text-right tabular-nums')}
621
- >
622
- {c.type === 'lookup' && row[c.field] != null && row[c.field] !== '' ? (
623
- <LookupField
624
- value={row[c.field]}
625
- onChange={() => {}}
626
- readonly
627
- field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField } as any}
628
- />
629
- ) : c.type === 'file' ? (
630
- displayText(c, row[c.field])
631
- ) : row[c.field] != null && row[c.field] !== '' ? (
632
- String(row[c.field])
633
- ) : (
634
- '—'
635
- )}
636
- </td>
637
- ))}
638
- </tr>
639
- ))
640
- )}
641
- </tbody>
642
- {showTotal && (
643
- <tfoot className="border-t border-border bg-muted/40">
644
- <tr>
645
- <td
646
- colSpan={Math.max((showLineNumbers ? 1 : 0) + totalColIndex, 1)}
647
- className="px-3 py-2 text-right text-xs font-medium text-muted-foreground"
648
- >
649
- Total
650
- </td>
651
- <td className="px-3 py-2 text-right font-semibold text-foreground tabular-nums">
652
- {total.toLocaleString()}
653
- </td>
654
- {columns.length - totalColIndex - 1 > 0 && (
655
- <td colSpan={columns.length - totalColIndex - 1} />
656
- )}
657
- </tr>
658
- </tfoot>
659
- )}
660
- </table>
661
- </div>
662
- </div>
663
- );
664
- }
665
-
666
- // ── Editable rendering ──────────────────────────────────────────────────────
667
- // A trailing "ghost" empty row is always present in grid mode (start-with-one
668
- // + auto-append): editing it materialises a real row, so the user keeps
669
- // entering lines without clicking "Add". Index-based keys keep the input
670
- // mounted across materialisation, so focus/caret survive the transition.
671
- const hasGhost = !isList && allowAdd && (maxRows == null || rows.length < maxRows);
672
- const displayRows: Row[] = hasGhost ? [...rows, blankRow()] : rows;
673
-
674
- /** Cell content: read-only display (list mode / computed columns) or an
675
- * editable borderless control (spreadsheet feel). */
676
- const renderCellInput = (c: GridColumn, colIdx: number, rowIdx: number, row: Row) => {
677
- const val = row?.[c.field];
678
- // A readonlyWhen-TRUE cell is locked: treat like the form-wide `disabled`.
679
- const locked = disabled || cellRules(c, row).readonly;
680
- // List (form-factor) mode → read-only at-a-glance display.
681
- if (isList) {
682
- if (c.type === 'lookup' && val != null && val !== '') {
683
- return (
684
- <LookupField value={val} onChange={() => {}} readonly
685
- field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField } as any} />
686
- );
687
- }
688
- return (
689
- <span className={cn('px-2 text-sm text-foreground', isNumeric(c.type) && 'tabular-nums', (val == null || val === '') && 'text-muted-foreground')}>
690
- {displayText(c, val)}
691
- </span>
692
- );
693
- }
694
- // Computed column → read-only, recomputed live, formatted.
695
- if (c.computed) {
696
- return (
697
- <span
698
- className={cn('block px-2 text-sm tabular-nums', isNumeric(c.type) ? 'text-right' : 'text-left', (val == null || val === '') ? 'text-muted-foreground' : 'text-foreground')}
699
- title="Computed"
700
- data-computed={c.field}
701
- >
702
- {displayText(c, val)}
703
- </span>
704
- );
705
- }
706
- if (c.type === 'lookup') {
707
- return (
708
- <LookupField
709
- value={val}
710
- onChange={(v: any) => setCellValue(rowIdx, c.field, v)}
711
- onSelectRecord={(rec: any) => applyLookupSelection(rowIdx, c, rec)}
712
- compact
713
- field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField, multiple: c.multiple, options: c.options, placeholder: '—' } as any}
714
- disabled={locked}
715
- />
716
- );
717
- }
718
- // File / image column → a real upload control in the cell (objectui#2360),
719
- // not a text input: chips for uploaded files + a compact picker button.
720
- if (c.type === 'file') {
721
- return (
722
- <FileCell
723
- value={val}
724
- onChange={(v: any) => setCellValue(rowIdx, c.field, v)}
725
- multiple={c.multiple}
726
- accept={Array.isArray(c.accept) && c.accept.length > 0 ? c.accept.join(',') : undefined}
727
- disabled={locked}
728
- aria-label={c.label || c.field}
729
- data-cell={`${rowIdx}-${colIdx}`}
730
- />
731
- );
732
- }
733
- if (c.type === 'select') {
734
- return (
735
- <Select value={val != null ? String(val) : ''} onValueChange={(v) => setCell(rowIdx, c, v)} disabled={locked}>
736
- <SelectTrigger className="h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus:ring-1 focus:ring-ring/60" aria-label={c.label || c.field}>
737
- <SelectValue placeholder="—" />
738
- </SelectTrigger>
739
- <SelectContent>
740
- {(c.options || []).map((o) => (
741
- <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
742
- ))}
743
- </SelectContent>
744
- </Select>
745
- );
746
- }
747
- return (
748
- <div className="relative">
749
- {c.type === 'currency' && (
750
- <span className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">{c.prefix || '¥'}</span>
751
- )}
752
- <Input
753
- data-cell={`${rowIdx}-${colIdx}`}
754
- onKeyDown={(e) => onCellKeyDown(e, rowIdx, colIdx)}
755
- className={cn(
756
- 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
757
- c.type === 'currency' && 'pl-6',
758
- isNumeric(c.type) && 'text-right tabular-nums',
759
- )}
760
- type={c.type === 'date' ? 'date' : isNumeric(c.type) ? 'number' : 'text'}
761
- step={isNumeric(c.type) ? c.step ?? 'any' : undefined}
762
- aria-label={c.label || c.field}
763
- value={val != null ? String(val) : ''}
764
- onChange={(e) => setCell(rowIdx, c, e.target.value)}
765
- disabled={locked}
766
- />
767
- </div>
768
- );
769
- };
770
-
771
- return (
772
- <div className={cn('space-y-2', className)} data-testid="line-items">
773
- {columnChooser && <div className="flex justify-end">{columnChooser}</div>}
774
- <div className="border border-border rounded-lg overflow-x-auto">
775
- <table ref={gridRef} className="w-full text-sm">
776
- <thead className="bg-muted/60 border-b border-border">
777
- <tr>
778
- {showLineNumbers && (
779
- <th className={cn('px-2 py-2 text-right text-xs font-medium text-muted-foreground', allowReorder ? 'w-14' : 'w-10')}>#</th>
780
- )}
781
- {columns.map((c) => (
782
- <th
783
- key={c.field}
784
- className={cn(
785
- 'px-2 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap',
786
- isNumeric(c.type) ? 'text-right' : 'text-left',
787
- )}
788
- style={widthStyle(c)}
789
- >
790
- {c.label || c.field}
791
- {c.required && !c.computed && <span className="text-destructive"> *</span>}
792
- </th>
793
- ))}
794
- {hasRowActions && <th style={{ width: actionColWidth }} />}
795
- </tr>
796
- </thead>
797
- <tbody className="divide-y divide-border">
798
- {isList && rows.length === 0 ? (
799
- <tr>
800
- <td
801
- colSpan={columns.length + (hasRowActions ? 1 : 0) + (showLineNumbers ? 1 : 0)}
802
- className="px-3 py-6 text-center text-muted-foreground"
803
- >
804
- No items yet — click “{cfg.add_label || 'Add'}” to begin.
805
- </td>
806
- </tr>
807
- ) : (
808
- displayRows.map((row, rowIdx) => {
809
- const isGhost = hasGhost && rowIdx === rows.length;
810
- const reorderable = allowReorder && !isGhost && !isList;
811
- return (
812
- <tr
813
- key={rowIdx}
814
- className={cn('group', !isGhost && 'hover:bg-muted/30')}
815
- {...(allowReorder && !isGhost
816
- ? {
817
- onDragOver: (e: React.DragEvent) => { if (dragIndex.current != null) e.preventDefault(); },
818
- onDrop: (e: React.DragEvent) => {
819
- e.preventDefault();
820
- if (dragIndex.current != null) { moveRow(dragIndex.current, rowIdx); dragIndex.current = null; }
821
- },
822
- }
823
- : {})}
824
- >
825
- {showLineNumbers && (
826
- <td className="px-1 py-1 text-right align-middle text-xs text-muted-foreground tabular-nums">
827
- <span className="inline-flex items-center justify-end gap-0.5">
828
- {reorderable && (
829
- <span
830
- draggable
831
- onDragStart={() => { dragIndex.current = rowIdx; }}
832
- onDragEnd={() => { dragIndex.current = null; }}
833
- className="cursor-grab text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
834
- title="Drag to reorder"
835
- aria-label="Drag to reorder"
836
- data-testid={`line-items-drag-${rowIdx}`}
837
- >
838
- <GripVertical className="h-3.5 w-3.5" />
839
- </span>
840
- )}
841
- <span className={cn(isGhost && 'opacity-30')}>{rowIdx + 1}</span>
842
- </span>
843
- </td>
844
- )}
845
- {columns.map((c, colIdx) => {
846
- // Inline validation: a required, non-computed cell that's
847
- // empty on a real (non-ghost) row flags red in place. The
848
- // "required" verdict honors a column's `requiredWhen` CEL
849
- // rule (B2), evaluated against the row + parent header.
850
- const required = cellRules(c, row).required;
851
- const invalid = !isGhost && !isList && required && !c.computed && (row[c.field] == null || row[c.field] === '');
852
- return (
853
- <td
854
- key={c.field}
855
- aria-invalid={invalid || undefined}
856
- title={invalid ? `${c.label || c.field} is required` : undefined}
857
- data-testid={invalid ? `line-items-invalid-${rowIdx}-${c.field}` : undefined}
858
- className={cn(
859
- 'border-r border-border/40 px-1 py-0.5 align-middle last:border-r-0',
860
- isList && 'px-2 py-1.5',
861
- invalid && 'bg-destructive/5 ring-1 ring-inset ring-destructive/50',
862
- )}
863
- >
864
- {renderCellInput(c, colIdx, rowIdx, row)}
865
- </td>
866
- );
867
- })}
868
- {hasRowActions && (
869
- <td className="px-1 py-0.5 align-middle whitespace-nowrap">
870
- <div className="flex items-center justify-center gap-0.5">
871
- {!isGhost && showExpand && (
872
- <Button
873
- type="button"
874
- variant="ghost"
875
- size="icon"
876
- className="h-8 w-8 text-muted-foreground hover:text-foreground"
877
- aria-label="Open row"
878
- title="Open full form"
879
- data-testid={`line-items-expand-${rowIdx}`}
880
- onClick={() => onRowExpand!(rowIdx)}
881
- >
882
- <Maximize2 className="h-3.5 w-3.5" />
883
- </Button>
884
- )}
885
- {!isGhost && allowDuplicate && (
886
- <Button
887
- type="button"
888
- variant="ghost"
889
- size="icon"
890
- // Always visible (not hover-revealed): row actions must be
891
- // discoverable and reachable on touch/coarse-pointer devices,
892
- // which have no hover. The action column width is reserved
893
- // regardless, so this adds no layout shift.
894
- className="h-8 w-8 text-muted-foreground hover:text-foreground"
895
- aria-label="Duplicate row"
896
- title="Duplicate line"
897
- data-testid={`line-items-duplicate-${rowIdx}`}
898
- onClick={() => duplicateRow(rowIdx)}
899
- disabled={disabled || (maxRows != null && rows.length >= maxRows)}
900
- >
901
- <Copy className="h-3.5 w-3.5" />
902
- </Button>
903
- )}
904
- {!isGhost && allowDelete && (
905
- <Button
906
- type="button"
907
- variant="ghost"
908
- size="icon"
909
- // Always visible — see the duplicate button above.
910
- className="h-8 w-8 text-muted-foreground hover:text-destructive"
911
- aria-label="Remove row"
912
- data-testid={`line-items-remove-${rowIdx}`}
913
- onClick={() => removeRow(rowIdx)}
914
- disabled={disabled || rows.length <= minRows}
915
- >
916
- <Trash2 className="h-4 w-4" />
917
- </Button>
918
- )}
919
- </div>
920
- </td>
921
- )}
922
- </tr>
923
- );
924
- })
925
- )}
926
- </tbody>
927
- {showTotal && (
928
- <tfoot className="border-t border-border bg-muted/40">
929
- <tr>
930
- <td
931
- colSpan={Math.max((showLineNumbers ? 1 : 0) + totalColIndex, 1)}
932
- className="px-3 py-2 text-right text-xs font-medium text-muted-foreground"
933
- >
934
- Total
935
- </td>
936
- <td className="px-3 py-2 text-right font-semibold text-foreground tabular-nums" data-testid="line-items-total">
937
- {total.toLocaleString()}
938
- </td>
939
- {(columns.length - totalColIndex - 1 + (hasRowActions ? 1 : 0)) > 0 && (
940
- <td colSpan={columns.length - totalColIndex - 1 + (hasRowActions ? 1 : 0)} />
941
- )}
942
- </tr>
943
- </tfoot>
944
- )}
945
- </table>
946
- </div>
947
-
948
- {allowAdd && (
949
- <Button
950
- type="button"
951
- variant="outline"
952
- size="sm"
953
- onClick={isList && onAdd ? onAdd : addRow}
954
- disabled={maxRows != null && rows.length >= maxRows}
955
- data-testid="line-items-add"
956
- >
957
- <Plus className="mr-1.5 h-4 w-4" />
958
- {cfg.add_label || 'Add line'}
959
- </Button>
960
- )}
961
- </div>
962
- );
963
- }
964
-
965
- /** Semantic alias — the master-detail subform's child grid. */
966
- export const LineItemsField = GridField;