@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,1365 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
Button,
|
|
4
|
-
cn,
|
|
5
|
-
Dialog,
|
|
6
|
-
DialogContent,
|
|
7
|
-
DialogHeader,
|
|
8
|
-
DialogTitle,
|
|
9
|
-
DialogFooter,
|
|
10
|
-
Input,
|
|
11
|
-
Label,
|
|
12
|
-
Select,
|
|
13
|
-
SelectContent,
|
|
14
|
-
SelectItem,
|
|
15
|
-
SelectTrigger,
|
|
16
|
-
SelectValue,
|
|
17
|
-
Checkbox,
|
|
18
|
-
Table,
|
|
19
|
-
TableHeader,
|
|
20
|
-
TableBody,
|
|
21
|
-
TableHead,
|
|
22
|
-
TableRow,
|
|
23
|
-
TableCell,
|
|
24
|
-
Separator,
|
|
25
|
-
Skeleton,
|
|
26
|
-
} from '@object-ui/components';
|
|
27
|
-
import {
|
|
28
|
-
Search,
|
|
29
|
-
Loader2,
|
|
30
|
-
AlertCircle,
|
|
31
|
-
ChevronLeft,
|
|
32
|
-
ChevronRight,
|
|
33
|
-
Check,
|
|
34
|
-
ArrowUpDown,
|
|
35
|
-
ArrowUp,
|
|
36
|
-
ArrowDown,
|
|
37
|
-
SlidersHorizontal,
|
|
38
|
-
X,
|
|
39
|
-
} from 'lucide-react';
|
|
40
|
-
import type { DataSource, LookupColumnDef, LookupFilterDef } from '@object-ui/types';
|
|
41
|
-
// The repo's single filter sink (`packages/core/src/utils/filter-converter.ts`)
|
|
42
|
-
// — shared with plugin-list's `buildEffectiveFilter` and plugin-view's
|
|
43
|
-
// ObjectView, so a spec `ViewFilterRule[]` lowers in exactly one place.
|
|
44
|
-
import { mergeFilterNodes } from '@object-ui/core';
|
|
45
|
-
import { useSafeFieldLabel, useDisplayLocale } from '@object-ui/i18n';
|
|
46
|
-
import { useFieldTranslation } from './useFieldTranslation';
|
|
47
|
-
import { useRecordQuery } from './useRecordQuery';
|
|
48
|
-
|
|
49
|
-
/** Default page size for the Record Picker dialog */
|
|
50
|
-
const DEFAULT_PAGE_SIZE = 10;
|
|
51
|
-
|
|
52
|
-
/** Minimum column width when resizing (px) */
|
|
53
|
-
const MIN_COL_WIDTH = 60;
|
|
54
|
-
|
|
55
|
-
/** Number of skeleton rows displayed during initial loading */
|
|
56
|
-
const SKELETON_ROW_COUNT = 5;
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Cell renderer function signature — matches getCellRenderer from @object-ui/fields.
|
|
60
|
-
* Accepts a field type and returns a React component that renders a formatted cell.
|
|
61
|
-
*/
|
|
62
|
-
export type CellRendererResolver = (fieldType: string) => React.FC<{ value: any; field: any }>;
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Filter column definition used by the inline filter bar.
|
|
66
|
-
* A subset of LookupColumnDef enriched with filter-specific metadata.
|
|
67
|
-
* Compatible with FilterUISchema.filters entries for easy bridging.
|
|
68
|
-
*/
|
|
69
|
-
export interface RecordPickerFilterColumn {
|
|
70
|
-
field: string;
|
|
71
|
-
label?: string;
|
|
72
|
-
type: 'text' | 'number' | 'select' | 'date' | 'boolean';
|
|
73
|
-
options?: Array<{ label: string; value: any }>;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Props passed to the custom filter bar renderer (renderFilterBar slot).
|
|
78
|
-
* Allows plugging in FilterUI or any custom component.
|
|
79
|
-
*/
|
|
80
|
-
export interface RecordPickerFilterBarProps {
|
|
81
|
-
/** Filter column definitions describing each filterable field */
|
|
82
|
-
filterColumns: RecordPickerFilterColumn[];
|
|
83
|
-
/** Current filter values keyed by field name */
|
|
84
|
-
values: Record<string, any>;
|
|
85
|
-
/** Called when a single filter value changes */
|
|
86
|
-
onChange: (field: string, value: any) => void;
|
|
87
|
-
/** Clear all filter values */
|
|
88
|
-
onClear: () => void;
|
|
89
|
-
/** Number of actively applied filters */
|
|
90
|
-
activeCount: number;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Props passed to the custom grid renderer (renderGrid slot).
|
|
95
|
-
* Allows plugging in ObjectGrid or any compatible table component.
|
|
96
|
-
*/
|
|
97
|
-
export interface RecordPickerGridSlotProps {
|
|
98
|
-
/** Resolved column definitions */
|
|
99
|
-
columns: LookupColumnDef[];
|
|
100
|
-
/** Current page of records */
|
|
101
|
-
records: any[];
|
|
102
|
-
/** Whether data is loading */
|
|
103
|
-
loading: boolean;
|
|
104
|
-
/** Total record count across all pages */
|
|
105
|
-
totalCount: number;
|
|
106
|
-
/** Current page number (1-based) */
|
|
107
|
-
currentPage: number;
|
|
108
|
-
/** Records per page */
|
|
109
|
-
pageSize: number;
|
|
110
|
-
/** Current sort field, null if unsorted */
|
|
111
|
-
sortField: string | null;
|
|
112
|
-
/** Current sort direction */
|
|
113
|
-
sortDirection: 'asc' | 'desc';
|
|
114
|
-
/** Called when a column header is clicked to sort */
|
|
115
|
-
onSort: (field: string) => void;
|
|
116
|
-
/** Called when page changes */
|
|
117
|
-
onPageChange: (page: number) => void;
|
|
118
|
-
/** Called when a row is clicked */
|
|
119
|
-
onRowClick: (record: any) => void;
|
|
120
|
-
/** Check if a record is selected */
|
|
121
|
-
isSelected: (record: any) => boolean;
|
|
122
|
-
/** Whether multiple selection is enabled */
|
|
123
|
-
multiple: boolean;
|
|
124
|
-
/** Record ID field name */
|
|
125
|
-
idField: string;
|
|
126
|
-
/** Cell renderer resolver */
|
|
127
|
-
cellRenderer?: CellRendererResolver;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Normalise a lookup_columns entry (string | LookupColumnDef) into a
|
|
132
|
-
* concrete LookupColumnDef object.
|
|
133
|
-
*/
|
|
134
|
-
function normalizeColumn(col: string | LookupColumnDef): LookupColumnDef {
|
|
135
|
-
return typeof col === 'string' ? { field: col } : col;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Pretty-print a field name as a column header label.
|
|
140
|
-
* Converts snake_case / camelCase to Title Case.
|
|
141
|
-
*/
|
|
142
|
-
function fieldToLabel(field: string): string {
|
|
143
|
-
return field
|
|
144
|
-
.replace(/_/g, ' ')
|
|
145
|
-
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
146
|
-
.replace(/\b\w/g, c => c.toUpperCase());
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Convert LookupFilterDef[] to a Record<string, any> compatible with
|
|
151
|
-
* QueryParams.$filter. Supports operator mapping for eq/ne/gt/lt/gte/lte/
|
|
152
|
-
* contains/in/notIn.
|
|
153
|
-
*/
|
|
154
|
-
export function lookupFiltersToRecord(
|
|
155
|
-
filters: LookupFilterDef[],
|
|
156
|
-
): Record<string, any> {
|
|
157
|
-
const result: Record<string, any> = {};
|
|
158
|
-
for (const f of filters) {
|
|
159
|
-
switch (f.operator) {
|
|
160
|
-
case 'eq':
|
|
161
|
-
result[f.field] = f.value;
|
|
162
|
-
break;
|
|
163
|
-
case 'ne':
|
|
164
|
-
result[f.field] = { $ne: f.value };
|
|
165
|
-
break;
|
|
166
|
-
case 'gt':
|
|
167
|
-
result[f.field] = { $gt: f.value };
|
|
168
|
-
break;
|
|
169
|
-
case 'lt':
|
|
170
|
-
result[f.field] = { $lt: f.value };
|
|
171
|
-
break;
|
|
172
|
-
case 'gte':
|
|
173
|
-
result[f.field] = { $gte: f.value };
|
|
174
|
-
break;
|
|
175
|
-
case 'lte':
|
|
176
|
-
result[f.field] = { $lte: f.value };
|
|
177
|
-
break;
|
|
178
|
-
case 'contains':
|
|
179
|
-
result[f.field] = { $contains: f.value };
|
|
180
|
-
break;
|
|
181
|
-
case 'in':
|
|
182
|
-
result[f.field] = { $in: f.value };
|
|
183
|
-
break;
|
|
184
|
-
case 'notIn':
|
|
185
|
-
result[f.field] = { $nin: f.value };
|
|
186
|
-
break;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
return result;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* A select option as the object schema declares it — `{ value, label }` plus
|
|
194
|
-
* whatever the renderer also reads (`color`, …), which the i18n translation
|
|
195
|
-
* carries through untouched.
|
|
196
|
-
*/
|
|
197
|
-
type SchemaOption = { value: any; label: string; [key: string]: any };
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Resolve a field's select options from the referenced object's schema field
|
|
201
|
-
* definition (`fieldsMeta[field]`), translated through the shared i18n option
|
|
202
|
-
* path.
|
|
203
|
-
*
|
|
204
|
-
* This is the SINGLE source for both surfaces that need option labels: the
|
|
205
|
-
* table cells (#3333) and the filter panel's select inputs (#3336) — so a
|
|
206
|
-
* picker column and the filter input for that same column can never disagree
|
|
207
|
-
* about what an option is called.
|
|
208
|
-
*
|
|
209
|
-
* Returns `undefined` when the schema field declares no options: a select
|
|
210
|
-
* field with no authored options genuinely has nothing to offer, and
|
|
211
|
-
* synthesising entries from the loaded page's raw stored values would paper
|
|
212
|
-
* over the metadata gap with a list that changes per page.
|
|
213
|
-
*/
|
|
214
|
-
function resolveSchemaOptions(
|
|
215
|
-
meta: { options?: unknown } | undefined,
|
|
216
|
-
objectName: string,
|
|
217
|
-
fieldName: string,
|
|
218
|
-
translateOptions: (o: string, f: string, opts: SchemaOption[]) => SchemaOption[],
|
|
219
|
-
): SchemaOption[] | undefined {
|
|
220
|
-
const raw = meta?.options;
|
|
221
|
-
if (!Array.isArray(raw) || raw.length === 0) return undefined;
|
|
222
|
-
const options = raw as SchemaOption[];
|
|
223
|
-
if (!objectName) return options;
|
|
224
|
-
return translateOptions(objectName, fieldName, options);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Option-value round-trip helpers for the filter panel's `select` input
|
|
229
|
-
* (#3422).
|
|
230
|
-
*
|
|
231
|
-
* Radix `Select` speaks strings only: an option renders as
|
|
232
|
-
* `value={String(opt.value)}` and `onValueChange` hands that same string back.
|
|
233
|
-
* A filter option's value, however, is whatever the metadata author wrote —
|
|
234
|
-
* `lookup_filters: [{ field: 'level', operator: 'in', value: [1, 2, 3] }]`
|
|
235
|
-
* derives options with NUMBER values (`LookupFilterDef.value` is `unknown`) —
|
|
236
|
-
* so writing the control's string straight into `$filter` queried
|
|
237
|
-
* `{ level: "1" }` against records storing `level: 1`, and the panel returned
|
|
238
|
-
* nothing for an option that plainly has records.
|
|
239
|
-
*
|
|
240
|
-
* The remap therefore happens at the CONTROL boundary, not in
|
|
241
|
-
* `filterValuesToRecord`: the control speaks string, the payload keeps the
|
|
242
|
-
* authored type. Coercing downstream would mean guessing whether `"1"` meant
|
|
243
|
-
* `1` or `"1"` — a guess the option list already answers exactly.
|
|
244
|
-
*
|
|
245
|
-
* Replicated from `matchOptionValue` / `toControlValue` in
|
|
246
|
-
* `packages/components/src/renderers/form/option-value.ts` (#3090), which
|
|
247
|
-
* solved the identical morph for the standalone form's select. Those two are
|
|
248
|
-
* module-private to `@object-ui/components` (its public barrel does not export
|
|
249
|
-
* them and the package publishes no deep subpath), so `@object-ui/fields`
|
|
250
|
-
* keeps its own copy of the four lines rather than widening another package's
|
|
251
|
-
* API to share them.
|
|
252
|
-
*/
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Stringify a value for a string-speaking control, preserving null/undefined
|
|
256
|
-
* (an absent value must stay absent, not become `"undefined"`).
|
|
257
|
-
*/
|
|
258
|
-
function toControlValue(value: unknown): string | undefined {
|
|
259
|
-
return value == null ? undefined : String(value);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Map a control's string back to the authored option value, so a numeric /
|
|
264
|
-
* boolean / object option round-trips with its type intact. Falls back to the
|
|
265
|
-
* raw string when nothing matches (a stale value, or a column that declares no
|
|
266
|
-
* options) — the pre-#3422 behaviour, so unmatched paths change nothing.
|
|
267
|
-
*
|
|
268
|
-
* When two options share a `String()` form — `1` and `'1'`, or two object
|
|
269
|
-
* values that both print `[object Object]` — the FIRST match wins. Such a list
|
|
270
|
-
* is already unrenderable as a dropdown (Radix would receive two `SelectItem`s
|
|
271
|
-
* with the same `value`, and React two children with the same key), so this
|
|
272
|
-
* tie-break exists to be deterministic, not to make an ambiguous option list
|
|
273
|
-
* work.
|
|
274
|
-
*/
|
|
275
|
-
function matchOptionValue(
|
|
276
|
-
options: ReadonlyArray<{ value: unknown }> | undefined,
|
|
277
|
-
raw: string,
|
|
278
|
-
): unknown {
|
|
279
|
-
const hit = options?.find(o => String(o.value) === raw);
|
|
280
|
-
return hit ? hit.value : raw;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Convert user-entered filter bar values into a $filter Record.
|
|
285
|
-
* Each key is a field name, each value the user-entered value.
|
|
286
|
-
* Empty/null values are ignored.
|
|
287
|
-
*
|
|
288
|
-
* Note the empty string is the "no filter on this field" sentinel here (and
|
|
289
|
-
* the `select` control's "nothing picked" value), so an option whose authored
|
|
290
|
-
* value is `''` cannot be expressed as an active filter. That predates #3422
|
|
291
|
-
* and is unchanged by it — the round-trip above restores an option's TYPE, it
|
|
292
|
-
* does not redefine what counts as an empty selection.
|
|
293
|
-
*/
|
|
294
|
-
function filterValuesToRecord(
|
|
295
|
-
values: Record<string, any>,
|
|
296
|
-
filterColumns: RecordPickerFilterColumn[],
|
|
297
|
-
): Record<string, any> {
|
|
298
|
-
const result: Record<string, any> = {};
|
|
299
|
-
for (const col of filterColumns) {
|
|
300
|
-
const v = values[col.field];
|
|
301
|
-
if (v === undefined || v === null || v === '') continue;
|
|
302
|
-
if (col.type === 'boolean') {
|
|
303
|
-
result[col.field] = Boolean(v);
|
|
304
|
-
} else if (col.type === 'text') {
|
|
305
|
-
result[col.field] = { $contains: v };
|
|
306
|
-
} else {
|
|
307
|
-
result[col.field] = v;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
return result;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
export interface RecordPickerDialogProps {
|
|
314
|
-
/** Whether the dialog is open */
|
|
315
|
-
open: boolean;
|
|
316
|
-
/** Called when the dialog should close */
|
|
317
|
-
onOpenChange: (open: boolean) => void;
|
|
318
|
-
|
|
319
|
-
/** Dialog title */
|
|
320
|
-
title?: string;
|
|
321
|
-
/** Allow multiple selection */
|
|
322
|
-
multiple?: boolean;
|
|
323
|
-
|
|
324
|
-
/** DataSource to fetch records from */
|
|
325
|
-
dataSource: DataSource;
|
|
326
|
-
/** Object name to query (e.g. 'customers') */
|
|
327
|
-
objectName: string;
|
|
328
|
-
|
|
329
|
-
/** Columns to display. Defaults to [displayField, descriptionField]. */
|
|
330
|
-
columns?: Array<string | LookupColumnDef>;
|
|
331
|
-
/** Primary display field (default: 'name') */
|
|
332
|
-
displayField?: string;
|
|
333
|
-
/**
|
|
334
|
-
* Optional `titleFormat` template (e.g. `"{full_name}"` or
|
|
335
|
-
* `"{case_number} - {subject}"`). When set and the displayField column is
|
|
336
|
-
* auto-inferred, the column renders via the template instead of reading
|
|
337
|
-
* a possibly-missing field. Mirrors how DetailView/ObjectCalendar resolve
|
|
338
|
-
* record titles.
|
|
339
|
-
*/
|
|
340
|
-
titleFormat?: string | null;
|
|
341
|
-
/** Record id field (default: 'id') */
|
|
342
|
-
idField?: string;
|
|
343
|
-
|
|
344
|
-
/** Page size (default: 10) */
|
|
345
|
-
pageSize?: number;
|
|
346
|
-
|
|
347
|
-
/** Currently selected value(s) */
|
|
348
|
-
value?: any;
|
|
349
|
-
/** Called when selection changes */
|
|
350
|
-
onSelect: (value: any) => void;
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* Called with the full record objects corresponding to the selected value(s).
|
|
354
|
-
* Useful for parent components (e.g. LookupField) that need display data
|
|
355
|
-
* (labels, descriptions) for the selected records beyond just their IDs.
|
|
356
|
-
*/
|
|
357
|
-
onSelectRecords?: (records: any[]) => void;
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Base filters applied to every query.
|
|
361
|
-
* Converted from LookupFieldMetadata.lookup_filters.
|
|
362
|
-
* Restricts which records are selectable (e.g. only active records).
|
|
363
|
-
*/
|
|
364
|
-
lookupFilters?: LookupFilterDef[];
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Hard filter constraint applied to every query. Unlike `lookupFilters`,
|
|
368
|
-
* entries here never surface in the filter bar and cannot be overridden by
|
|
369
|
-
* user filter input.
|
|
370
|
-
*
|
|
371
|
-
* Two shapes, discriminated STRUCTURALLY (`Array.isArray`), because the two
|
|
372
|
-
* callers speak two legitimate vocabularies and neither should be bent into
|
|
373
|
-
* the other:
|
|
374
|
-
*
|
|
375
|
-
* - **`QueryParams.$filter` record form** (`{ account: 'a1' }`) — the
|
|
376
|
-
* dependent (cascading) lookup chain, where the parent field's value MUST
|
|
377
|
-
* scope the candidate set (#2215). Merged by KEY OVERWRITE, so a cascaded
|
|
378
|
-
* value REPLACES a stale `lookupFilters` entry on the same field instead of
|
|
379
|
-
* intersecting with it. That precedence is load-bearing: an `and` of both
|
|
380
|
-
* would ask for `account = 'stale' AND account = 'a1'` and return nothing.
|
|
381
|
-
* - **A spec `ViewFilterRule[]`** (`[{ field, operator, value? }]`) — an
|
|
382
|
-
* author's `record:related_list.add.picker.filter`, handed over VERBATIM
|
|
383
|
-
* (#3831). Lowered by `mergeFilterNodes`, the repo's single filter sink, so
|
|
384
|
-
* all 19 `VIEW_FILTER_OPERATORS` reach the wire — including the four
|
|
385
|
-
* (`before`, `after`, `is_empty`, `is_not_empty`) the record form has no
|
|
386
|
-
* `$op` for. No second operator vocabulary is introduced here: two already
|
|
387
|
-
* exist (the spec's `AST_OPERATOR_MAP`, data-objectstack's
|
|
388
|
-
* `FILTER_OPERATOR_ALIASES`) and #3948 is what a third costs.
|
|
389
|
-
*
|
|
390
|
-
* The discriminator is exact rather than heuristic — every AST node is an
|
|
391
|
-
* ARRAY and a rule is a plain OBJECT, the same predicate `toFilterNode` uses.
|
|
392
|
-
*
|
|
393
|
-
* Typed `unknown` rather than `Record< string, any >` on purpose: that type
|
|
394
|
-
* ACCEPTED a rule array (TypeScript lets an array satisfy a string index of
|
|
395
|
-
* `any`), the old object-spread merge then flattened it to
|
|
396
|
-
* `{"0": {...}, "1": {...}}`, and the query filtered on columns literally
|
|
397
|
-
* named `0`/`1` — type-check green, wrong query, no diagnostic anywhere.
|
|
398
|
-
*/
|
|
399
|
-
baseFilter?: unknown;
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Cell renderer resolver function.
|
|
403
|
-
* When provided, columns with a `type` property will be rendered using the
|
|
404
|
-
* resolved cell renderer (e.g. badges for select, formatted currency, etc.).
|
|
405
|
-
* Typically pass `getCellRenderer` from @object-ui/fields.
|
|
406
|
-
*/
|
|
407
|
-
cellRenderer?: CellRendererResolver;
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* The referenced object's schema `fields` map (field name → field
|
|
411
|
-
* definition). When provided, cell renderers receive the FULL field
|
|
412
|
-
* metadata — `options`, `currency`, `scale`, `precision`, `format`,
|
|
413
|
-
* `reference_to`, … — exactly like the list view enriches its columns from
|
|
414
|
-
* the object schema. Without it a `select` column falls back to
|
|
415
|
-
* title-casing the raw stored value instead of resolving the option label
|
|
416
|
-
* (#3333: `manufacturing` rendered as "Manufacturing" instead of the
|
|
417
|
-
* authored option label).
|
|
418
|
-
*
|
|
419
|
-
* The filter bar reads the same map: a `select` filter column with no
|
|
420
|
-
* authored `options` takes them from the schema field here, so the filter
|
|
421
|
-
* panel's dropdown offers exactly the options the table cells render
|
|
422
|
-
* (#3336 — it used to open empty, leaving the field unfilterable).
|
|
423
|
-
*/
|
|
424
|
-
fieldsMeta?: Record<string, any>;
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Filter bar column definitions.
|
|
428
|
-
* When provided, shows an inline filter bar below the search input.
|
|
429
|
-
* Columns can include type-specific inputs (text, number, select, date, boolean).
|
|
430
|
-
*/
|
|
431
|
-
filterColumns?: RecordPickerFilterColumn[];
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Custom filter bar renderer slot.
|
|
435
|
-
* When provided, replaces the built-in filter bar with a custom component
|
|
436
|
-
* (e.g. FilterUI from @object-ui/plugin-view).
|
|
437
|
-
* Receives filter state and callbacks via RecordPickerFilterBarProps.
|
|
438
|
-
*
|
|
439
|
-
* @example
|
|
440
|
-
* renderFilterBar={(props) => (
|
|
441
|
-
* <FilterUI
|
|
442
|
-
* schema={{ type: 'filter-ui', filters: props.filterColumns, layout: 'inline' }}
|
|
443
|
-
* onChange={(values) => Object.entries(values).forEach(([k, v]) => props.onChange(k, v))}
|
|
444
|
-
* />
|
|
445
|
-
* )}
|
|
446
|
-
*/
|
|
447
|
-
renderFilterBar?: (props: RecordPickerFilterBarProps) => React.ReactNode;
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Custom grid renderer slot.
|
|
451
|
-
* When provided, replaces the built-in table with a custom grid component
|
|
452
|
-
* (e.g. ObjectGrid from @object-ui/plugin-grid).
|
|
453
|
-
* Receives data, columns, and interaction callbacks via RecordPickerGridSlotProps.
|
|
454
|
-
*
|
|
455
|
-
* @example
|
|
456
|
-
* renderGrid={(props) => (
|
|
457
|
-
* <ObjectGrid
|
|
458
|
-
* schema={{ type: 'object-grid', objectName, columns: props.columns }}
|
|
459
|
-
* dataSource={dataSource}
|
|
460
|
-
* />
|
|
461
|
-
* )}
|
|
462
|
-
*/
|
|
463
|
-
renderGrid?: (props: RecordPickerGridSlotProps) => React.ReactNode;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/**
|
|
467
|
-
* RecordPickerDialog — Enterprise-grade record selection dialog.
|
|
468
|
-
*
|
|
469
|
-
* Renders records in a table with multi-column display, search,
|
|
470
|
-
* pagination, column sorting, keyboard navigation, loading/error/empty
|
|
471
|
-
* states, and single/multi-select. Responsive: mobile-friendly width
|
|
472
|
-
* via Tailwind breakpoints.
|
|
473
|
-
*/
|
|
474
|
-
export function RecordPickerDialog({
|
|
475
|
-
open,
|
|
476
|
-
onOpenChange,
|
|
477
|
-
title = 'Select Record',
|
|
478
|
-
multiple = false,
|
|
479
|
-
dataSource,
|
|
480
|
-
objectName,
|
|
481
|
-
columns: columnsProp,
|
|
482
|
-
displayField = 'name',
|
|
483
|
-
titleFormat,
|
|
484
|
-
idField = 'id',
|
|
485
|
-
pageSize = DEFAULT_PAGE_SIZE,
|
|
486
|
-
value,
|
|
487
|
-
onSelect,
|
|
488
|
-
onSelectRecords,
|
|
489
|
-
lookupFilters,
|
|
490
|
-
baseFilter,
|
|
491
|
-
cellRenderer,
|
|
492
|
-
fieldsMeta,
|
|
493
|
-
filterColumns,
|
|
494
|
-
renderFilterBar,
|
|
495
|
-
renderGrid,
|
|
496
|
-
}: RecordPickerDialogProps) {
|
|
497
|
-
const { t } = useFieldTranslation();
|
|
498
|
-
const { translateOptions } = useSafeFieldLabel();
|
|
499
|
-
// The one date/number locale resolver: tenant regional default → active UI
|
|
500
|
-
// language → 'en' (objectui#4272). Read unconditionally at component level.
|
|
501
|
-
const displayLocale = useDisplayLocale();
|
|
502
|
-
|
|
503
|
-
// Query state (records/loading/error/total + page/search/sort) lives in the
|
|
504
|
-
// shared useRecordQuery kernel — instantiated after mergedFilter below.
|
|
505
|
-
|
|
506
|
-
// For multi-select, track pending selections before confirming
|
|
507
|
-
const [pendingSelection, setPendingSelection] = useState<Set<any>>(new Set());
|
|
508
|
-
|
|
509
|
-
// Cache selected record objects across page navigations (multi-select).
|
|
510
|
-
// When the user toggles a row the full record is stored here so that
|
|
511
|
-
// onSelectRecords can return complete objects even after paging away.
|
|
512
|
-
const selectedRecordsMap = useRef<Map<any, any>>(new Map());
|
|
513
|
-
|
|
514
|
-
// Keyboard navigation: focused row index
|
|
515
|
-
const [focusedRow, setFocusedRow] = useState(-1);
|
|
516
|
-
const tableBodyRef = useRef<HTMLTableSectionElement>(null);
|
|
517
|
-
|
|
518
|
-
// Filter bar state
|
|
519
|
-
const [filterBarOpen, setFilterBarOpen] = useState(false);
|
|
520
|
-
const [filterValues, setFilterValues] = useState<Record<string, any>>({});
|
|
521
|
-
|
|
522
|
-
// Column resize state: widths keyed by field name
|
|
523
|
-
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
|
524
|
-
const resizeRef = useRef<{ field: string; startX: number; startWidth: number } | null>(null);
|
|
525
|
-
|
|
526
|
-
// Page jump input state
|
|
527
|
-
const [pageJumpValue, setPageJumpValue] = useState('');
|
|
528
|
-
|
|
529
|
-
// Resolved columns
|
|
530
|
-
const resolvedColumns = useMemo<LookupColumnDef[]>(() => {
|
|
531
|
-
if (columnsProp && columnsProp.length > 0) {
|
|
532
|
-
return columnsProp.map(normalizeColumn);
|
|
533
|
-
}
|
|
534
|
-
// Auto-infer: just use displayField
|
|
535
|
-
return [{ field: displayField, label: fieldToLabel(displayField) }];
|
|
536
|
-
}, [columnsProp, displayField]);
|
|
537
|
-
|
|
538
|
-
// Field descriptors handed to the type-aware cell renderers, enriched from
|
|
539
|
-
// the referenced object's schema (`fieldsMeta`) the same way the list view
|
|
540
|
-
// enriches its columns. This is what lets a `select` column resolve its
|
|
541
|
-
// option label (options + i18n) instead of title-casing the raw value
|
|
542
|
-
// (#3333). Columns whose def carries no `type` inherit the schema field's
|
|
543
|
-
// type so authored string `lookup_columns` format identically.
|
|
544
|
-
const columnFieldDescriptors = useMemo<Record<string, any>>(() => {
|
|
545
|
-
const map: Record<string, any> = {};
|
|
546
|
-
for (const col of resolvedColumns) {
|
|
547
|
-
const meta = fieldsMeta?.[col.field];
|
|
548
|
-
const type = col.type ?? meta?.type;
|
|
549
|
-
if (!type) continue;
|
|
550
|
-
const descriptor: any = meta
|
|
551
|
-
? { ...meta, name: col.field, type }
|
|
552
|
-
: { name: col.field, type };
|
|
553
|
-
const options = resolveSchemaOptions(meta, objectName, col.field, translateOptions);
|
|
554
|
-
if (options) descriptor.options = options;
|
|
555
|
-
map[col.field] = descriptor;
|
|
556
|
-
}
|
|
557
|
-
return map;
|
|
558
|
-
}, [resolvedColumns, fieldsMeta, objectName, translateOptions]);
|
|
559
|
-
|
|
560
|
-
// Auto-generate filter columns from lookupFilters when no explicit filterColumns given.
|
|
561
|
-
// Each LookupFilterDef becomes a filterable field with inferred type.
|
|
562
|
-
const baseFilterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
|
|
563
|
-
if (filterColumns && filterColumns.length > 0) return filterColumns;
|
|
564
|
-
// Auto-derive from lookupFilters: each filter entry becomes a filterable field
|
|
565
|
-
if (lookupFilters && lookupFilters.length > 0) {
|
|
566
|
-
return lookupFilters.map(f => {
|
|
567
|
-
// Infer filter input type from value type first, then fall back to operator
|
|
568
|
-
let type: RecordPickerFilterColumn['type'] = 'text';
|
|
569
|
-
if (typeof f.value === 'boolean') {
|
|
570
|
-
type = 'boolean';
|
|
571
|
-
} else if (Array.isArray(f.value)) {
|
|
572
|
-
type = 'select';
|
|
573
|
-
} else if (typeof f.value === 'number') {
|
|
574
|
-
type = 'number';
|
|
575
|
-
} else if (f.operator === 'gt' || f.operator === 'lt' || f.operator === 'gte' || f.operator === 'lte') {
|
|
576
|
-
type = 'number';
|
|
577
|
-
} else if (f.operator === 'in' || f.operator === 'notIn') {
|
|
578
|
-
type = 'select';
|
|
579
|
-
}
|
|
580
|
-
return {
|
|
581
|
-
field: f.field,
|
|
582
|
-
label: fieldToLabel(f.field),
|
|
583
|
-
type,
|
|
584
|
-
// For array values (in/notIn), derive selectable options
|
|
585
|
-
...(Array.isArray(f.value) ? {
|
|
586
|
-
options: (f.value as any[]).map(v => {
|
|
587
|
-
if (v != null && typeof v === 'object') {
|
|
588
|
-
const obj = v as Record<string, unknown>;
|
|
589
|
-
return { label: String(obj.name || obj.label || obj.title || v), value: v };
|
|
590
|
-
}
|
|
591
|
-
return { label: String(v), value: v };
|
|
592
|
-
}),
|
|
593
|
-
} : {}),
|
|
594
|
-
};
|
|
595
|
-
});
|
|
596
|
-
}
|
|
597
|
-
return undefined;
|
|
598
|
-
}, [filterColumns, lookupFilters]);
|
|
599
|
-
|
|
600
|
-
// Filter columns as the filter bar consumes them: every `select` filter
|
|
601
|
-
// carries the options its schema field declares. The filter panel's Select reads
|
|
602
|
-
// `col.options` — a derived select column (LookupField turns each typed picker
|
|
603
|
-
// column into a filter column) carries none, so the dropdown opened empty and
|
|
604
|
-
// the field could not be filtered at all (#3336).
|
|
605
|
-
//
|
|
606
|
-
// The options come from `fieldsMeta` through `resolveSchemaOptions`, i.e. the
|
|
607
|
-
// SAME schema source + i18n translation the table cells use (#3333) — not a
|
|
608
|
-
// second derivation that could drift from the cells. An explicitly authored
|
|
609
|
-
// `options` list on the filter column always wins (it is the more specific
|
|
610
|
-
// statement), and a schema field with no options stays optionless: an empty
|
|
611
|
-
// dropdown for THAT field is the honest rendering of missing metadata.
|
|
612
|
-
const effectiveFilterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
|
|
613
|
-
if (!baseFilterColumns) return undefined;
|
|
614
|
-
return baseFilterColumns.map(col => {
|
|
615
|
-
if (col.type !== 'select') return col;
|
|
616
|
-
if (col.options && col.options.length > 0) return col;
|
|
617
|
-
const options = resolveSchemaOptions(fieldsMeta?.[col.field], objectName, col.field, translateOptions);
|
|
618
|
-
return options ? { ...col, options } : col;
|
|
619
|
-
});
|
|
620
|
-
}, [baseFilterColumns, fieldsMeta, objectName, translateOptions]);
|
|
621
|
-
|
|
622
|
-
// Merge base lookup_filters with user filter bar values, then apply the hard
|
|
623
|
-
// `baseFilter` constraint BY SHAPE (see the prop's own doc for why the two
|
|
624
|
-
// shapes exist):
|
|
625
|
-
//
|
|
626
|
-
// record form → spread LAST, exactly as this merge has always done, so
|
|
627
|
-
// user filter-bar input can never widen it back out and a
|
|
628
|
-
// cascaded parent value replaces a stale same-field
|
|
629
|
-
// `lookupFilters` entry (#2215).
|
|
630
|
-
// rule array → its OWN `and` child via `mergeFilterNodes`, the shared
|
|
631
|
-
// sink, so a spec `ViewFilterRule[]` lowers losslessly
|
|
632
|
-
// (#3831) instead of being spread into `{0: rule}`.
|
|
633
|
-
//
|
|
634
|
-
// When BOTH are in play the record side is still built first and lowered as
|
|
635
|
-
// one node, so its key-overwrite precedence survives the conjunction.
|
|
636
|
-
const mergedFilter = useMemo<unknown>(() => {
|
|
637
|
-
const lookupBase = lookupFilters?.length
|
|
638
|
-
? lookupFiltersToRecord(lookupFilters)
|
|
639
|
-
: {};
|
|
640
|
-
const userFilter = effectiveFilterColumns?.length
|
|
641
|
-
? filterValuesToRecord(filterValues, effectiveFilterColumns)
|
|
642
|
-
: {};
|
|
643
|
-
const rules = Array.isArray(baseFilter) ? baseFilter : undefined;
|
|
644
|
-
const recordBase = rules
|
|
645
|
-
? undefined
|
|
646
|
-
: (baseFilter as Record<string, any> | undefined);
|
|
647
|
-
const combined = { ...lookupBase, ...userFilter, ...(recordBase ?? {}) };
|
|
648
|
-
const record = Object.keys(combined).length > 0 ? combined : undefined;
|
|
649
|
-
return rules ? mergeFilterNodes(record, rules) : record;
|
|
650
|
-
}, [lookupFilters, effectiveFilterColumns, filterValues, baseFilter]);
|
|
651
|
-
|
|
652
|
-
// Shared query kernel: builds params, fetches, and owns records/loading/error/
|
|
653
|
-
// total plus the page/search/sort controls. Selection state stays local (above).
|
|
654
|
-
const query = useRecordQuery({
|
|
655
|
-
dataSource,
|
|
656
|
-
objectName,
|
|
657
|
-
enabled: open,
|
|
658
|
-
pageSize,
|
|
659
|
-
paginate: true,
|
|
660
|
-
filter: mergedFilter,
|
|
661
|
-
});
|
|
662
|
-
// Preserve the previous local names so the handlers and render below are
|
|
663
|
-
// unchanged (the migration is a pure refactor).
|
|
664
|
-
const { records, loading, error } = query;
|
|
665
|
-
const totalCount = query.total;
|
|
666
|
-
const totalPages = query.totalPages;
|
|
667
|
-
const searchQuery = query.search;
|
|
668
|
-
const currentPage = query.page;
|
|
669
|
-
const sortField = query.sort?.field ?? null;
|
|
670
|
-
const sortDirection: 'asc' | 'desc' = query.sort?.direction ?? 'asc';
|
|
671
|
-
const setCurrentPage = query.setPage;
|
|
672
|
-
const handleSort = query.toggleSort;
|
|
673
|
-
const handleSearchChange = query.setSearch;
|
|
674
|
-
|
|
675
|
-
// Reset non-query UI/selection state when the dialog closes. Query state
|
|
676
|
-
// (search/page/sort/records) is cleared by useRecordQuery when `enabled`
|
|
677
|
-
// (== open) goes false, kept separate so resets never cascade into a fetch
|
|
678
|
-
// (React #185).
|
|
679
|
-
useEffect(() => {
|
|
680
|
-
if (!open) {
|
|
681
|
-
setFocusedRow(-1);
|
|
682
|
-
setFilterBarOpen(false);
|
|
683
|
-
setFilterValues({});
|
|
684
|
-
setColumnWidths({});
|
|
685
|
-
setPageJumpValue('');
|
|
686
|
-
setPendingSelection(new Set(
|
|
687
|
-
multiple ? (Array.isArray(value) ? value : []) : [],
|
|
688
|
-
));
|
|
689
|
-
selectedRecordsMap.current.clear();
|
|
690
|
-
}
|
|
691
|
-
// Intentionally depends only on `open` — `multiple` and `value` are
|
|
692
|
-
// captured at close-time and don't need to trigger resets while closed.
|
|
693
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
694
|
-
}, [open]);
|
|
695
|
-
|
|
696
|
-
// Initialize pending selection when dialog opens
|
|
697
|
-
useEffect(() => {
|
|
698
|
-
if (open && multiple) {
|
|
699
|
-
setPendingSelection(new Set(Array.isArray(value) ? value : []));
|
|
700
|
-
}
|
|
701
|
-
}, [open, multiple, value]);
|
|
702
|
-
|
|
703
|
-
// Reset keyboard focus whenever the result set changes (previously done
|
|
704
|
-
// inline at the end of fetchRecords).
|
|
705
|
-
useEffect(() => {
|
|
706
|
-
setFocusedRow(-1);
|
|
707
|
-
}, [records]);
|
|
708
|
-
|
|
709
|
-
// Get record id
|
|
710
|
-
const getRecordId = useCallback(
|
|
711
|
-
(record: any) => record[idField] ?? record.id ?? record._id,
|
|
712
|
-
[idField],
|
|
713
|
-
);
|
|
714
|
-
|
|
715
|
-
// Check if a record is selected
|
|
716
|
-
const isSelected = useCallback(
|
|
717
|
-
(record: any) => {
|
|
718
|
-
const rid = getRecordId(record);
|
|
719
|
-
if (multiple) {
|
|
720
|
-
return pendingSelection.has(rid);
|
|
721
|
-
}
|
|
722
|
-
return value === rid;
|
|
723
|
-
},
|
|
724
|
-
[multiple, value, pendingSelection, getRecordId],
|
|
725
|
-
);
|
|
726
|
-
|
|
727
|
-
// Handle row click
|
|
728
|
-
const handleRowClick = useCallback(
|
|
729
|
-
(record: any) => {
|
|
730
|
-
const rid = getRecordId(record);
|
|
731
|
-
|
|
732
|
-
if (multiple) {
|
|
733
|
-
setPendingSelection(prev => {
|
|
734
|
-
const next = new Set(prev);
|
|
735
|
-
if (next.has(rid)) {
|
|
736
|
-
next.delete(rid);
|
|
737
|
-
selectedRecordsMap.current.delete(rid);
|
|
738
|
-
} else {
|
|
739
|
-
next.add(rid);
|
|
740
|
-
selectedRecordsMap.current.set(rid, record);
|
|
741
|
-
}
|
|
742
|
-
return next;
|
|
743
|
-
});
|
|
744
|
-
} else {
|
|
745
|
-
// Single select — immediately close
|
|
746
|
-
onSelect(rid);
|
|
747
|
-
onSelectRecords?.([record]);
|
|
748
|
-
onOpenChange(false);
|
|
749
|
-
}
|
|
750
|
-
},
|
|
751
|
-
[multiple, getRecordId, onSelect, onSelectRecords, onOpenChange],
|
|
752
|
-
);
|
|
753
|
-
|
|
754
|
-
// Confirm multi-select
|
|
755
|
-
const handleConfirm = useCallback(() => {
|
|
756
|
-
const ids = Array.from(pendingSelection);
|
|
757
|
-
onSelect(ids);
|
|
758
|
-
// Build the full record array from the cache for the caller
|
|
759
|
-
const selectedRecords = ids
|
|
760
|
-
.map(id => selectedRecordsMap.current.get(id))
|
|
761
|
-
.filter(Boolean);
|
|
762
|
-
onSelectRecords?.(selectedRecords);
|
|
763
|
-
onOpenChange(false);
|
|
764
|
-
}, [pendingSelection, onSelect, onSelectRecords, onOpenChange]);
|
|
765
|
-
|
|
766
|
-
// Page navigation
|
|
767
|
-
const handlePrevPage = useCallback(() => {
|
|
768
|
-
setCurrentPage(Math.max(1, currentPage - 1));
|
|
769
|
-
}, [setCurrentPage, currentPage]);
|
|
770
|
-
|
|
771
|
-
const handleNextPage = useCallback(() => {
|
|
772
|
-
setCurrentPage(Math.min(totalPages, currentPage + 1));
|
|
773
|
-
}, [setCurrentPage, currentPage, totalPages]);
|
|
774
|
-
|
|
775
|
-
// Page jump handler
|
|
776
|
-
const handlePageJump = useCallback(
|
|
777
|
-
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
778
|
-
if (e.key !== 'Enter') return;
|
|
779
|
-
const page = parseInt(pageJumpValue, 10);
|
|
780
|
-
if (!isNaN(page) && page >= 1 && page <= totalPages) {
|
|
781
|
-
setCurrentPage(page);
|
|
782
|
-
}
|
|
783
|
-
setPageJumpValue('');
|
|
784
|
-
},
|
|
785
|
-
[pageJumpValue, totalPages],
|
|
786
|
-
);
|
|
787
|
-
|
|
788
|
-
// Keyboard navigation for the table
|
|
789
|
-
const handleTableKeyDown = useCallback(
|
|
790
|
-
(e: React.KeyboardEvent) => {
|
|
791
|
-
if (records.length === 0) return;
|
|
792
|
-
|
|
793
|
-
if (e.key === 'ArrowDown') {
|
|
794
|
-
e.preventDefault();
|
|
795
|
-
setFocusedRow(prev => Math.min(prev + 1, records.length - 1));
|
|
796
|
-
} else if (e.key === 'ArrowUp') {
|
|
797
|
-
e.preventDefault();
|
|
798
|
-
setFocusedRow(prev => Math.max(prev - 1, 0));
|
|
799
|
-
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
800
|
-
e.preventDefault();
|
|
801
|
-
if (focusedRow >= 0 && focusedRow < records.length) {
|
|
802
|
-
handleRowClick(records[focusedRow]);
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
},
|
|
806
|
-
[records, focusedRow, handleRowClick],
|
|
807
|
-
);
|
|
808
|
-
|
|
809
|
-
// Scroll focused row into view
|
|
810
|
-
useEffect(() => {
|
|
811
|
-
if (focusedRow >= 0 && tableBodyRef.current) {
|
|
812
|
-
const row = tableBodyRef.current.querySelector(`[data-row-index="${focusedRow}"]`);
|
|
813
|
-
if (row && typeof row.scrollIntoView === 'function') {
|
|
814
|
-
row.scrollIntoView({ block: 'nearest' });
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
}, [focusedRow]);
|
|
818
|
-
|
|
819
|
-
// Get display value for a cell — type-aware rendering when cellRenderer is provided
|
|
820
|
-
const renderCellContent = useCallback((record: any, col: LookupColumnDef): React.ReactNode => {
|
|
821
|
-
// When the column is the auto-inferred displayField column and the
|
|
822
|
-
// referenced object declares a `titleFormat`, render via the template so
|
|
823
|
-
// users see human-readable names instead of a raw id when the displayField
|
|
824
|
-
// (commonly defaulted to `name`) doesn't exist on the record.
|
|
825
|
-
if (titleFormat && col.field === displayField) {
|
|
826
|
-
const EMPTY = '\u0000';
|
|
827
|
-
const SEP = '[-\\u2013\\u2014|/·,:]';
|
|
828
|
-
let any = false;
|
|
829
|
-
const raw = titleFormat.replace(/\{([^{}]+)\}/g, (_m, key) => {
|
|
830
|
-
const v = (record as any)[key.trim()];
|
|
831
|
-
if (v !== null && v !== undefined && v !== '') {
|
|
832
|
-
any = true;
|
|
833
|
-
return String(v);
|
|
834
|
-
}
|
|
835
|
-
return EMPTY;
|
|
836
|
-
});
|
|
837
|
-
if (any) {
|
|
838
|
-
const out = raw
|
|
839
|
-
.replace(new RegExp(`\\s*${SEP}\\s*${EMPTY}`, 'g'), '')
|
|
840
|
-
.replace(new RegExp(`${EMPTY}\\s*${SEP}\\s*`, 'g'), '')
|
|
841
|
-
.replace(new RegExp(EMPTY, 'g'), '')
|
|
842
|
-
.replace(/\s+/g, ' ')
|
|
843
|
-
.trim();
|
|
844
|
-
if (out) return out;
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
const val = record[col.field];
|
|
849
|
-
|
|
850
|
-
// Use type-aware renderer when a field descriptor (column `type`, or the
|
|
851
|
-
// schema field's type via `fieldsMeta`) and a resolver are available.
|
|
852
|
-
const descriptor = columnFieldDescriptors[col.field];
|
|
853
|
-
if (descriptor && cellRenderer) {
|
|
854
|
-
const Renderer = cellRenderer(descriptor.type);
|
|
855
|
-
if (Renderer) {
|
|
856
|
-
return <Renderer value={val} field={descriptor} />;
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
// Fallback: plain text formatting
|
|
861
|
-
if (val === null || val === undefined) return '';
|
|
862
|
-
if (typeof val === 'object') {
|
|
863
|
-
// Handle MongoDB types / expanded references
|
|
864
|
-
if (val.$numberDecimal) return String(Number(val.$numberDecimal));
|
|
865
|
-
if (val.$oid) return String(val.$oid);
|
|
866
|
-
if (val.$date) return new Date(val.$date).toLocaleDateString(displayLocale);
|
|
867
|
-
if (val.name || val.label) return String(val.name || val.label);
|
|
868
|
-
return JSON.stringify(val);
|
|
869
|
-
}
|
|
870
|
-
if (typeof val === 'boolean') return val ? 'Yes' : 'No';
|
|
871
|
-
return String(val);
|
|
872
|
-
}, [cellRenderer, titleFormat, displayField, columnFieldDescriptors, displayLocale]);
|
|
873
|
-
|
|
874
|
-
// Render sort indicator for a column
|
|
875
|
-
const renderSortIcon = useCallback((field: string) => {
|
|
876
|
-
if (sortField !== field) {
|
|
877
|
-
return <ArrowUpDown className="ml-1 size-3 opacity-40" />;
|
|
878
|
-
}
|
|
879
|
-
return sortDirection === 'asc'
|
|
880
|
-
? <ArrowUp className="ml-1 size-3" />
|
|
881
|
-
: <ArrowDown className="ml-1 size-3" />;
|
|
882
|
-
}, [sortField, sortDirection]);
|
|
883
|
-
|
|
884
|
-
// Column resize: mouse-down on drag handle
|
|
885
|
-
const handleResizeStart = useCallback(
|
|
886
|
-
(e: React.MouseEvent, field: string, currentWidth: number) => {
|
|
887
|
-
e.preventDefault();
|
|
888
|
-
e.stopPropagation();
|
|
889
|
-
resizeRef.current = { field, startX: e.clientX, startWidth: currentWidth };
|
|
890
|
-
|
|
891
|
-
const handleMouseMove = (moveEvt: MouseEvent) => {
|
|
892
|
-
if (!resizeRef.current) return;
|
|
893
|
-
const delta = moveEvt.clientX - resizeRef.current.startX;
|
|
894
|
-
const newWidth = Math.max(MIN_COL_WIDTH, resizeRef.current.startWidth + delta);
|
|
895
|
-
setColumnWidths(prev => ({ ...prev, [resizeRef.current!.field]: newWidth }));
|
|
896
|
-
};
|
|
897
|
-
|
|
898
|
-
const handleMouseUp = () => {
|
|
899
|
-
resizeRef.current = null;
|
|
900
|
-
document.removeEventListener('mousemove', handleMouseMove);
|
|
901
|
-
document.removeEventListener('mouseup', handleMouseUp);
|
|
902
|
-
};
|
|
903
|
-
|
|
904
|
-
document.addEventListener('mousemove', handleMouseMove);
|
|
905
|
-
document.addEventListener('mouseup', handleMouseUp);
|
|
906
|
-
},
|
|
907
|
-
[],
|
|
908
|
-
);
|
|
909
|
-
|
|
910
|
-
// Filter bar: update a single field value
|
|
911
|
-
const handleFilterChange = useCallback(
|
|
912
|
-
(field: string, val: any) => {
|
|
913
|
-
setFilterValues(prev => ({ ...prev, [field]: val }));
|
|
914
|
-
setCurrentPage(1);
|
|
915
|
-
},
|
|
916
|
-
[],
|
|
917
|
-
);
|
|
918
|
-
|
|
919
|
-
// Filter bar: clear all filter values
|
|
920
|
-
const handleFilterClear = useCallback(() => {
|
|
921
|
-
setFilterValues({});
|
|
922
|
-
setCurrentPage(1);
|
|
923
|
-
}, []);
|
|
924
|
-
|
|
925
|
-
// Active filter count for badge
|
|
926
|
-
const activeFilterCount = useMemo(
|
|
927
|
-
() => Object.values(filterValues).filter(v => v !== undefined && v !== null && v !== '').length,
|
|
928
|
-
[filterValues],
|
|
929
|
-
);
|
|
930
|
-
|
|
931
|
-
// Render a single filter bar input
|
|
932
|
-
const renderFilterInput = useCallback(
|
|
933
|
-
(col: RecordPickerFilterColumn) => {
|
|
934
|
-
const val = filterValues[col.field];
|
|
935
|
-
const label = col.label || fieldToLabel(col.field);
|
|
936
|
-
|
|
937
|
-
switch (col.type) {
|
|
938
|
-
case 'select':
|
|
939
|
-
return (
|
|
940
|
-
<div className="space-y-1">
|
|
941
|
-
<Label className="text-xs text-muted-foreground">{label}</Label>
|
|
942
|
-
{/* The control speaks string; the stored filter value keeps the
|
|
943
|
-
authored option type (#3422 — see `matchOptionValue`). */}
|
|
944
|
-
<Select
|
|
945
|
-
value={toControlValue(val) ?? ''}
|
|
946
|
-
onValueChange={v => handleFilterChange(col.field, matchOptionValue(col.options, v))}
|
|
947
|
-
>
|
|
948
|
-
<SelectTrigger className="h-8 text-xs">
|
|
949
|
-
<SelectValue placeholder={t('lookup.filterPlaceholder', { label })} />
|
|
950
|
-
</SelectTrigger>
|
|
951
|
-
<SelectContent>
|
|
952
|
-
{col.options?.map(opt => (
|
|
953
|
-
<SelectItem key={String(opt.value)} value={String(opt.value)}>
|
|
954
|
-
{opt.label}
|
|
955
|
-
</SelectItem>
|
|
956
|
-
))}
|
|
957
|
-
</SelectContent>
|
|
958
|
-
</Select>
|
|
959
|
-
</div>
|
|
960
|
-
);
|
|
961
|
-
case 'number':
|
|
962
|
-
return (
|
|
963
|
-
<div className="space-y-1">
|
|
964
|
-
<Label className="text-xs text-muted-foreground">{label}</Label>
|
|
965
|
-
<Input
|
|
966
|
-
type="number"
|
|
967
|
-
className="h-8 text-xs"
|
|
968
|
-
value={val ?? ''}
|
|
969
|
-
placeholder={t('lookup.filterPlaceholder', { label })}
|
|
970
|
-
onChange={e => {
|
|
971
|
-
const raw = e.target.value;
|
|
972
|
-
handleFilterChange(col.field, raw === '' ? '' : Number(raw));
|
|
973
|
-
}}
|
|
974
|
-
/>
|
|
975
|
-
</div>
|
|
976
|
-
);
|
|
977
|
-
case 'date':
|
|
978
|
-
return (
|
|
979
|
-
<div className="space-y-1">
|
|
980
|
-
<Label className="text-xs text-muted-foreground">{label}</Label>
|
|
981
|
-
<Input
|
|
982
|
-
type="date"
|
|
983
|
-
className="h-8 text-xs"
|
|
984
|
-
value={val ?? ''}
|
|
985
|
-
onChange={e => handleFilterChange(col.field, e.target.value)}
|
|
986
|
-
/>
|
|
987
|
-
</div>
|
|
988
|
-
);
|
|
989
|
-
case 'boolean':
|
|
990
|
-
return (
|
|
991
|
-
<div className="space-y-1">
|
|
992
|
-
<Label className="text-xs text-muted-foreground">{label}</Label>
|
|
993
|
-
<div className="flex items-center gap-2 h-8">
|
|
994
|
-
<Checkbox
|
|
995
|
-
checked={Boolean(val)}
|
|
996
|
-
onCheckedChange={checked => handleFilterChange(col.field, Boolean(checked))}
|
|
997
|
-
/>
|
|
998
|
-
<span className="text-xs text-muted-foreground">{t('lookup.yes')}</span>
|
|
999
|
-
</div>
|
|
1000
|
-
</div>
|
|
1001
|
-
);
|
|
1002
|
-
case 'text':
|
|
1003
|
-
default:
|
|
1004
|
-
return (
|
|
1005
|
-
<div className="space-y-1">
|
|
1006
|
-
<Label className="text-xs text-muted-foreground">{label}</Label>
|
|
1007
|
-
<Input
|
|
1008
|
-
className="h-8 text-xs"
|
|
1009
|
-
value={val ?? ''}
|
|
1010
|
-
placeholder={t('lookup.filterPlaceholder', { label })}
|
|
1011
|
-
onChange={e => handleFilterChange(col.field, e.target.value)}
|
|
1012
|
-
/>
|
|
1013
|
-
</div>
|
|
1014
|
-
);
|
|
1015
|
-
}
|
|
1016
|
-
},
|
|
1017
|
-
[filterValues, handleFilterChange],
|
|
1018
|
-
);
|
|
1019
|
-
|
|
1020
|
-
// Row background class logic: selected > odd-striped > default
|
|
1021
|
-
const getRowBgClass = useCallback((selected: boolean, idx: number) => {
|
|
1022
|
-
if (selected) return 'bg-primary/5 hover:bg-primary/10';
|
|
1023
|
-
if (idx % 2 === 1) return 'bg-muted/20 hover:bg-accent/30';
|
|
1024
|
-
return 'hover:bg-accent/30';
|
|
1025
|
-
}, []);
|
|
1026
|
-
|
|
1027
|
-
return (
|
|
1028
|
-
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
1029
|
-
<DialogContent
|
|
1030
|
-
className="w-[95vw] sm:max-w-3xl lg:max-w-5xl max-h-[85vh] sm:max-h-[80vh] flex flex-col gap-0"
|
|
1031
|
-
data-testid="record-picker-dialog"
|
|
1032
|
-
>
|
|
1033
|
-
<DialogHeader>
|
|
1034
|
-
<DialogTitle>
|
|
1035
|
-
{title}
|
|
1036
|
-
{multiple && <span className="sr-only"> (multiple selection)</span>}
|
|
1037
|
-
</DialogTitle>
|
|
1038
|
-
</DialogHeader>
|
|
1039
|
-
|
|
1040
|
-
{/* Search bar */}
|
|
1041
|
-
<div className="relative rounded-md border bg-muted/30 mb-3">
|
|
1042
|
-
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
1043
|
-
<Input
|
|
1044
|
-
placeholder={t('table.search')}
|
|
1045
|
-
value={searchQuery}
|
|
1046
|
-
onChange={(e) => handleSearchChange(e.target.value)}
|
|
1047
|
-
className="pl-9 border-0 bg-transparent shadow-none focus-visible:ring-0"
|
|
1048
|
-
data-testid="record-picker-search"
|
|
1049
|
-
/>
|
|
1050
|
-
{loading && (
|
|
1051
|
-
<Loader2
|
|
1052
|
-
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground"
|
|
1053
|
-
data-testid="record-picker-loading-indicator"
|
|
1054
|
-
/>
|
|
1055
|
-
)}
|
|
1056
|
-
</div>
|
|
1057
|
-
|
|
1058
|
-
{/* Filter bar (inline) — supports external FilterUI via renderFilterBar slot */}
|
|
1059
|
-
{effectiveFilterColumns && effectiveFilterColumns.length > 0 && (
|
|
1060
|
-
<div className="py-2">
|
|
1061
|
-
{renderFilterBar ? (
|
|
1062
|
-
/* External filter bar (e.g. FilterUI from plugin-view) */
|
|
1063
|
-
<div data-testid="record-picker-filter-bar">
|
|
1064
|
-
{renderFilterBar({
|
|
1065
|
-
filterColumns: effectiveFilterColumns,
|
|
1066
|
-
values: filterValues,
|
|
1067
|
-
onChange: handleFilterChange,
|
|
1068
|
-
onClear: handleFilterClear,
|
|
1069
|
-
activeCount: activeFilterCount,
|
|
1070
|
-
})}
|
|
1071
|
-
</div>
|
|
1072
|
-
) : (
|
|
1073
|
-
/* Built-in filter bar (default) */
|
|
1074
|
-
<>
|
|
1075
|
-
<div className="flex items-center gap-2" data-testid="record-picker-filter-bar">
|
|
1076
|
-
<Button
|
|
1077
|
-
type="button"
|
|
1078
|
-
variant={activeFilterCount > 0 ? 'secondary' : 'outline'}
|
|
1079
|
-
size="sm"
|
|
1080
|
-
className="gap-1.5 shrink-0"
|
|
1081
|
-
onClick={() => setFilterBarOpen(prev => !prev)}
|
|
1082
|
-
>
|
|
1083
|
-
<SlidersHorizontal className="size-3.5" />
|
|
1084
|
-
{t('lookup.filters')}
|
|
1085
|
-
{activeFilterCount > 0 && (
|
|
1086
|
-
<span className="inline-flex h-5 min-w-[20px] items-center justify-center rounded-full bg-primary/10 px-1 text-xs font-medium text-primary">
|
|
1087
|
-
{activeFilterCount}
|
|
1088
|
-
</span>
|
|
1089
|
-
)}
|
|
1090
|
-
</Button>
|
|
1091
|
-
{activeFilterCount > 0 && (
|
|
1092
|
-
<Button
|
|
1093
|
-
type="button"
|
|
1094
|
-
variant="ghost"
|
|
1095
|
-
size="sm"
|
|
1096
|
-
className="gap-1 text-xs"
|
|
1097
|
-
onClick={handleFilterClear}
|
|
1098
|
-
>
|
|
1099
|
-
<X className="size-3" />
|
|
1100
|
-
{t('lookup.clear')}
|
|
1101
|
-
</Button>
|
|
1102
|
-
)}
|
|
1103
|
-
</div>
|
|
1104
|
-
{filterBarOpen && (
|
|
1105
|
-
<div className="mt-2 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 border rounded-md p-3 bg-muted/30" data-testid="record-picker-filter-panel">
|
|
1106
|
-
{effectiveFilterColumns.map(col => (
|
|
1107
|
-
<div key={col.field}>{renderFilterInput(col)}</div>
|
|
1108
|
-
))}
|
|
1109
|
-
</div>
|
|
1110
|
-
)}
|
|
1111
|
-
</>
|
|
1112
|
-
)}
|
|
1113
|
-
</div>
|
|
1114
|
-
)}
|
|
1115
|
-
|
|
1116
|
-
{/* Error state */}
|
|
1117
|
-
{error && (
|
|
1118
|
-
<div className="flex flex-col items-center gap-2 py-4" role="alert">
|
|
1119
|
-
<AlertCircle className="size-5 text-destructive" />
|
|
1120
|
-
<p className="text-sm text-destructive">{error}</p>
|
|
1121
|
-
<Button
|
|
1122
|
-
variant="outline"
|
|
1123
|
-
size="sm"
|
|
1124
|
-
onClick={() => query.refetch()}
|
|
1125
|
-
type="button"
|
|
1126
|
-
>
|
|
1127
|
-
{t('lookup.retry')}
|
|
1128
|
-
</Button>
|
|
1129
|
-
</div>
|
|
1130
|
-
)}
|
|
1131
|
-
|
|
1132
|
-
{/* Grid area — external ObjectGrid via renderGrid slot, or built-in table */}
|
|
1133
|
-
{renderGrid ? (
|
|
1134
|
-
/* External grid component (e.g. ObjectGrid from plugin-grid) */
|
|
1135
|
-
<div className="flex-1 min-h-0" data-testid="record-picker-grid-slot">
|
|
1136
|
-
{renderGrid({
|
|
1137
|
-
columns: resolvedColumns,
|
|
1138
|
-
records,
|
|
1139
|
-
loading,
|
|
1140
|
-
totalCount,
|
|
1141
|
-
currentPage,
|
|
1142
|
-
pageSize,
|
|
1143
|
-
sortField,
|
|
1144
|
-
sortDirection,
|
|
1145
|
-
onSort: handleSort,
|
|
1146
|
-
onPageChange: setCurrentPage,
|
|
1147
|
-
onRowClick: handleRowClick,
|
|
1148
|
-
isSelected,
|
|
1149
|
-
multiple,
|
|
1150
|
-
idField,
|
|
1151
|
-
cellRenderer,
|
|
1152
|
-
})}
|
|
1153
|
-
</div>
|
|
1154
|
-
) : (
|
|
1155
|
-
/* Built-in table (default) */
|
|
1156
|
-
<>
|
|
1157
|
-
{/* Skeleton loading state (initial) */}
|
|
1158
|
-
{loading && records.length === 0 && !error && (
|
|
1159
|
-
<div className="flex-1 overflow-hidden min-h-0 border rounded-md" role="status" aria-live="polite" data-testid="record-picker-skeleton">
|
|
1160
|
-
<Table>
|
|
1161
|
-
<TableHeader>
|
|
1162
|
-
<TableRow className="bg-muted/40">
|
|
1163
|
-
{multiple && <TableHead className="w-10" />}
|
|
1164
|
-
{resolvedColumns.map(col => (
|
|
1165
|
-
<TableHead key={col.field}>
|
|
1166
|
-
<Skeleton className="h-4 w-20" />
|
|
1167
|
-
</TableHead>
|
|
1168
|
-
))}
|
|
1169
|
-
</TableRow>
|
|
1170
|
-
</TableHeader>
|
|
1171
|
-
<TableBody>
|
|
1172
|
-
{Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => (
|
|
1173
|
-
<TableRow key={i}>
|
|
1174
|
-
{multiple && (
|
|
1175
|
-
<TableCell className="w-10">
|
|
1176
|
-
<Skeleton className="size-4 rounded" />
|
|
1177
|
-
</TableCell>
|
|
1178
|
-
)}
|
|
1179
|
-
{resolvedColumns.map(col => (
|
|
1180
|
-
<TableCell key={col.field}>
|
|
1181
|
-
<Skeleton className="h-4 w-full" />
|
|
1182
|
-
</TableCell>
|
|
1183
|
-
))}
|
|
1184
|
-
</TableRow>
|
|
1185
|
-
))}
|
|
1186
|
-
</TableBody>
|
|
1187
|
-
</Table>
|
|
1188
|
-
</div>
|
|
1189
|
-
)}
|
|
1190
|
-
|
|
1191
|
-
{/* Empty state */}
|
|
1192
|
-
{!loading && !error && records.length === 0 && (
|
|
1193
|
-
<div className="py-8 text-center">
|
|
1194
|
-
<p className="text-sm text-muted-foreground">{t('lookup.noRecords')}</p>
|
|
1195
|
-
</div>
|
|
1196
|
-
)}
|
|
1197
|
-
|
|
1198
|
-
{/* Table */}
|
|
1199
|
-
{!error && records.length > 0 && (
|
|
1200
|
-
<div
|
|
1201
|
-
className="relative flex-1 overflow-auto min-h-0 border rounded-md"
|
|
1202
|
-
aria-busy={loading || undefined}
|
|
1203
|
-
data-state={loading ? 'loading' : 'idle'}
|
|
1204
|
-
tabIndex={0}
|
|
1205
|
-
onKeyDown={handleTableKeyDown}
|
|
1206
|
-
role="grid"
|
|
1207
|
-
aria-label="Records"
|
|
1208
|
-
>
|
|
1209
|
-
{/* Loading overlay for subsequent fetches (page/sort/filter) */}
|
|
1210
|
-
{loading && (
|
|
1211
|
-
<div
|
|
1212
|
-
className="absolute inset-0 z-10 flex items-center justify-center bg-background/60"
|
|
1213
|
-
data-testid="record-picker-loading-overlay"
|
|
1214
|
-
>
|
|
1215
|
-
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
1216
|
-
</div>
|
|
1217
|
-
)}
|
|
1218
|
-
<Table style={Object.keys(columnWidths).length > 0 ? { tableLayout: 'fixed' } : undefined}>
|
|
1219
|
-
<TableHeader className="sticky top-0 z-[5] bg-muted/50 [&_tr]:border-b" data-testid="record-picker-sticky-header">
|
|
1220
|
-
<TableRow>
|
|
1221
|
-
{multiple && (
|
|
1222
|
-
<TableHead className="w-10" />
|
|
1223
|
-
)}
|
|
1224
|
-
{resolvedColumns.map(col => {
|
|
1225
|
-
const w = columnWidths[col.field];
|
|
1226
|
-
const styleWidth = w ? { width: `${w}px`, minWidth: `${w}px` } : col.width ? { width: col.width } : undefined;
|
|
1227
|
-
return (
|
|
1228
|
-
<TableHead
|
|
1229
|
-
key={col.field}
|
|
1230
|
-
style={styleWidth}
|
|
1231
|
-
className="cursor-pointer select-none relative group text-xs font-semibold uppercase tracking-wider"
|
|
1232
|
-
onClick={() => handleSort(col.field)}
|
|
1233
|
-
aria-sort={sortField === col.field ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
|
|
1234
|
-
>
|
|
1235
|
-
<span className="inline-flex items-center">
|
|
1236
|
-
{col.label || fieldToLabel(col.field)}
|
|
1237
|
-
{renderSortIcon(col.field)}
|
|
1238
|
-
</span>
|
|
1239
|
-
{/* Column resize handle */}
|
|
1240
|
-
<span
|
|
1241
|
-
role="separator"
|
|
1242
|
-
aria-orientation="vertical"
|
|
1243
|
-
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize opacity-0 group-hover:opacity-100 bg-border hover:bg-primary/50 transition-opacity"
|
|
1244
|
-
onMouseDown={e => {
|
|
1245
|
-
const th = e.currentTarget.parentElement;
|
|
1246
|
-
const rect = th?.getBoundingClientRect();
|
|
1247
|
-
handleResizeStart(e, col.field, rect?.width ?? 100);
|
|
1248
|
-
}}
|
|
1249
|
-
onClick={e => e.stopPropagation()}
|
|
1250
|
-
data-testid={`resize-handle-${col.field}`}
|
|
1251
|
-
/>
|
|
1252
|
-
</TableHead>
|
|
1253
|
-
);
|
|
1254
|
-
})}
|
|
1255
|
-
</TableRow>
|
|
1256
|
-
</TableHeader>
|
|
1257
|
-
<TableBody ref={tableBodyRef}>
|
|
1258
|
-
{records.map((record, idx) => {
|
|
1259
|
-
const rid = getRecordId(record);
|
|
1260
|
-
const selected = isSelected(record);
|
|
1261
|
-
const focused = idx === focusedRow;
|
|
1262
|
-
|
|
1263
|
-
return (
|
|
1264
|
-
<TableRow
|
|
1265
|
-
key={rid ?? idx}
|
|
1266
|
-
data-row-index={idx}
|
|
1267
|
-
className={cn(
|
|
1268
|
-
'cursor-pointer transition-colors',
|
|
1269
|
-
getRowBgClass(selected, idx),
|
|
1270
|
-
focused && 'ring-2 ring-primary ring-inset',
|
|
1271
|
-
)}
|
|
1272
|
-
onClick={() => handleRowClick(record)}
|
|
1273
|
-
data-testid={`record-row-${rid}`}
|
|
1274
|
-
aria-selected={selected}
|
|
1275
|
-
>
|
|
1276
|
-
{multiple && (
|
|
1277
|
-
<TableCell className="w-10">
|
|
1278
|
-
{selected && <Check className="size-4 text-primary" />}
|
|
1279
|
-
</TableCell>
|
|
1280
|
-
)}
|
|
1281
|
-
{resolvedColumns.map(col => (
|
|
1282
|
-
<TableCell key={col.field} className="py-2.5">
|
|
1283
|
-
{renderCellContent(record, col)}
|
|
1284
|
-
</TableCell>
|
|
1285
|
-
))}
|
|
1286
|
-
</TableRow>
|
|
1287
|
-
);
|
|
1288
|
-
})}
|
|
1289
|
-
</TableBody>
|
|
1290
|
-
</Table>
|
|
1291
|
-
</div>
|
|
1292
|
-
)}
|
|
1293
|
-
|
|
1294
|
-
{/* Pagination — fixed bottom bar */}
|
|
1295
|
-
{!error && totalCount > 0 && (
|
|
1296
|
-
<div
|
|
1297
|
-
className="flex items-center justify-between text-sm text-muted-foreground border-t pt-3 mt-2 px-1"
|
|
1298
|
-
data-testid="record-picker-pagination"
|
|
1299
|
-
>
|
|
1300
|
-
<span>
|
|
1301
|
-
{totalCount === 1 ? t('lookup.recordCountOne') : t('lookup.recordCount', { count: totalCount })}
|
|
1302
|
-
{totalPages > 1 && ` · ${t('lookup.pageOf', { current: currentPage, total: totalPages })}`}
|
|
1303
|
-
</span>
|
|
1304
|
-
{totalPages > 1 && (
|
|
1305
|
-
<div className="flex items-center gap-1.5">
|
|
1306
|
-
<Button
|
|
1307
|
-
variant="outline"
|
|
1308
|
-
size="icon"
|
|
1309
|
-
className="size-7"
|
|
1310
|
-
onClick={handlePrevPage}
|
|
1311
|
-
disabled={currentPage <= 1}
|
|
1312
|
-
type="button"
|
|
1313
|
-
aria-label={t('lookup.prevPage')}
|
|
1314
|
-
>
|
|
1315
|
-
<ChevronLeft className="size-4" />
|
|
1316
|
-
</Button>
|
|
1317
|
-
<Input
|
|
1318
|
-
className="h-7 w-12 text-center text-xs px-1"
|
|
1319
|
-
placeholder={String(currentPage)}
|
|
1320
|
-
value={pageJumpValue}
|
|
1321
|
-
onChange={e => setPageJumpValue(e.target.value)}
|
|
1322
|
-
onKeyDown={handlePageJump}
|
|
1323
|
-
aria-label={t('lookup.jumpToPage')}
|
|
1324
|
-
data-testid="record-picker-page-jump"
|
|
1325
|
-
/>
|
|
1326
|
-
<Button
|
|
1327
|
-
variant="outline"
|
|
1328
|
-
size="icon"
|
|
1329
|
-
className="size-7"
|
|
1330
|
-
onClick={handleNextPage}
|
|
1331
|
-
disabled={currentPage >= totalPages}
|
|
1332
|
-
type="button"
|
|
1333
|
-
aria-label={t('lookup.nextPage')}
|
|
1334
|
-
>
|
|
1335
|
-
<ChevronRight className="size-4" />
|
|
1336
|
-
</Button>
|
|
1337
|
-
</div>
|
|
1338
|
-
)}
|
|
1339
|
-
</div>
|
|
1340
|
-
)}
|
|
1341
|
-
</>
|
|
1342
|
-
)}
|
|
1343
|
-
|
|
1344
|
-
{/* Multi-select confirmation */}
|
|
1345
|
-
{multiple && (
|
|
1346
|
-
<DialogFooter>
|
|
1347
|
-
<div className="flex items-center gap-2 w-full justify-between">
|
|
1348
|
-
<span className="text-sm text-muted-foreground">
|
|
1349
|
-
{t('table.selected', { count: pendingSelection.size })}
|
|
1350
|
-
</span>
|
|
1351
|
-
<div className="flex gap-2">
|
|
1352
|
-
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
|
1353
|
-
{t('common.cancel')}
|
|
1354
|
-
</Button>
|
|
1355
|
-
<Button type="button" onClick={handleConfirm}>
|
|
1356
|
-
{t('common.confirm')}
|
|
1357
|
-
</Button>
|
|
1358
|
-
</div>
|
|
1359
|
-
</div>
|
|
1360
|
-
</DialogFooter>
|
|
1361
|
-
)}
|
|
1362
|
-
</DialogContent>
|
|
1363
|
-
</Dialog>
|
|
1364
|
-
);
|
|
1365
|
-
}
|