@object-ui/fields 17.5.0 → 17.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/CHANGELOG.md +714 -0
  2. package/dist/FieldEditWidget.d.ts +16 -2
  3. package/dist/field-type-alias.d.ts +16 -0
  4. package/dist/index.cjs +4 -4
  5. package/dist/index.css +27 -9
  6. package/dist/index.d.ts +425 -72
  7. package/dist/index.js +2530 -2228
  8. package/dist/widgets/AddressField.d.ts +3 -3
  9. package/dist/widgets/AutoNumberField.d.ts +1 -1
  10. package/dist/widgets/AvatarField.d.ts +1 -1
  11. package/dist/widgets/BooleanField.d.ts +1 -1
  12. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/DateField.d.ts +1 -1
  18. package/dist/widgets/DateTimeField.d.ts +1 -1
  19. package/dist/widgets/EmailField.d.ts +1 -1
  20. package/dist/widgets/FileField.d.ts +18 -2
  21. package/dist/widgets/FilterConditionField.d.ts +28 -1
  22. package/dist/widgets/FormulaField.d.ts +1 -1
  23. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  24. package/dist/widgets/GeolocationField.d.ts +1 -1
  25. package/dist/widgets/GridField.d.ts +56 -4
  26. package/dist/widgets/ImageField.d.ts +1 -1
  27. package/dist/widgets/LocationField.d.ts +1 -1
  28. package/dist/widgets/LookupField.d.ts +1 -1
  29. package/dist/widgets/MasterDetailField.d.ts +1 -1
  30. package/dist/widgets/MultiSelectField.d.ts +1 -1
  31. package/dist/widgets/NumberField.d.ts +1 -1
  32. package/dist/widgets/ObjectField.d.ts +1 -1
  33. package/dist/widgets/ObjectRefField.d.ts +1 -1
  34. package/dist/widgets/OptionsEmptyState.d.ts +13 -7
  35. package/dist/widgets/PasswordField.d.ts +1 -1
  36. package/dist/widgets/PercentField.d.ts +1 -1
  37. package/dist/widgets/PhoneField.d.ts +1 -1
  38. package/dist/widgets/QRCodeField.d.ts +1 -1
  39. package/dist/widgets/RadioField.d.ts +1 -1
  40. package/dist/widgets/RatingField.d.ts +1 -1
  41. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  42. package/dist/widgets/RecordPickerDialog.d.ts +2 -4
  43. package/dist/widgets/RichTextField.d.ts +37 -1
  44. package/dist/widgets/SelectField.d.ts +1 -1
  45. package/dist/widgets/SignatureField.d.ts +32 -2
  46. package/dist/widgets/SliderField.d.ts +37 -2
  47. package/dist/widgets/SummaryField.d.ts +1 -1
  48. package/dist/widgets/TagsField.d.ts +1 -1
  49. package/dist/widgets/TextAreaField.d.ts +6 -2
  50. package/dist/widgets/TextField.d.ts +1 -1
  51. package/dist/widgets/TimeField.d.ts +1 -1
  52. package/dist/widgets/UrlField.d.ts +1 -1
  53. package/dist/widgets/UserField.d.ts +1 -1
  54. package/dist/widgets/VectorField.d.ts +1 -1
  55. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  56. package/dist/widgets/address-format.d.ts +27 -7
  57. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  58. package/dist/widgets/richTextDisplay.d.ts +80 -0
  59. package/dist/widgets/toHostGroupProps.d.ts +119 -21
  60. package/dist/widgets/types.d.ts +8 -3
  61. package/package.json +9 -10
  62. package/dist/widgets/CharacterCount.d.ts +0 -56
  63. package/src/FieldEditWidget.test.ts +0 -133
  64. package/src/FieldEditWidget.tsx +0 -207
  65. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  66. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  67. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  68. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  69. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  70. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  71. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  72. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  73. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  74. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  75. package/src/__tests__/cell-truncation.test.tsx +0 -96
  76. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  77. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  78. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  79. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  80. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  81. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  82. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  83. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  84. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  85. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  86. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  87. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  88. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  89. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  90. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  91. package/src/__tests__/registry-collision.test.ts +0 -91
  92. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  93. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  94. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  95. package/src/__tests__/validation-feedback.test.tsx +0 -361
  96. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  97. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  98. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  99. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  100. package/src/coerce-safe-value.test.ts +0 -34
  101. package/src/complex-widgets.test.tsx +0 -924
  102. package/src/currency.ts +0 -144
  103. package/src/datetime-widgets.test.tsx +0 -185
  104. package/src/field-type-alias.multiple.test.ts +0 -81
  105. package/src/field-type-alias.ts +0 -156
  106. package/src/field-type-coverage.test.ts +0 -67
  107. package/src/index.css +0 -44
  108. package/src/index.tsx +0 -2745
  109. package/src/standard-widgets.test.tsx +0 -364
  110. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  111. package/src/widgets/AddressField.tsx +0 -182
  112. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  113. package/src/widgets/AutoNumberField.tsx +0 -18
  114. package/src/widgets/AvatarField.tsx +0 -117
  115. package/src/widgets/BooleanField.tsx +0 -86
  116. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  117. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  118. package/src/widgets/CharacterCount.tsx +0 -263
  119. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  120. package/src/widgets/CheckboxesField.tsx +0 -169
  121. package/src/widgets/CodeField.tsx +0 -41
  122. package/src/widgets/ColorField.tsx +0 -53
  123. package/src/widgets/CurrencyField.tsx +0 -135
  124. package/src/widgets/DateField.tsx +0 -40
  125. package/src/widgets/DateTimeField.tsx +0 -46
  126. package/src/widgets/EmailField.tsx +0 -37
  127. package/src/widgets/FileField.tsx +0 -512
  128. package/src/widgets/FilterConditionField.tsx +0 -454
  129. package/src/widgets/FormulaField.tsx +0 -38
  130. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  131. package/src/widgets/GeolocationField.tsx +0 -193
  132. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  133. package/src/widgets/GridField.test.tsx +0 -632
  134. package/src/widgets/GridField.tsx +0 -1070
  135. package/src/widgets/ImageCropperDialog.tsx +0 -245
  136. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  137. package/src/widgets/ImageField.tsx +0 -305
  138. package/src/widgets/ImageLightbox.test.tsx +0 -52
  139. package/src/widgets/ImageLightbox.tsx +0 -108
  140. package/src/widgets/LocationField.tsx +0 -57
  141. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  142. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  143. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  144. package/src/widgets/LookupField.idField.test.tsx +0 -61
  145. package/src/widgets/LookupField.tsx +0 -1295
  146. package/src/widgets/MarkdownContent.tsx +0 -19
  147. package/src/widgets/MasterDetailField.tsx +0 -131
  148. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  149. package/src/widgets/MultiSelectField.tsx +0 -196
  150. package/src/widgets/NumberField.tsx +0 -49
  151. package/src/widgets/ObjectField.tsx +0 -94
  152. package/src/widgets/ObjectRefField.tsx +0 -121
  153. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  154. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  155. package/src/widgets/OptionsEmptyState.tsx +0 -99
  156. package/src/widgets/PasswordField.tsx +0 -49
  157. package/src/widgets/PeoplePicker.test.tsx +0 -360
  158. package/src/widgets/PeoplePicker.tsx +0 -579
  159. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  160. package/src/widgets/PercentField.tsx +0 -95
  161. package/src/widgets/PersonRow.tsx +0 -117
  162. package/src/widgets/PhoneField.tsx +0 -37
  163. package/src/widgets/QRCodeField.tsx +0 -103
  164. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  165. package/src/widgets/RadioField.tsx +0 -143
  166. package/src/widgets/RatingField.tsx +0 -94
  167. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  168. package/src/widgets/RecipientPickerField.tsx +0 -188
  169. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  170. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  171. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  172. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  173. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  174. package/src/widgets/RichTextField.tsx +0 -176
  175. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  176. package/src/widgets/SelectField.tsx +0 -193
  177. package/src/widgets/SelectionTray.tsx +0 -110
  178. package/src/widgets/SignatureField.tsx +0 -140
  179. package/src/widgets/SliderField.tsx +0 -39
  180. package/src/widgets/SummaryField.tsx +0 -31
  181. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  182. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  183. package/src/widgets/TagsField.tsx +0 -88
  184. package/src/widgets/TextAreaField.tsx +0 -213
  185. package/src/widgets/TextField.tsx +0 -45
  186. package/src/widgets/TimeField.tsx +0 -31
  187. package/src/widgets/UrlField.tsx +0 -46
  188. package/src/widgets/UserField.tsx +0 -59
  189. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  190. package/src/widgets/VectorField.tsx +0 -29
  191. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  192. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  193. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  194. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  195. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  196. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  197. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  198. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  199. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  200. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  201. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  202. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  203. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  204. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  205. package/src/widgets/address-format.ts +0 -120
  206. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  207. package/src/widgets/deriveLookupColumns.ts +0 -108
  208. package/src/widgets/file-size-guard.ts +0 -55
  209. package/src/widgets/file-value.test.ts +0 -230
  210. package/src/widgets/file-value.ts +0 -246
  211. package/src/widgets/nativeDateValue.test.ts +0 -115
  212. package/src/widgets/nativeDateValue.ts +0 -112
  213. package/src/widgets/openNativePicker.ts +0 -18
  214. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  215. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  216. package/src/widgets/personDisplay.test.ts +0 -113
  217. package/src/widgets/personDisplay.ts +0 -92
  218. package/src/widgets/recentLookups.test.ts +0 -45
  219. package/src/widgets/recentLookups.ts +0 -47
  220. package/src/widgets/toDomProps.ts +0 -204
  221. package/src/widgets/toHostGroupProps.ts +0 -102
  222. package/src/widgets/types.ts +0 -242
  223. package/src/widgets/useCascadingOptions.ts +0 -46
  224. package/src/widgets/useFieldTranslation.ts +0 -132
  225. package/src/widgets/useRecordQuery.test.ts +0 -174
  226. package/src/widgets/useRecordQuery.ts +0 -291
  227. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  228. package/src/widgets/useUploadingSignal.ts +0 -20
  229. package/src/withFieldCarrier.tsx +0 -75
@@ -1,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;