@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.
- package/CHANGELOG.md +6150 -0
- package/README.md +108 -8
- package/dist/FieldEditWidget.d.ts +70 -8
- package/dist/coerceToSafeValue.d.ts +54 -0
- package/dist/currency.d.ts +1 -1
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.css +53 -11
- package/dist/index.d.ts +653 -128
- package/dist/index.js +4275 -3171
- package/dist/widgets/AddressField.d.ts +3 -3
- package/dist/widgets/AutoNumberField.d.ts +1 -1
- package/dist/widgets/AvatarField.d.ts +20 -4
- package/dist/widgets/BooleanField.d.ts +2 -2
- package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/DateField.d.ts +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +18 -2
- package/dist/widgets/FilterConditionField.d.ts +43 -2
- package/dist/widgets/FormulaField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
- package/dist/widgets/GeolocationField.d.ts +10 -4
- package/dist/widgets/GridField.d.ts +102 -86
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +31 -4
- package/dist/widgets/LookupField.d.ts +9 -1
- package/dist/widgets/MasterDetailField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +10 -3
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +38 -8
- package/dist/widgets/PasswordField.d.ts +15 -1
- package/dist/widgets/PeoplePicker.d.ts +17 -2
- package/dist/widgets/PercentField.d.ts +3 -2
- package/dist/widgets/PersonRow.d.ts +13 -2
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +44 -1
- package/dist/widgets/RecordPickerDialog.d.ts +33 -16
- package/dist/widgets/RichTextField.d.ts +37 -1
- package/dist/widgets/SelectField.d.ts +1 -1
- package/dist/widgets/SelectionTray.d.ts +13 -2
- package/dist/widgets/SignatureField.d.ts +32 -2
- package/dist/widgets/SliderField.d.ts +37 -2
- package/dist/widgets/SummaryField.d.ts +1 -1
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +6 -2
- package/dist/widgets/TextField.d.ts +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/UserField.d.ts +1 -1
- package/dist/widgets/VectorField.d.ts +1 -1
- package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
- package/dist/widgets/address-format.d.ts +27 -7
- package/dist/widgets/file-affordance.d.ts +33 -0
- package/dist/widgets/file-size-guard.d.ts +5 -23
- package/dist/widgets/file-value.d.ts +38 -9
- package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
- package/dist/widgets/nativeDateValue.d.ts +9 -27
- package/dist/widgets/number-format.d.ts +137 -0
- package/dist/widgets/numberBadInput.d.ts +135 -0
- package/dist/widgets/personDisplay.d.ts +22 -4
- package/dist/widgets/richTextDisplay.d.ts +116 -0
- package/dist/widgets/toDomProps.d.ts +10 -1
- package/dist/widgets/toHostGroupProps.d.ts +172 -21
- package/dist/widgets/toHostProps.d.ts +105 -0
- package/dist/widgets/types.d.ts +19 -6
- package/dist/widgets/upload-error-message.d.ts +6 -0
- package/dist/widgets/uploadingScope.d.ts +142 -0
- package/dist/widgets/useCascadingOptions.d.ts +3 -2
- package/dist/widgets/useRecordQuery.d.ts +4 -2
- package/dist/widgets/useUploadingSignal.d.ts +8 -0
- package/package.json +11 -13
- package/dist/widgets/CharacterCount.d.ts +0 -56
- package/src/FieldEditWidget.test.ts +0 -133
- package/src/FieldEditWidget.tsx +0 -207
- package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
- package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
- package/src/__tests__/DateCellRenderer.test.tsx +0 -82
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
- package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
- package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
- package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
- package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
- package/src/__tests__/cell-truncation.test.tsx +0 -96
- package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
- package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
- package/src/__tests__/date-locale-channel.test.tsx +0 -305
- package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
- package/src/__tests__/group-labelling-declaration.test.ts +0 -148
- package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
- package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
- package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
- package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
- package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
- package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
- package/src/__tests__/registry-collision.test.ts +0 -91
- package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
- package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
- package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
- package/src/__tests__/validation-feedback.test.tsx +0 -361
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
- package/src/__tests__/widget-props-contract.test.tsx +0 -142
- package/src/coerce-safe-value.test.ts +0 -34
- package/src/complex-widgets.test.tsx +0 -924
- package/src/currency.ts +0 -144
- package/src/datetime-widgets.test.tsx +0 -185
- package/src/field-type-alias.multiple.test.ts +0 -81
- package/src/field-type-alias.ts +0 -156
- package/src/field-type-coverage.test.ts +0 -67
- package/src/index.css +0 -44
- package/src/index.tsx +0 -2745
- package/src/standard-widgets.test.tsx +0 -364
- package/src/widgets/AddressField.postalCode.test.tsx +0 -257
- package/src/widgets/AddressField.tsx +0 -182
- package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
- package/src/widgets/AutoNumberField.tsx +0 -18
- package/src/widgets/AvatarField.tsx +0 -117
- package/src/widgets/BooleanField.tsx +0 -86
- package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
- package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
- package/src/widgets/CharacterCount.tsx +0 -263
- package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
- package/src/widgets/CheckboxesField.tsx +0 -169
- package/src/widgets/CodeField.tsx +0 -41
- package/src/widgets/ColorField.tsx +0 -53
- package/src/widgets/CurrencyField.tsx +0 -135
- package/src/widgets/DateField.tsx +0 -40
- package/src/widgets/DateTimeField.tsx +0 -46
- package/src/widgets/EmailField.tsx +0 -37
- package/src/widgets/FileField.tsx +0 -512
- package/src/widgets/FilterConditionField.tsx +0 -454
- package/src/widgets/FormulaField.tsx +0 -38
- package/src/widgets/FullscreenFieldEditor.tsx +0 -60
- package/src/widgets/GeolocationField.tsx +0 -193
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
- package/src/widgets/GridField.test.tsx +0 -632
- package/src/widgets/GridField.tsx +0 -1070
- package/src/widgets/ImageCropperDialog.tsx +0 -245
- package/src/widgets/ImageField.maxSize.test.tsx +0 -154
- package/src/widgets/ImageField.tsx +0 -305
- package/src/widgets/ImageLightbox.test.tsx +0 -52
- package/src/widgets/ImageLightbox.tsx +0 -108
- package/src/widgets/LocationField.tsx +0 -57
- package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
- package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
- package/src/widgets/LookupField.hydration.test.tsx +0 -136
- package/src/widgets/LookupField.idField.test.tsx +0 -61
- package/src/widgets/LookupField.tsx +0 -1295
- package/src/widgets/MarkdownContent.tsx +0 -19
- package/src/widgets/MasterDetailField.tsx +0 -131
- package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
- package/src/widgets/MultiSelectField.tsx +0 -196
- package/src/widgets/NumberField.tsx +0 -49
- package/src/widgets/ObjectField.tsx +0 -94
- package/src/widgets/ObjectRefField.tsx +0 -121
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
- package/src/widgets/OptionsEmptyState.test.tsx +0 -169
- package/src/widgets/OptionsEmptyState.tsx +0 -99
- package/src/widgets/PasswordField.tsx +0 -49
- package/src/widgets/PeoplePicker.test.tsx +0 -360
- package/src/widgets/PeoplePicker.tsx +0 -579
- package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
- package/src/widgets/PercentField.tsx +0 -95
- package/src/widgets/PersonRow.tsx +0 -117
- package/src/widgets/PhoneField.tsx +0 -37
- package/src/widgets/QRCodeField.tsx +0 -103
- package/src/widgets/RadioField.cascade.test.tsx +0 -141
- package/src/widgets/RadioField.tsx +0 -143
- package/src/widgets/RatingField.tsx +0 -94
- package/src/widgets/RecipientPickerField.test.tsx +0 -157
- package/src/widgets/RecipientPickerField.tsx +0 -188
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
- package/src/widgets/RecordPickerDialog.tsx +0 -1365
- package/src/widgets/RichTextField.tsx +0 -176
- package/src/widgets/SelectField.cascade.test.tsx +0 -138
- package/src/widgets/SelectField.tsx +0 -193
- package/src/widgets/SelectionTray.tsx +0 -110
- package/src/widgets/SignatureField.tsx +0 -140
- package/src/widgets/SliderField.tsx +0 -39
- package/src/widgets/SummaryField.tsx +0 -31
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
- package/src/widgets/TagsField.placeholder.test.tsx +0 -95
- package/src/widgets/TagsField.tsx +0 -88
- package/src/widgets/TextAreaField.tsx +0 -213
- package/src/widgets/TextField.tsx +0 -45
- package/src/widgets/TimeField.tsx +0 -31
- package/src/widgets/UrlField.tsx +0 -46
- package/src/widgets/UserField.tsx +0 -59
- package/src/widgets/UserFieldPicker.test.tsx +0 -78
- package/src/widgets/VectorField.tsx +0 -29
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
- package/src/widgets/_cell-renderer-bridge.ts +0 -19
- package/src/widgets/address-format.ts +0 -120
- package/src/widgets/deriveLookupColumns.test.ts +0 -112
- package/src/widgets/deriveLookupColumns.ts +0 -108
- package/src/widgets/file-size-guard.ts +0 -55
- package/src/widgets/file-value.test.ts +0 -230
- package/src/widgets/file-value.ts +0 -246
- package/src/widgets/nativeDateValue.test.ts +0 -115
- package/src/widgets/nativeDateValue.ts +0 -112
- package/src/widgets/openNativePicker.ts +0 -18
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
- package/src/widgets/personDisplay.test.ts +0 -113
- package/src/widgets/personDisplay.ts +0 -92
- package/src/widgets/recentLookups.test.ts +0 -45
- package/src/widgets/recentLookups.ts +0 -47
- package/src/widgets/toDomProps.ts +0 -204
- package/src/widgets/toHostGroupProps.ts +0 -102
- package/src/widgets/types.ts +0 -242
- package/src/widgets/useCascadingOptions.ts +0 -46
- package/src/widgets/useFieldTranslation.ts +0 -132
- package/src/widgets/useRecordQuery.test.ts +0 -174
- package/src/widgets/useRecordQuery.ts +0 -291
- package/src/widgets/useUploadingSignal.test.tsx +0 -62
- package/src/widgets/useUploadingSignal.ts +0 -20
- 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;
|