@object-ui/fields 17.5.0 → 17.7.0

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