@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,1295 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect, useCallback, useRef, useContext, useMemo } from 'react';
|
|
2
|
-
import { cn,
|
|
3
|
-
Button,
|
|
4
|
-
Input,
|
|
5
|
-
Badge,
|
|
6
|
-
Avatar,
|
|
7
|
-
AvatarFallback,
|
|
8
|
-
AvatarImage,
|
|
9
|
-
Popover,
|
|
10
|
-
PopoverTrigger,
|
|
11
|
-
PopoverContent, EmptyValue } from '@object-ui/components';
|
|
12
|
-
import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
|
|
13
|
-
import { FieldWidgetComponentProps } from './types';
|
|
14
|
-
import { toDomProps } from './toDomProps';
|
|
15
|
-
import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
|
|
16
|
-
import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog';
|
|
17
|
-
import type { RecordPickerFilterColumn } from './RecordPickerDialog';
|
|
18
|
-
import { PeoplePicker } from './PeoplePicker';
|
|
19
|
-
import { useRecordQuery } from './useRecordQuery';
|
|
20
|
-
import { deriveLookupColumns } from './deriveLookupColumns';
|
|
21
|
-
import { getRecordDisplayName } from '@object-ui/core';
|
|
22
|
-
import { getRecentLookupIds, pushRecentLookupId } from './recentLookups';
|
|
23
|
-
import { getPersonInitials } from './personDisplay';
|
|
24
|
-
import { getCellRendererResolver } from './_cell-renderer-bridge';
|
|
25
|
-
import { SchemaRendererContext as ImportedSchemaRendererContext, useAction, useHasActionProvider } from '@object-ui/react';
|
|
26
|
-
import { useFieldTranslation } from './useFieldTranslation';
|
|
27
|
-
|
|
28
|
-
export interface LookupOption {
|
|
29
|
-
value: string | number;
|
|
30
|
-
label: string;
|
|
31
|
-
description?: string;
|
|
32
|
-
[key: string]: any;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Page size for the quick-select popover typeahead */
|
|
36
|
-
const LOOKUP_PAGE_SIZE = 50;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* SchemaRendererContext is created by @object-ui/react.
|
|
40
|
-
* Using a static import to be compatible with Next.js Turbopack SSR.
|
|
41
|
-
*/
|
|
42
|
-
const SchemaRendererContext: React.Context<any> = ImportedSchemaRendererContext;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* A relation whose picker should offer inline "create the referenced record" by
|
|
46
|
-
* default. Inline quick-create is a STANDARD capability so a freshly-built app
|
|
47
|
-
* isn't a dead end (an empty required picker → you can create the FIRST related
|
|
48
|
-
* record right here). Platform/system objects are excluded: the user directory
|
|
49
|
-
* (`sys_user` and its bare `user`/`users` aliases) and everything under the
|
|
50
|
-
* `sys_`/`cloud_`/`ai_` namespaces are pre-populated plumbing you must not create
|
|
51
|
-
* from a field picker. A user-authored business entity (customer, pet, book, …)
|
|
52
|
-
* never matches, so it gets the capability.
|
|
53
|
-
*/
|
|
54
|
-
const SYSTEM_REFERENCE_RX = /^(sys_|cloud_|ai_)/;
|
|
55
|
-
const USER_DIRECTORY_REFS = new Set(['user', 'users']);
|
|
56
|
-
function isUserFacingReference(reference: string | undefined): boolean {
|
|
57
|
-
return !!reference && !SYSTEM_REFERENCE_RX.test(reference) && !USER_DIRECTORY_REFS.has(reference);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Render a record title from a `titleFormat` template (e.g. `{full_name}` or
|
|
62
|
-
* `{case_number} - {subject}`). When a templated key resolves to an empty
|
|
63
|
-
* value, the surrounding separator (`-/|·,:` plus em/en dashes) is stripped
|
|
64
|
-
* so we never produce orphan glyphs like `" - foo"`.
|
|
65
|
-
*
|
|
66
|
-
* Mirrors the implementation in `@object-ui/plugin-detail`'s
|
|
67
|
-
* `resolveDisplayTitle` and `@object-ui/plugin-calendar`'s event-title
|
|
68
|
-
* renderer so labels stay consistent across the product.
|
|
69
|
-
*/
|
|
70
|
-
function formatRecordTitle(record: any, titleFormat: string): string | null {
|
|
71
|
-
if (!record || typeof record !== 'object' || !titleFormat) return null;
|
|
72
|
-
const EMPTY = '\u0000';
|
|
73
|
-
const SEP = '[-\\u2013\\u2014|/·,:]';
|
|
74
|
-
let any = false;
|
|
75
|
-
const raw = titleFormat.replace(/\{([^{}]+)\}/g, (_m, key) => {
|
|
76
|
-
const v = (record as any)[key.trim()];
|
|
77
|
-
if (v !== null && v !== undefined && v !== '') {
|
|
78
|
-
any = true;
|
|
79
|
-
return String(v);
|
|
80
|
-
}
|
|
81
|
-
return EMPTY;
|
|
82
|
-
});
|
|
83
|
-
if (!any) return null;
|
|
84
|
-
const out = raw
|
|
85
|
-
.replace(new RegExp(`\\s*${SEP}\\s*${EMPTY}`, 'g'), '')
|
|
86
|
-
.replace(new RegExp(`${EMPTY}\\s*${SEP}\\s*`, 'g'), '')
|
|
87
|
-
.replace(new RegExp(EMPTY, 'g'), '')
|
|
88
|
-
.replace(/\s+/g, ' ')
|
|
89
|
-
.trim();
|
|
90
|
-
return out || null;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Map a raw record to a LookupOption using a display field and an id field.
|
|
95
|
-
*
|
|
96
|
-
* Label precedence (ADR-0079):
|
|
97
|
-
* 1. `titleFormat` template (when supplied, derived from the referenced
|
|
98
|
-
* object's schema) — e.g. `"Acme - John Doe"`.
|
|
99
|
-
* 2. the explicit `displayField` value on the record.
|
|
100
|
-
* 3. the unified `@object-ui/core#getRecordDisplayName` against the referenced
|
|
101
|
-
* object's schema (`objectDef`) — adds `displayNameField` + type-aware
|
|
102
|
-
* field derivation, so a lookup to an object whose name lives in e.g.
|
|
103
|
-
* `activity_name` resolves a real name instead of the raw id. We stop short
|
|
104
|
-
* of the resolver's `Record #<id>` floor here so the chip still falls
|
|
105
|
-
* through to the bare id when nothing nameable exists.
|
|
106
|
-
* 4. the legacy hard-coded name list, then the raw id.
|
|
107
|
-
*/
|
|
108
|
-
function recordToOption(
|
|
109
|
-
record: any,
|
|
110
|
-
displayField: string,
|
|
111
|
-
idField: string,
|
|
112
|
-
descriptionField?: string,
|
|
113
|
-
titleFormat?: string | null,
|
|
114
|
-
objectDef?: any,
|
|
115
|
-
): LookupOption {
|
|
116
|
-
const val = record[idField] ?? record.id ?? record._id ?? record.externalId;
|
|
117
|
-
const templated = titleFormat ? formatRecordTitle(record, titleFormat) : null;
|
|
118
|
-
|
|
119
|
-
// Object-level resolver fallback (displayNameField + derivation), excluding
|
|
120
|
-
// its id floor so we don't shadow the explicit `String(val)` tail.
|
|
121
|
-
let unified: string | undefined;
|
|
122
|
-
if (objectDef) {
|
|
123
|
-
const resolved = getRecordDisplayName(objectDef, record);
|
|
124
|
-
const id = record?.id ?? record?._id;
|
|
125
|
-
const isFloor =
|
|
126
|
-
resolved === 'Untitled' ||
|
|
127
|
-
(id !== null && id !== undefined && resolved === `Record #${id}`);
|
|
128
|
-
if (!isFloor) unified = resolved;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const label =
|
|
132
|
-
templated ??
|
|
133
|
-
record[displayField] ??
|
|
134
|
-
unified ??
|
|
135
|
-
record.label ??
|
|
136
|
-
record.name ??
|
|
137
|
-
record.full_name ??
|
|
138
|
-
record.title ??
|
|
139
|
-
record.subject ??
|
|
140
|
-
record.externalId ??
|
|
141
|
-
String(val);
|
|
142
|
-
const description = descriptionField ? record[descriptionField] : undefined;
|
|
143
|
-
return { value: val, label: String(label), description, ...record };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* A reference value can arrive JSON-encoded — e.g. an unresolved external-id
|
|
148
|
-
* reference `'{"externalId":"Website Relaunch"}'`. Parse such a string into its
|
|
149
|
-
* object form so the inline editor resolves it through the same path as a
|
|
150
|
-
* server-`$expand`ed record. Returns null for anything that isn't a JSON object
|
|
151
|
-
* string. Mirrors the read cell (`LookupCellRenderer`) so the two stay aligned.
|
|
152
|
-
*/
|
|
153
|
-
function parseReferenceObjectString(v: any): Record<string, any> | null {
|
|
154
|
-
if (typeof v !== 'string') return null;
|
|
155
|
-
const s = v.trim();
|
|
156
|
-
if (!s.startsWith('{') || !s.endsWith('}')) return null;
|
|
157
|
-
try {
|
|
158
|
-
const parsed = JSON.parse(s);
|
|
159
|
-
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
160
|
-
} catch {
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Map a LookupColumnDef.type to a filter input type for the filter bar.
|
|
167
|
-
* Returns undefined if the field type is not filterable.
|
|
168
|
-
*/
|
|
169
|
-
function mapFieldTypeToFilterType(
|
|
170
|
-
fieldType: string,
|
|
171
|
-
): RecordPickerFilterColumn['type'] | undefined {
|
|
172
|
-
const mapping: Record<string, RecordPickerFilterColumn['type']> = {
|
|
173
|
-
text: 'text',
|
|
174
|
-
number: 'number',
|
|
175
|
-
currency: 'number',
|
|
176
|
-
percent: 'number',
|
|
177
|
-
select: 'select',
|
|
178
|
-
status: 'select',
|
|
179
|
-
date: 'date',
|
|
180
|
-
datetime: 'date',
|
|
181
|
-
boolean: 'boolean',
|
|
182
|
-
};
|
|
183
|
-
return mapping[fieldType];
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Lookup field for selecting related records.
|
|
188
|
-
* Supports single and multi-select with search.
|
|
189
|
-
*
|
|
190
|
-
* When a `dataSource` is provided (either via props, via `field.dataSource`,
|
|
191
|
-
* or via SchemaRendererContext), the dialog will dynamically load records
|
|
192
|
-
* from the referenced object using `DataSource.find()`.
|
|
193
|
-
* Falls back to static `options` when no DataSource is available.
|
|
194
|
-
*/
|
|
195
|
-
export function LookupField({ value, onChange, field, readonly, error: fieldError, ...props }: FieldWidgetComponentProps<any>) {
|
|
196
|
-
const [isOpen, setIsOpen] = useState(false);
|
|
197
|
-
const { t } = useFieldTranslation();
|
|
198
|
-
const listboxId = React.useId();
|
|
199
|
-
|
|
200
|
-
// Create-new error is local; the popover's fetch state (search/loading/error/
|
|
201
|
-
// total/options) is sourced from the shared useRecordQuery kernel below.
|
|
202
|
-
const [createError, setCreateError] = useState<string | null>(null);
|
|
203
|
-
|
|
204
|
-
// Records selected via RecordPickerDialog (Level 2).
|
|
205
|
-
// Stored as LookupOption so that findOption can resolve display labels
|
|
206
|
-
// even when the record wasn't part of the Level 1 popover fetch.
|
|
207
|
-
const [pickerResolvedRecords, setPickerResolvedRecords] = useState<LookupOption[]>([]);
|
|
208
|
-
|
|
209
|
-
// Ids whose hydration attempt has FINISHED — found or not — keyed by
|
|
210
|
-
// String(id). Distinguishes "still loading" from "unresolvable" (deleted
|
|
211
|
-
// record, fetch failure) so the trigger's hydrating state can't stick
|
|
212
|
-
// forever on an id that will never resolve (#3108).
|
|
213
|
-
const [hydrationSettled, setHydrationSettled] = useState<Set<string>>(() => new Set());
|
|
214
|
-
|
|
215
|
-
// Arrow-key active index (-1 = none)
|
|
216
|
-
const [activeIndex, setActiveIndex] = useState(-1);
|
|
217
|
-
const listRef = useRef<HTMLDivElement>(null);
|
|
218
|
-
|
|
219
|
-
const lookupField = field as any;
|
|
220
|
-
|
|
221
|
-
// The form renderer passes `field: field.field || field` — `.field` is the
|
|
222
|
-
// declared metadata slot (objectui#3090) — so the actual objectSchema field
|
|
223
|
-
// metadata (reference_to, display_field, etc.) can arrive nested at
|
|
224
|
-
// `lookupField.field`. Unwrap it so lookup-specific properties resolve
|
|
225
|
-
// correctly. (This used to credit the docs-demo `createFieldRenderer` wrapper,
|
|
226
|
-
// which never produced the nesting and was removed in objectui#3910; the form
|
|
227
|
-
// path is the real producer.)
|
|
228
|
-
// ObjectStack convention uses `reference` while the types use `reference_to`,
|
|
229
|
-
// so we check for both property names.
|
|
230
|
-
const innerField = lookupField?.field;
|
|
231
|
-
const fieldMeta = (innerField && typeof innerField === 'object' && ('reference_to' in innerField || 'reference' in innerField || 'type' in innerField))
|
|
232
|
-
? innerField
|
|
233
|
-
: lookupField;
|
|
234
|
-
|
|
235
|
-
const staticOptions: LookupOption[] = fieldMeta?.options || [];
|
|
236
|
-
const multiple = fieldMeta?.multiple || false;
|
|
237
|
-
const displayField = fieldMeta?.display_field || fieldMeta?.displayField || fieldMeta?.reference_field || 'name';
|
|
238
|
-
const descriptionField: string | undefined = fieldMeta?.description_field ?? fieldMeta?.descriptionField;
|
|
239
|
-
const idField = fieldMeta?.id_field || 'id';
|
|
240
|
-
// ObjectStack convention uses `reference`; types define `reference_to` — support both
|
|
241
|
-
const referenceTo: string | undefined = fieldMeta?.reference_to || fieldMeta?.reference;
|
|
242
|
-
// Inline quick-create — a STANDARD capability, default ON for user-facing
|
|
243
|
-
// relations: an empty/zero-result picker offers to create the referenced
|
|
244
|
-
// record (opening its create form; see handleCreateNew) so the first related
|
|
245
|
-
// record can be made right here. An explicit `allowCreate` (either casing)
|
|
246
|
-
// wins — set it `false` to opt a field out; system/user-directory references
|
|
247
|
-
// are excluded from the default (isUserFacingReference).
|
|
248
|
-
const explicitAllowCreate = fieldMeta?.allow_create ?? fieldMeta?.allowCreate;
|
|
249
|
-
const allowCreate: boolean =
|
|
250
|
-
explicitAllowCreate != null ? !!explicitAllowCreate : isUserFacingReference(referenceTo);
|
|
251
|
-
|
|
252
|
-
// Enterprise Record Picker configuration
|
|
253
|
-
const lookupColumns: Array<string | LookupColumnDef> | undefined = fieldMeta?.lookup_columns ?? fieldMeta?.lookupColumns;
|
|
254
|
-
const lookupPageSize: number | undefined = fieldMeta?.lookup_page_size ?? fieldMeta?.lookupPageSize;
|
|
255
|
-
const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookup_filters ?? fieldMeta?.lookupFilters;
|
|
256
|
-
|
|
257
|
-
// Search-first PeoplePicker opt-in (user fields). When `picker === 'search'`
|
|
258
|
-
// the Level-2 picker is the rich PeoplePicker (avatar rows + selection tray)
|
|
259
|
-
// instead of the classic table dialog. `subtitle`/`avatar_field` drive the rows.
|
|
260
|
-
const pickerVariant: string | undefined = fieldMeta?.picker;
|
|
261
|
-
const subtitleFields: string[] | undefined = fieldMeta?.subtitle;
|
|
262
|
-
const avatarField: string = fieldMeta?.avatar_field ?? fieldMeta?.avatarField ?? 'image';
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Dependent lookups — restrict candidates based on values of *other* fields
|
|
266
|
-
* in the same form. Two shapes are accepted:
|
|
267
|
-
*
|
|
268
|
-
* 1. `depends_on: ['country']` → shorthand. The dependent field value is sent
|
|
269
|
-
* as both the filter field and the source field (i.e. `country = ${country}`).
|
|
270
|
-
* 2. `depends_on: [{ field: 'country', param: 'country_id' }]` → explicit.
|
|
271
|
-
* The remote field name (`param`) can differ from the local field name.
|
|
272
|
-
*
|
|
273
|
-
* When any dependency is empty, the lookup is gated and the user sees a
|
|
274
|
-
* helpful "Select {field} first" hint instead of unfiltered records.
|
|
275
|
-
*/
|
|
276
|
-
const dependsOn = useMemo<Array<{ field: string; param: string }>>(() => {
|
|
277
|
-
const raw = fieldMeta?.depends_on ?? fieldMeta?.dependsOn;
|
|
278
|
-
if (!raw) return [];
|
|
279
|
-
if (Array.isArray(raw)) {
|
|
280
|
-
return raw.map((d: any) =>
|
|
281
|
-
typeof d === 'string' ? { field: d, param: d } : { field: d.field, param: d.param ?? d.field },
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
return [];
|
|
285
|
-
}, [fieldMeta?.depends_on, fieldMeta?.dependsOn]);
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* The gate sentence's `{{fields}}` — the controlling fields named the way the
|
|
289
|
-
* user sees them on the form, not the way the metadata spells them.
|
|
290
|
-
*
|
|
291
|
-
* `depends_on` holds API names, and this used to interpolate them straight
|
|
292
|
-
* into the sentence, so every locale — `en` included — read "Select
|
|
293
|
-
* crm_account first" (objectstack#5407): an internal identifier in the UI,
|
|
294
|
-
* not merely an untranslated word. The host form supplies the name→label map
|
|
295
|
-
* (`dependsOnLabels`); a name it doesn't cover falls back to itself, so a
|
|
296
|
-
* standalone widget with no host renders exactly what it did before.
|
|
297
|
-
*/
|
|
298
|
-
const dependsOnLabelsProp = props.dependsOnLabels;
|
|
299
|
-
const dependsOnFieldsText = useMemo(
|
|
300
|
-
() => dependsOn.map((d) => dependsOnLabelsProp?.[d.field] || d.field).join(', '),
|
|
301
|
-
[dependsOn, dependsOnLabelsProp],
|
|
302
|
-
);
|
|
303
|
-
|
|
304
|
-
// Resolve dependent field values from explicit prop or SchemaRendererContext.data
|
|
305
|
-
const dependentValuesProp = props.dependentValues;
|
|
306
|
-
|
|
307
|
-
// Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none
|
|
308
|
-
const ctx = useContext(SchemaRendererContext);
|
|
309
|
-
const contextDataSource = ctx?.dataSource ?? null;
|
|
310
|
-
const dataSource: DataSource | null =
|
|
311
|
-
(props.dataSource as DataSource | null | undefined) ?? lookupField?.dataSource ?? fieldMeta?.dataSource ?? contextDataSource;
|
|
312
|
-
|
|
313
|
-
/** Resolve dependent values from the explicit prop (preferred), the form-data
|
|
314
|
-
* context provided by @object-ui/react, or finally `ctx.data` (record scope). */
|
|
315
|
-
const resolvedDependentValues: Record<string, any> = useMemo(() => {
|
|
316
|
-
if (dependentValuesProp) return dependentValuesProp;
|
|
317
|
-
return (ctx?.formValues ?? ctx?.data ?? {}) as Record<string, any>;
|
|
318
|
-
}, [dependentValuesProp, ctx?.formValues, ctx?.data]);
|
|
319
|
-
|
|
320
|
-
/** True when at least one dependency is missing (empty). The picker is gated
|
|
321
|
-
* in that state so we never issue an unfiltered query that ignores the
|
|
322
|
-
* user's earlier choices. */
|
|
323
|
-
const dependenciesMissing = useMemo(() => {
|
|
324
|
-
if (dependsOn.length === 0) return false;
|
|
325
|
-
return dependsOn.some(({ field }) => {
|
|
326
|
-
const v = resolvedDependentValues[field];
|
|
327
|
-
return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
|
|
328
|
-
});
|
|
329
|
-
}, [dependsOn, resolvedDependentValues]);
|
|
330
|
-
|
|
331
|
-
const hasDataSource = dataSource != null && typeof dataSource.find === 'function' && !!referenceTo;
|
|
332
|
-
|
|
333
|
-
// Fetch the referenced object's schema so we can render option labels via
|
|
334
|
-
// its `titleFormat` template (e.g. `{full_name}`, `{case_number} - {subject}`).
|
|
335
|
-
// Without this the label fell back to a non-existent `name` field and
|
|
336
|
-
// ultimately to the raw record id.
|
|
337
|
-
const [refObjectSchema, setRefObjectSchema] = useState<any>(null);
|
|
338
|
-
useEffect(() => {
|
|
339
|
-
if (!dataSource || !referenceTo) return;
|
|
340
|
-
const getSchema = (dataSource as any).getObjectSchema;
|
|
341
|
-
if (typeof getSchema !== 'function') return;
|
|
342
|
-
let alive = true;
|
|
343
|
-
Promise.resolve(getSchema.call(dataSource, referenceTo))
|
|
344
|
-
.then((s: any) => { if (alive) setRefObjectSchema(s); })
|
|
345
|
-
.catch(() => { /* fall back to displayField chain */ });
|
|
346
|
-
return () => { alive = false; };
|
|
347
|
-
}, [dataSource, referenceTo]);
|
|
348
|
-
|
|
349
|
-
const refTitleFormat: string | null = useMemo(() => {
|
|
350
|
-
const raw = refObjectSchema?.titleFormat;
|
|
351
|
-
if (typeof raw === 'string') return raw;
|
|
352
|
-
if (raw && typeof raw === 'object' && typeof raw.source === 'string') return raw.source;
|
|
353
|
-
return null;
|
|
354
|
-
}, [refObjectSchema]);
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Picker columns. Honour explicit `lookup_columns` when authored; otherwise
|
|
358
|
-
* derive a multi-column, disambiguating set from the referenced object's
|
|
359
|
-
* schema so every lookup gets a useful picker with zero field-level config.
|
|
360
|
-
*/
|
|
361
|
-
const pickerColumns = useMemo<Array<string | LookupColumnDef> | undefined>(() => {
|
|
362
|
-
if (lookupColumns && lookupColumns.length > 0) return lookupColumns;
|
|
363
|
-
const derived = deriveLookupColumns(refObjectSchema, { displayField });
|
|
364
|
-
return derived.length > 0 ? derived : undefined;
|
|
365
|
-
}, [lookupColumns, refObjectSchema, displayField]);
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* Secondary line under each quick-select option. Honour explicit
|
|
369
|
-
* `description_field`; otherwise reuse the first derived non-display column so
|
|
370
|
-
* the inline popover also benefits from the richer schema.
|
|
371
|
-
*/
|
|
372
|
-
const effectiveDescriptionField = useMemo<string | undefined>(() => {
|
|
373
|
-
if (descriptionField) return descriptionField;
|
|
374
|
-
const extra = pickerColumns?.find((c) => (typeof c === 'string' ? c : c.field) !== displayField);
|
|
375
|
-
if (!extra) return undefined;
|
|
376
|
-
return typeof extra === 'string' ? extra : extra.field;
|
|
377
|
-
}, [descriptionField, pickerColumns, displayField]);
|
|
378
|
-
|
|
379
|
-
// Derive filter-bar columns from any typed picker columns.
|
|
380
|
-
const filterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
|
|
381
|
-
if (!pickerColumns) return undefined;
|
|
382
|
-
const cols: RecordPickerFilterColumn[] = [];
|
|
383
|
-
for (const c of pickerColumns) {
|
|
384
|
-
if (typeof c === 'object' && c.type) {
|
|
385
|
-
const filterType = mapFieldTypeToFilterType(c.type);
|
|
386
|
-
if (filterType) cols.push({ field: c.field, label: c.label, type: filterType });
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
return cols.length > 0 ? cols : undefined;
|
|
390
|
-
}, [pickerColumns]);
|
|
391
|
-
|
|
392
|
-
// Optional create-new callback
|
|
393
|
-
const onCreateNew: ((searchQuery: string) => void) | undefined =
|
|
394
|
-
props.onCreateNew ?? lookupField?.onCreateNew;
|
|
395
|
-
|
|
396
|
-
// State for the full Record Picker dialog (Level 2)
|
|
397
|
-
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
|
398
|
-
|
|
399
|
-
// Dependent-lookup chain as a hard QueryParams.$filter record. Shared by
|
|
400
|
-
// EVERY candidate surface — quick-select popover, Level-2 table picker and
|
|
401
|
-
// the search-first PeoplePicker — so no picker can bypass the cascade
|
|
402
|
-
// (#2215: the table picker used to list the full unfiltered set).
|
|
403
|
-
const dependentFilter = useMemo<Record<string, any> | undefined>(() => {
|
|
404
|
-
const f: Record<string, any> = {};
|
|
405
|
-
for (const { field, param } of dependsOn) {
|
|
406
|
-
const v = resolvedDependentValues[field];
|
|
407
|
-
if (v === undefined || v === null || v === '') continue;
|
|
408
|
-
f[param] = typeof v === 'number' ? v : String(v);
|
|
409
|
-
}
|
|
410
|
-
return Object.keys(f).length > 0 ? f : undefined;
|
|
411
|
-
}, [dependsOn, resolvedDependentValues]);
|
|
412
|
-
|
|
413
|
-
// Determine which options to display
|
|
414
|
-
// Quick-select popover fetch — the shared record-query kernel (same one the
|
|
415
|
-
// Record Picker dialog and PeoplePicker use). Filter = dependent-lookup chain
|
|
416
|
-
// + base lookupFilters, so the popover matches the full picker.
|
|
417
|
-
const popoverFilter = useMemo<Record<string, any> | undefined>(() => {
|
|
418
|
-
const f: Record<string, any> = {
|
|
419
|
-
...(lookupFilters && lookupFilters.length > 0 ? lookupFiltersToRecord(lookupFilters) : {}),
|
|
420
|
-
...(dependentFilter ?? {}),
|
|
421
|
-
};
|
|
422
|
-
return Object.keys(f).length > 0 ? f : undefined;
|
|
423
|
-
}, [dependentFilter, lookupFilters]);
|
|
424
|
-
|
|
425
|
-
const popoverQuery = useRecordQuery({
|
|
426
|
-
dataSource,
|
|
427
|
-
objectName: referenceTo,
|
|
428
|
-
enabled: isOpen && hasDataSource && !dependenciesMissing,
|
|
429
|
-
pageSize: LOOKUP_PAGE_SIZE,
|
|
430
|
-
filter: popoverFilter,
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
// Re-source the popover's fetch state from the kernel; all existing read sites
|
|
434
|
-
// (searchQuery / loading / error / totalCount / fetchedOptions) stay unchanged.
|
|
435
|
-
const searchQuery = popoverQuery.search;
|
|
436
|
-
const loading = popoverQuery.loading;
|
|
437
|
-
const totalCount = popoverQuery.total;
|
|
438
|
-
const error = popoverQuery.error ?? createError;
|
|
439
|
-
const fetchedOptions = useMemo(
|
|
440
|
-
() =>
|
|
441
|
-
popoverQuery.records.map(r =>
|
|
442
|
-
recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema),
|
|
443
|
-
),
|
|
444
|
-
[popoverQuery.records, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema],
|
|
445
|
-
);
|
|
446
|
-
|
|
447
|
-
const allOptions = hasDataSource ? fetchedOptions : staticOptions;
|
|
448
|
-
|
|
449
|
-
// For static options, filter locally based on search
|
|
450
|
-
const filteredOptions = useMemo(() => {
|
|
451
|
-
if (hasDataSource) return allOptions;
|
|
452
|
-
if (!searchQuery) return allOptions;
|
|
453
|
-
const q = searchQuery.toLowerCase();
|
|
454
|
-
return allOptions.filter(opt =>
|
|
455
|
-
opt.label.toLowerCase().includes(q) ||
|
|
456
|
-
(opt.description && opt.description.toLowerCase().includes(q))
|
|
457
|
-
);
|
|
458
|
-
}, [hasDataSource, allOptions, searchQuery]);
|
|
459
|
-
|
|
460
|
-
// Reset active index when options change
|
|
461
|
-
useEffect(() => {
|
|
462
|
-
setActiveIndex(-1);
|
|
463
|
-
}, [filteredOptions.length]);
|
|
464
|
-
|
|
465
|
-
// Reset the keyboard cursor when the popover closes. Fetch state (records,
|
|
466
|
-
// search, error, total) is owned by `popoverQuery` and resets automatically
|
|
467
|
-
// when it becomes disabled (via `enabled`), including its debounced search.
|
|
468
|
-
useEffect(() => {
|
|
469
|
-
if (!isOpen) setActiveIndex(-1);
|
|
470
|
-
}, [isOpen]);
|
|
471
|
-
|
|
472
|
-
// Search is the kernel's debounced setter.
|
|
473
|
-
const handleSearchChange = useCallback(
|
|
474
|
-
(query: string) => popoverQuery.setSearch(query),
|
|
475
|
-
[popoverQuery.setSearch],
|
|
476
|
-
);
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Hydrate the picker's display when the field already has a value (e.g.
|
|
480
|
-
* edit-mode load, prefill via query-string from a related-list "+ New")
|
|
481
|
-
* but no option resolves it yet. Fetches the referenced record(s) via
|
|
482
|
-
* the DataSource and caches them in `pickerResolvedRecords` so the chip
|
|
483
|
-
* shows a friendly label instead of an empty placeholder.
|
|
484
|
-
*/
|
|
485
|
-
useEffect(() => {
|
|
486
|
-
if (!hasDataSource || !dataSource || !referenceTo) return;
|
|
487
|
-
const raw: any[] = multiple
|
|
488
|
-
? Array.isArray(value) ? value : []
|
|
489
|
-
: value != null && value !== '' ? [value] : [];
|
|
490
|
-
// Expanded-reference values (server `$expand`, or their JSON-encoded string
|
|
491
|
-
// form) already carry their display fields and resolve directly in
|
|
492
|
-
// `resolveSelectedOption` — only bare ids need a fetch. Passing an object (or
|
|
493
|
-
// a JSON string) to `findOne` would query for a bogus id and leave the
|
|
494
|
-
// trigger stuck on the placeholder.
|
|
495
|
-
const ids = raw.filter(
|
|
496
|
-
(v) => v != null && v !== '' && typeof v !== 'object' && !parseReferenceObjectString(v),
|
|
497
|
-
);
|
|
498
|
-
if (!ids.length) return;
|
|
499
|
-
// Only fetch records we haven't resolved yet.
|
|
500
|
-
const unresolved = ids.filter((v) => !findOption(v));
|
|
501
|
-
if (!unresolved.length) return;
|
|
502
|
-
|
|
503
|
-
let cancelled = false;
|
|
504
|
-
(async () => {
|
|
505
|
-
try {
|
|
506
|
-
const fetched: LookupOption[] = [];
|
|
507
|
-
// Single id: the pre-existing cheap paths — a primary-id `findOne`
|
|
508
|
-
// GET, or an equality filter when the field commits a different
|
|
509
|
-
// column (`id_field: 'name'` — e.g. position machine names,
|
|
510
|
-
// objectstack #3508).
|
|
511
|
-
if (unresolved.length === 1) {
|
|
512
|
-
const id = unresolved[0];
|
|
513
|
-
if (typeof (dataSource as any).findOne === 'function' && idField === 'id') {
|
|
514
|
-
const rec = await (dataSource as any).findOne(referenceTo, id);
|
|
515
|
-
if (rec) fetched.push(recordToOption(rec, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
|
|
516
|
-
} else {
|
|
517
|
-
const res = await dataSource.find(referenceTo, {
|
|
518
|
-
$filter: { [idField]: id },
|
|
519
|
-
$top: 1,
|
|
520
|
-
} as QueryParams);
|
|
521
|
-
const rows = (res as any)?.data ?? res ?? [];
|
|
522
|
-
if (rows[0]) fetched.push(recordToOption(rows[0], displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
|
|
523
|
-
}
|
|
524
|
-
} else {
|
|
525
|
-
// SEVERAL unresolved ids: one `$in` query per chunk. A multi-value
|
|
526
|
-
// field can hold dozens of ids, and the old serial findOne-per-id
|
|
527
|
-
// took seconds while the trigger sat on the empty "Select…"
|
|
528
|
-
// placeholder (#3108).
|
|
529
|
-
// Chunked to LOOKUP_PAGE_SIZE so no request exceeds the page size
|
|
530
|
-
// a server may cap `$top` at; chunks run in parallel.
|
|
531
|
-
const chunks: any[][] = [];
|
|
532
|
-
for (let i = 0; i < unresolved.length; i += LOOKUP_PAGE_SIZE) {
|
|
533
|
-
chunks.push(unresolved.slice(i, i + LOOKUP_PAGE_SIZE));
|
|
534
|
-
}
|
|
535
|
-
const results = await Promise.all(
|
|
536
|
-
chunks.map((chunk) =>
|
|
537
|
-
dataSource.find(referenceTo, {
|
|
538
|
-
$filter: { [idField]: { $in: chunk } },
|
|
539
|
-
$top: chunk.length,
|
|
540
|
-
} as QueryParams),
|
|
541
|
-
),
|
|
542
|
-
);
|
|
543
|
-
for (const res of results) {
|
|
544
|
-
const rows = (res as any)?.data ?? res ?? [];
|
|
545
|
-
if (!Array.isArray(rows)) continue;
|
|
546
|
-
for (const row of rows) {
|
|
547
|
-
fetched.push(recordToOption(row, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
if (!cancelled && fetched.length) {
|
|
552
|
-
setPickerResolvedRecords((prev) => {
|
|
553
|
-
const map = new Map(prev.map((o) => [o.value, o]));
|
|
554
|
-
for (const o of fetched) map.set(o.value, o);
|
|
555
|
-
return Array.from(map.values());
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
} catch {
|
|
559
|
-
// Ignore — chip will fall back to showing the raw id.
|
|
560
|
-
} finally {
|
|
561
|
-
// Mark the attempt settled — found or not — so `hydrating` below
|
|
562
|
-
// clears even for ids that no longer resolve (deleted records,
|
|
563
|
-
// fetch failures) instead of spinning forever.
|
|
564
|
-
if (!cancelled) {
|
|
565
|
-
setHydrationSettled((prev) => {
|
|
566
|
-
const next = new Set(prev);
|
|
567
|
-
for (const id of unresolved) next.add(String(id));
|
|
568
|
-
return next;
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
})();
|
|
573
|
-
return () => {
|
|
574
|
-
cancelled = true;
|
|
575
|
-
};
|
|
576
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
577
|
-
}, [value, hasDataSource, referenceTo, displayField, idField, effectiveDescriptionField, multiple]);
|
|
578
|
-
|
|
579
|
-
// Get selected option(s) — check static, fetched, and picker-resolved options
|
|
580
|
-
const findOption = useCallback(
|
|
581
|
-
(v: any): LookupOption | undefined => {
|
|
582
|
-
return (
|
|
583
|
-
staticOptions.find(opt => opt.value === v) ??
|
|
584
|
-
fetchedOptions.find(opt => opt.value === v) ??
|
|
585
|
-
pickerResolvedRecords.find(opt => opt.value === v)
|
|
586
|
-
);
|
|
587
|
-
},
|
|
588
|
-
[staticOptions, fetchedOptions, pickerResolvedRecords],
|
|
589
|
-
);
|
|
590
|
-
|
|
591
|
-
// String-coerced fallback for `findOption` — matches the read cell's tolerant
|
|
592
|
-
// `String(a) === String(b)` comparison so a numeric cell value still resolves
|
|
593
|
-
// against a string-keyed option (and vice versa). Only consulted when the
|
|
594
|
-
// strict match misses, so homogeneous option lists are unaffected.
|
|
595
|
-
const findOptionLoose = useCallback(
|
|
596
|
-
(v: any): LookupOption | undefined => {
|
|
597
|
-
const key = String(v);
|
|
598
|
-
return (
|
|
599
|
-
staticOptions.find(opt => String(opt.value) === key) ??
|
|
600
|
-
fetchedOptions.find(opt => String(opt.value) === key) ??
|
|
601
|
-
pickerResolvedRecords.find(opt => String(opt.value) === key)
|
|
602
|
-
);
|
|
603
|
-
},
|
|
604
|
-
[staticOptions, fetchedOptions, pickerResolvedRecords],
|
|
605
|
-
);
|
|
606
|
-
|
|
607
|
-
// Collapse an expanded-reference value (the related record object returned by
|
|
608
|
-
// server `$expand`) to its bare id — used for option matching / highlighting.
|
|
609
|
-
const normalizeId = useCallback(
|
|
610
|
-
(raw: any): any => {
|
|
611
|
-
const obj = raw != null && typeof raw === 'object' ? raw : parseReferenceObjectString(raw);
|
|
612
|
-
return obj ? (obj[idField] ?? obj.id ?? obj._id ?? obj.externalId) : raw;
|
|
613
|
-
},
|
|
614
|
-
[idField],
|
|
615
|
-
);
|
|
616
|
-
|
|
617
|
-
// Level-2 pickers (PeoplePicker / RecordPickerDialog) operate on bare ids —
|
|
618
|
-
// their seed queries and selected-row matching compare against `idField`
|
|
619
|
-
// values. Collapse any `$expand`-ed record objects (passed through
|
|
620
|
-
// uncollapsed by the inline editor, objectui#2572) before handing the value
|
|
621
|
-
// down, or the seed fetch would query for a bogus object-shaped id.
|
|
622
|
-
const pickerValue = useMemo(
|
|
623
|
-
() => (multiple ? (Array.isArray(value) ? value.map(normalizeId) : []) : normalizeId(value)),
|
|
624
|
-
[multiple, value, normalizeId],
|
|
625
|
-
);
|
|
626
|
-
|
|
627
|
-
// Resolve a raw field value into its display option. An expanded-reference
|
|
628
|
-
// object is mapped directly (mirroring the read cell's display-name path) so
|
|
629
|
-
// the inline editor shows the record's name instead of the placeholder; a bare
|
|
630
|
-
// id resolves through the static / fetched / picker-hydrated option lists.
|
|
631
|
-
const resolveSelectedOption = useCallback(
|
|
632
|
-
(raw: any): LookupOption | undefined => {
|
|
633
|
-
if (raw == null || raw === '') return undefined;
|
|
634
|
-
// An expanded-reference object (server `$expand`) — or its JSON-encoded
|
|
635
|
-
// string form, e.g. an external-id reference `'{"externalId":"…"}'` — is
|
|
636
|
-
// mapped directly, mirroring the read cell (`LookupCellRenderer`).
|
|
637
|
-
const asObject = typeof raw === 'object' ? raw : parseReferenceObjectString(raw);
|
|
638
|
-
if (asObject) {
|
|
639
|
-
return recordToOption(asObject, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema);
|
|
640
|
-
}
|
|
641
|
-
// Bare id: strict match first, then a String()-coerced fallback so a
|
|
642
|
-
// numeric cell value still resolves against a string-keyed option (and
|
|
643
|
-
// vice versa) — matching the read cell's tolerant comparison.
|
|
644
|
-
return findOption(raw) ?? findOptionLoose(raw);
|
|
645
|
-
},
|
|
646
|
-
[findOption, findOptionLoose, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema],
|
|
647
|
-
);
|
|
648
|
-
|
|
649
|
-
// A value can hold bare ids that no option list resolves YET — the batch
|
|
650
|
-
// hydration above is still in flight. While any such id is pending, the
|
|
651
|
-
// trigger must not present the field as empty: a 41-value lookup rendered
|
|
652
|
-
// the bare "Select…" placeholder for seconds, indistinguishable from
|
|
653
|
-
// having no value at all (#3108).
|
|
654
|
-
const hydrating = useMemo(() => {
|
|
655
|
-
if (!hasDataSource) return false;
|
|
656
|
-
const raw: any[] = multiple
|
|
657
|
-
? Array.isArray(value) ? value : []
|
|
658
|
-
: value != null && value !== '' ? [value] : [];
|
|
659
|
-
return raw.some(
|
|
660
|
-
(v) =>
|
|
661
|
-
v != null && v !== '' && typeof v !== 'object' && !parseReferenceObjectString(v) &&
|
|
662
|
-
!findOption(v) && !findOptionLoose(v) &&
|
|
663
|
-
!hydrationSettled.has(String(v)),
|
|
664
|
-
);
|
|
665
|
-
}, [hasDataSource, multiple, value, findOption, findOptionLoose, hydrationSettled]);
|
|
666
|
-
|
|
667
|
-
// Committed-value count. During hydration the resolved-option count
|
|
668
|
-
// undercounts (unresolved ids are filtered out below), so the trigger
|
|
669
|
-
// shows this raw count instead.
|
|
670
|
-
const rawSelectedCount = multiple
|
|
671
|
-
? (Array.isArray(value) ? value : []).filter((v) => v != null && v !== '').length
|
|
672
|
-
: value != null && value !== '' ? 1 : 0;
|
|
673
|
-
|
|
674
|
-
const selectedOptions = multiple
|
|
675
|
-
? (Array.isArray(value) ? value : []).map(resolveSelectedOption).filter(Boolean)
|
|
676
|
-
: value ? [resolveSelectedOption(value)].filter(Boolean) : [];
|
|
677
|
-
|
|
678
|
-
// Optional: receive the FULL selected record (not just its id) so a host can
|
|
679
|
-
// auto-fill sibling fields from it — e.g. a line-item grid copying a product's
|
|
680
|
-
// unit_price/description when the item is chosen. When provided (single
|
|
681
|
-
// select), it drives the update and the host owns the resulting value change.
|
|
682
|
-
const onSelectRecord = props.onSelectRecord;
|
|
683
|
-
|
|
684
|
-
const handleSelect = useCallback(
|
|
685
|
-
(option: LookupOption) => {
|
|
686
|
-
// Cache the picked option so its label resolves synchronously and durably,
|
|
687
|
-
// independent of the popover's `fetchedOptions` (which the editor may have
|
|
688
|
-
// remounted away, or which a slow/contended re-render hasn't surfaced yet —
|
|
689
|
-
// the intermittent CI failure where a just-picked lookup showed no label,
|
|
690
|
-
// #2150). `selectedOptions` consults `pickerResolvedRecords` in `findOption`.
|
|
691
|
-
if (option && option.value != null) {
|
|
692
|
-
setPickerResolvedRecords((prev) => {
|
|
693
|
-
const map = new Map(prev.map((o) => [o.value, o]));
|
|
694
|
-
map.set(option.value, option);
|
|
695
|
-
return Array.from(map.values());
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
if (multiple) {
|
|
699
|
-
// Normalise any expanded-reference objects to bare ids so toggling
|
|
700
|
-
// compares like-for-like and always persists ids (never mixed shapes).
|
|
701
|
-
const currentValues = (Array.isArray(value) ? value : []).map(normalizeId);
|
|
702
|
-
const isSelected = currentValues.includes(option.value);
|
|
703
|
-
|
|
704
|
-
if (isSelected) {
|
|
705
|
-
onChange(currentValues.filter((v: any) => v !== option.value));
|
|
706
|
-
} else {
|
|
707
|
-
if (referenceTo) pushRecentLookupId(referenceTo, option.value);
|
|
708
|
-
onChange([...currentValues, option.value]);
|
|
709
|
-
}
|
|
710
|
-
} else {
|
|
711
|
-
if (referenceTo) pushRecentLookupId(referenceTo, option.value);
|
|
712
|
-
if (onSelectRecord) onSelectRecord(option);
|
|
713
|
-
else onChange(option.value);
|
|
714
|
-
setIsOpen(false);
|
|
715
|
-
}
|
|
716
|
-
},
|
|
717
|
-
[multiple, value, onChange, onSelectRecord, referenceTo, normalizeId],
|
|
718
|
-
);
|
|
719
|
-
|
|
720
|
-
const handleRemove = (optionValue: any) => {
|
|
721
|
-
if (multiple) {
|
|
722
|
-
const currentValues = (Array.isArray(value) ? value : []).map(normalizeId);
|
|
723
|
-
onChange(currentValues.filter((v: any) => v !== optionValue));
|
|
724
|
-
} else {
|
|
725
|
-
onChange(null);
|
|
726
|
-
}
|
|
727
|
-
};
|
|
728
|
-
|
|
729
|
-
// Callback from RecordPickerDialog — caches selected records so that
|
|
730
|
-
// findOption can resolve display labels after the dialog closes.
|
|
731
|
-
const handlePickerSelectRecords = useCallback(
|
|
732
|
-
(records: any[]) => {
|
|
733
|
-
const mapped = records.map(r => recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
|
|
734
|
-
if (referenceTo) mapped.forEach((o) => pushRecentLookupId(referenceTo, o.value));
|
|
735
|
-
setPickerResolvedRecords(mapped);
|
|
736
|
-
},
|
|
737
|
-
[displayField, idField, effectiveDescriptionField, refTitleFormat, referenceTo],
|
|
738
|
-
);
|
|
739
|
-
|
|
740
|
-
// ── Recently-used, quick-create, combined option list ────────────────────
|
|
741
|
-
const [recentOptions, setRecentOptions] = useState<LookupOption[]>([]);
|
|
742
|
-
useEffect(() => {
|
|
743
|
-
if (!isOpen || !hasDataSource || !dataSource || !referenceTo || searchQuery) return;
|
|
744
|
-
const ids = getRecentLookupIds(referenceTo);
|
|
745
|
-
if (!ids.length) { setRecentOptions([]); return; }
|
|
746
|
-
let cancelled = false;
|
|
747
|
-
(async () => {
|
|
748
|
-
try {
|
|
749
|
-
const recs: LookupOption[] = [];
|
|
750
|
-
for (const id of ids) {
|
|
751
|
-
const cached = findOption(id);
|
|
752
|
-
if (cached) { recs.push(cached); continue; }
|
|
753
|
-
if (typeof (dataSource as any).findOne === 'function') {
|
|
754
|
-
const r = await (dataSource as any).findOne(referenceTo, id);
|
|
755
|
-
if (r) recs.push(recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
if (!cancelled) setRecentOptions(recs);
|
|
759
|
-
} catch { if (!cancelled) setRecentOptions([]); }
|
|
760
|
-
})();
|
|
761
|
-
return () => { cancelled = true; };
|
|
762
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
763
|
-
}, [isOpen, hasDataSource, referenceTo, searchQuery]);
|
|
764
|
-
|
|
765
|
-
// Recently-used first (only before the user types), then live results — one
|
|
766
|
-
// de-duped list that drives BOTH rendering and arrow-key navigation.
|
|
767
|
-
const recentCount = (!searchQuery && hasDataSource) ? recentOptions.length : 0;
|
|
768
|
-
const visibleOptions = useMemo(() => {
|
|
769
|
-
if (searchQuery || !hasDataSource || recentOptions.length === 0) return filteredOptions;
|
|
770
|
-
const recentIds = new Set(recentOptions.map((o) => o.value));
|
|
771
|
-
return [...recentOptions, ...filteredOptions.filter((o) => !recentIds.has(o.value))];
|
|
772
|
-
}, [searchQuery, hasDataSource, recentOptions, filteredOptions]);
|
|
773
|
-
useEffect(() => { setActiveIndex(-1); }, [visibleOptions.length]);
|
|
774
|
-
|
|
775
|
-
const [creating, setCreating] = useState(false);
|
|
776
|
-
// Open the referenced object's create form through the ActionProvider's modal
|
|
777
|
-
// handler (when a form host wired one). Safe outside a provider — useAction
|
|
778
|
-
// returns a local runner and useHasActionProvider is false.
|
|
779
|
-
const { execute } = useAction();
|
|
780
|
-
const hasActionProvider = useHasActionProvider();
|
|
781
|
-
const canCreate = !!onCreateNew || (allowCreate && (hasActionProvider || hasDataSource));
|
|
782
|
-
const handleCreateNew = useCallback(
|
|
783
|
-
async (q: string) => {
|
|
784
|
-
const label = (q || '').trim();
|
|
785
|
-
if (onCreateNew) { onCreateNew(label); setIsOpen(false); return; }
|
|
786
|
-
if (!allowCreate || !referenceTo) return;
|
|
787
|
-
setCreateError(null);
|
|
788
|
-
|
|
789
|
-
// Preferred path — open the referenced object's FULL create form so the
|
|
790
|
-
// user fills every required field themselves, then select the record they
|
|
791
|
-
// created. This is the standard "create related record" behaviour: it
|
|
792
|
-
// works for ANY object (not only ones whose sole required field is the
|
|
793
|
-
// title) and turns an empty required picker from a dead end into a way to
|
|
794
|
-
// author the first related record.
|
|
795
|
-
if (hasActionProvider) {
|
|
796
|
-
setIsOpen(false); // close the picker popover first
|
|
797
|
-
// Defer opening the create dialog until the popover has fully closed and
|
|
798
|
-
// returned focus to the field trigger. Two nested-modal bugs come from
|
|
799
|
-
// opening it in the same tick as the triggering click:
|
|
800
|
-
// 1) the just-mounted Dialog treats this click's release (and the
|
|
801
|
-
// popover's own dismiss) as an outside-interaction and flashes shut
|
|
802
|
-
// on the FIRST click (works on the second);
|
|
803
|
-
// 2) with the popover already gone, the "+ create" button that Radix
|
|
804
|
-
// would return focus to is unmounted, so when the nested modal later
|
|
805
|
-
// closes focus leaks to <body> and dismisses the PARENT form's dialog
|
|
806
|
-
// too ("Cancel closes both").
|
|
807
|
-
// A macrotask runs after Radix's focus-return layout effects, so focus is
|
|
808
|
-
// safely back on the still-mounted field trigger before the Dialog opens.
|
|
809
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
810
|
-
const result: any = await execute({
|
|
811
|
-
type: 'modal',
|
|
812
|
-
modal: { objectName: referenceTo, mode: 'create' },
|
|
813
|
-
} as any);
|
|
814
|
-
if (result?.success && result.data) {
|
|
815
|
-
const opt = recordToOption(result.data, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema);
|
|
816
|
-
setPickerResolvedRecords((prev) => [opt, ...prev.filter((o) => o.value !== opt.value)]);
|
|
817
|
-
handleSelect(opt);
|
|
818
|
-
return;
|
|
819
|
-
}
|
|
820
|
-
// `success` + an echoed `modal` schema means no modal handler was wired
|
|
821
|
-
// in this tree → fall through to the legacy inline create. Anything else
|
|
822
|
-
// (cancel / no data) means the user backed out → stop.
|
|
823
|
-
if (!(result?.success && result.modal)) return;
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
// Fallback (no modal handler in scope): the legacy one-field inline create
|
|
827
|
-
// from the typed text. Best-effort; surfaces any validation error inline.
|
|
828
|
-
if (!dataSource || !label) return;
|
|
829
|
-
setCreating(true);
|
|
830
|
-
try {
|
|
831
|
-
const created = await (dataSource as any).create(referenceTo, { [displayField]: label });
|
|
832
|
-
const opt = recordToOption(created, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema);
|
|
833
|
-
setPickerResolvedRecords((prev) => [opt, ...prev.filter((o) => o.value !== opt.value)]);
|
|
834
|
-
handleSelect(opt);
|
|
835
|
-
} catch (err) {
|
|
836
|
-
setCreateError(err instanceof Error ? err.message : String(err));
|
|
837
|
-
} finally {
|
|
838
|
-
setCreating(false);
|
|
839
|
-
}
|
|
840
|
-
},
|
|
841
|
-
[onCreateNew, allowCreate, referenceTo, hasActionProvider, execute, dataSource, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema, handleSelect],
|
|
842
|
-
);
|
|
843
|
-
|
|
844
|
-
// Compact one-line preview of an option's extra (non-display) columns —
|
|
845
|
-
// shown as a native tooltip so users can disambiguate without opening it.
|
|
846
|
-
const previewOf = useCallback(
|
|
847
|
-
(option: LookupOption): string | undefined => {
|
|
848
|
-
if (!pickerColumns || pickerColumns.length === 0) return undefined;
|
|
849
|
-
const parts: string[] = [];
|
|
850
|
-
for (const c of pickerColumns) {
|
|
851
|
-
const f = typeof c === 'string' ? c : c.field;
|
|
852
|
-
if (f === displayField) continue;
|
|
853
|
-
const v = (option as any)[f];
|
|
854
|
-
if (v === null || v === undefined || v === '') continue;
|
|
855
|
-
const lbl = typeof c === 'string' ? f : (c.label || f);
|
|
856
|
-
const text = typeof v === 'object' ? (v.name ?? v.label ?? JSON.stringify(v)) : v;
|
|
857
|
-
parts.push(`${lbl}: ${text}`);
|
|
858
|
-
}
|
|
859
|
-
return parts.length ? parts.join(' · ') : undefined;
|
|
860
|
-
},
|
|
861
|
-
[pickerColumns, displayField],
|
|
862
|
-
);
|
|
863
|
-
|
|
864
|
-
// Keyboard handler for the search input — arrow keys + Enter
|
|
865
|
-
const handleSearchKeyDown = useCallback(
|
|
866
|
-
(e: React.KeyboardEvent) => {
|
|
867
|
-
if (e.key === 'ArrowDown') {
|
|
868
|
-
e.preventDefault();
|
|
869
|
-
setActiveIndex(prev =>
|
|
870
|
-
prev < visibleOptions.length - 1 ? prev + 1 : prev,
|
|
871
|
-
);
|
|
872
|
-
} else if (e.key === 'ArrowUp') {
|
|
873
|
-
e.preventDefault();
|
|
874
|
-
setActiveIndex(prev => (prev > 0 ? prev - 1 : 0));
|
|
875
|
-
} else if (e.key === 'Enter') {
|
|
876
|
-
e.preventDefault();
|
|
877
|
-
if (activeIndex >= 0 && activeIndex < visibleOptions.length) {
|
|
878
|
-
handleSelect(visibleOptions[activeIndex]);
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
},
|
|
882
|
-
[visibleOptions, activeIndex, handleSelect],
|
|
883
|
-
);
|
|
884
|
-
|
|
885
|
-
// Scroll active item into view
|
|
886
|
-
useEffect(() => {
|
|
887
|
-
if (activeIndex >= 0 && listRef.current) {
|
|
888
|
-
const el = listRef.current.querySelector(`[data-lookup-index="${activeIndex}"]`);
|
|
889
|
-
el?.scrollIntoView({ block: 'nearest' });
|
|
890
|
-
}
|
|
891
|
-
}, [activeIndex]);
|
|
892
|
-
|
|
893
|
-
if (readonly) {
|
|
894
|
-
if (!selectedOptions.length) {
|
|
895
|
-
if (hydrating) {
|
|
896
|
-
return (
|
|
897
|
-
<span
|
|
898
|
-
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground"
|
|
899
|
-
data-testid="lookup-hydrating"
|
|
900
|
-
>
|
|
901
|
-
<Loader2 className="size-3.5 animate-spin" />
|
|
902
|
-
{multiple ? t('table.selected', { count: rawSelectedCount }) : t('lookup.loading')}
|
|
903
|
-
</span>
|
|
904
|
-
);
|
|
905
|
-
}
|
|
906
|
-
return <EmptyValue />;
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
if (multiple) {
|
|
910
|
-
return (
|
|
911
|
-
<div className="flex flex-wrap gap-1">
|
|
912
|
-
{selectedOptions.map((opt, idx) => (
|
|
913
|
-
<Badge key={idx} variant="outline">
|
|
914
|
-
{opt?.label || opt?.[displayField]}
|
|
915
|
-
</Badge>
|
|
916
|
-
))}
|
|
917
|
-
</div>
|
|
918
|
-
);
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
return (
|
|
922
|
-
<span className="text-sm">
|
|
923
|
-
{selectedOptions[0]?.label || selectedOptions[0]?.[displayField]}
|
|
924
|
-
</span>
|
|
925
|
-
);
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
// Compact mode (e.g. inside a line-item grid cell): show the selected value
|
|
929
|
-
// INSIDE a borderless trigger on a single line — no chip stacked above a
|
|
930
|
-
// separate "Select…" button (which double-stacks and wastes the row height).
|
|
931
|
-
const compact = !!props.compact;
|
|
932
|
-
const singleSelectedLabel = selectedOptions[0]?.label || selectedOptions[0]?.[displayField];
|
|
933
|
-
|
|
934
|
-
// Shared field trigger — the anchor for either the inline PeoplePicker
|
|
935
|
-
// (search fields) or the classic quick-select popover. No onClick: the Radix
|
|
936
|
-
// trigger it is slotted into (PopoverTrigger / SheetTrigger) owns open/close.
|
|
937
|
-
//
|
|
938
|
-
// DOM pass-through onto this button — the widget's real focusable control —
|
|
939
|
-
// per objectui#3318. `name` is withheld: no native control here takes part
|
|
940
|
-
// in form submission, and a stray `name` on a button invites exactly the
|
|
941
|
-
// accidental-submitter semantics #3306 kept it off the SelectTrigger for.
|
|
942
|
-
const { name: _domName, ...triggerDomProps } = toDomProps(props);
|
|
943
|
-
const triggerButton = (
|
|
944
|
-
<Button
|
|
945
|
-
{...triggerDomProps}
|
|
946
|
-
variant="outline"
|
|
947
|
-
className={cn(
|
|
948
|
-
// max-w-full: in a BLOCK parent (detail-section inline edit) the
|
|
949
|
-
// inline-flex button is content-sized — a long selected label pushed
|
|
950
|
-
// it past the card edge; bounding it lets the inner `truncate` span
|
|
951
|
-
// clip instead (objectui#3466). flex-1/min-w-0 keep handling the
|
|
952
|
-
// flex-parent (form row) case.
|
|
953
|
-
'min-w-0 max-w-full flex-1 justify-start text-left font-normal',
|
|
954
|
-
compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
|
|
955
|
-
)}
|
|
956
|
-
type="button"
|
|
957
|
-
disabled={dependenciesMissing || props.disabled}
|
|
958
|
-
data-testid={dependenciesMissing ? 'lookup-trigger-gated' : ((props.name || lookupField?.name) ? `lookup-trigger-${props.name || lookupField.name}` : 'lookup-trigger')}
|
|
959
|
-
title={dependenciesMissing
|
|
960
|
-
? t('lookup.selectFirst', { fields: dependsOnFieldsText })
|
|
961
|
-
: undefined}
|
|
962
|
-
// AFTER the spread so this widget's own computation wins (#3222):
|
|
963
|
-
// `fieldError` is the published validation slot — NOT the popover's
|
|
964
|
-
// fetch error, which is a widget-internal state named `error` below.
|
|
965
|
-
aria-invalid={!!fieldError}
|
|
966
|
-
>
|
|
967
|
-
{hydrating ? (
|
|
968
|
-
<Loader2
|
|
969
|
-
className={cn('size-4 shrink-0 animate-spin text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')}
|
|
970
|
-
data-testid="lookup-hydrating"
|
|
971
|
-
/>
|
|
972
|
-
) : (
|
|
973
|
-
<Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
|
|
974
|
-
)}
|
|
975
|
-
<span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
|
|
976
|
-
{dependenciesMissing
|
|
977
|
-
? t('lookup.selectFirst', { fields: dependsOnFieldsText })
|
|
978
|
-
: hydrating
|
|
979
|
-
// The value EXISTS but its labels are still loading — say so
|
|
980
|
-
// instead of the empty placeholder (#3108).
|
|
981
|
-
? multiple
|
|
982
|
-
? t('table.selected', { count: rawSelectedCount })
|
|
983
|
-
: t('lookup.loading')
|
|
984
|
-
: compact && !multiple && selectedOptions.length > 0
|
|
985
|
-
? singleSelectedLabel
|
|
986
|
-
: selectedOptions.length === 0
|
|
987
|
-
? lookupField?.placeholder || t('common.select')
|
|
988
|
-
: multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')}
|
|
989
|
-
</span>
|
|
990
|
-
</Button>
|
|
991
|
-
);
|
|
992
|
-
|
|
993
|
-
return (
|
|
994
|
-
<div className={compact ? '' : 'space-y-2'}>
|
|
995
|
-
{/* Selected values display (full mode only — compact shows it in-trigger) */}
|
|
996
|
-
{selectedOptions.length > 0 && !compact && (
|
|
997
|
-
<div className="flex flex-wrap gap-1">
|
|
998
|
-
{selectedOptions.map((opt, idx) => {
|
|
999
|
-
const chipLabel = opt?.label || opt?.[displayField];
|
|
1000
|
-
// Search-first (people) fields show avatar chips; classic lookups
|
|
1001
|
-
// keep the plain text Badge.
|
|
1002
|
-
if (pickerVariant === 'search') {
|
|
1003
|
-
const avatarUrl = (opt as any)?.[avatarField] || (opt as any)?.image;
|
|
1004
|
-
return (
|
|
1005
|
-
<span
|
|
1006
|
-
key={idx}
|
|
1007
|
-
data-testid="people-field-chip"
|
|
1008
|
-
className="inline-flex items-center gap-1.5 rounded-full border bg-background py-0.5 pl-0.5 pr-1.5 text-sm"
|
|
1009
|
-
>
|
|
1010
|
-
<Avatar className="size-6 shrink-0">
|
|
1011
|
-
{avatarUrl && <AvatarImage src={avatarUrl} alt={String(chipLabel || '')} />}
|
|
1012
|
-
<AvatarFallback className="text-[10px]">
|
|
1013
|
-
{getPersonInitials(String(chipLabel || ''))}
|
|
1014
|
-
</AvatarFallback>
|
|
1015
|
-
</Avatar>
|
|
1016
|
-
<span className="max-w-[10rem] truncate">{chipLabel}</span>
|
|
1017
|
-
<button
|
|
1018
|
-
onClick={() => handleRemove(opt?.value)}
|
|
1019
|
-
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
1020
|
-
type="button"
|
|
1021
|
-
aria-label={t('lookup.remove', { label: chipLabel })}
|
|
1022
|
-
>
|
|
1023
|
-
<X className="size-3" />
|
|
1024
|
-
</button>
|
|
1025
|
-
</span>
|
|
1026
|
-
);
|
|
1027
|
-
}
|
|
1028
|
-
return (
|
|
1029
|
-
<Badge key={idx} variant="outline" className="gap-1">
|
|
1030
|
-
{chipLabel}
|
|
1031
|
-
<button
|
|
1032
|
-
onClick={() => handleRemove(opt?.value)}
|
|
1033
|
-
className="ml-1 hover:text-destructive"
|
|
1034
|
-
type="button"
|
|
1035
|
-
aria-label={t('lookup.remove', { label: chipLabel })}
|
|
1036
|
-
>
|
|
1037
|
-
<X className="size-3" />
|
|
1038
|
-
</button>
|
|
1039
|
-
</Badge>
|
|
1040
|
-
);
|
|
1041
|
-
})}
|
|
1042
|
-
</div>
|
|
1043
|
-
)}
|
|
1044
|
-
|
|
1045
|
-
{/* Field control: search-first inline combobox (anchored dropdown / mobile
|
|
1046
|
-
sheet), else the classic quick-select popover. */}
|
|
1047
|
-
{pickerVariant === 'search' && hasDataSource && dataSource && referenceTo ? (
|
|
1048
|
-
<PeoplePicker
|
|
1049
|
-
inline
|
|
1050
|
-
trigger={triggerButton}
|
|
1051
|
-
open={isPickerOpen}
|
|
1052
|
-
onOpenChange={setIsPickerOpen}
|
|
1053
|
-
title={lookupField?.label || t('common.select')}
|
|
1054
|
-
multiple={multiple}
|
|
1055
|
-
dataSource={dataSource}
|
|
1056
|
-
objectName={referenceTo}
|
|
1057
|
-
displayField={displayField}
|
|
1058
|
-
idField={idField}
|
|
1059
|
-
subtitleFields={subtitleFields}
|
|
1060
|
-
avatarField={avatarField}
|
|
1061
|
-
pageSize={lookupPageSize}
|
|
1062
|
-
value={pickerValue}
|
|
1063
|
-
onSelect={onChange}
|
|
1064
|
-
onSelectRecords={handlePickerSelectRecords}
|
|
1065
|
-
lookupFilters={lookupFilters}
|
|
1066
|
-
baseFilter={dependentFilter}
|
|
1067
|
-
/>
|
|
1068
|
-
) : (
|
|
1069
|
-
<div className="flex items-center gap-1.5">
|
|
1070
|
-
<Popover
|
|
1071
|
-
open={isOpen}
|
|
1072
|
-
onOpenChange={(o) => {
|
|
1073
|
-
if (!dependenciesMissing) setIsOpen(o);
|
|
1074
|
-
}}
|
|
1075
|
-
>
|
|
1076
|
-
<PopoverTrigger asChild>
|
|
1077
|
-
{triggerButton}
|
|
1078
|
-
</PopoverTrigger>
|
|
1079
|
-
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
1080
|
-
{/* Search input */}
|
|
1081
|
-
<div className="p-2">
|
|
1082
|
-
<div className="relative">
|
|
1083
|
-
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
1084
|
-
<Input
|
|
1085
|
-
placeholder={t('table.search')}
|
|
1086
|
-
value={searchQuery}
|
|
1087
|
-
onChange={(e) => handleSearchChange(e.target.value)}
|
|
1088
|
-
onKeyDown={handleSearchKeyDown}
|
|
1089
|
-
className="w-full pl-9 h-8 text-sm"
|
|
1090
|
-
role="combobox"
|
|
1091
|
-
aria-autocomplete="list"
|
|
1092
|
-
aria-controls={listboxId}
|
|
1093
|
-
aria-expanded={isOpen}
|
|
1094
|
-
aria-activedescendant={activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined}
|
|
1095
|
-
/>
|
|
1096
|
-
{loading && (
|
|
1097
|
-
<Loader2
|
|
1098
|
-
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground"
|
|
1099
|
-
data-testid="lookup-loading"
|
|
1100
|
-
/>
|
|
1101
|
-
)}
|
|
1102
|
-
</div>
|
|
1103
|
-
</div>
|
|
1104
|
-
|
|
1105
|
-
{/* Error state */}
|
|
1106
|
-
{error && (
|
|
1107
|
-
<div className="flex flex-col items-center gap-2 py-4 px-2" role="alert">
|
|
1108
|
-
<AlertCircle className="size-5 text-destructive" />
|
|
1109
|
-
<p className="text-sm text-destructive">{error}</p>
|
|
1110
|
-
<Button
|
|
1111
|
-
variant="outline"
|
|
1112
|
-
size="sm"
|
|
1113
|
-
onClick={() => popoverQuery.refetch()}
|
|
1114
|
-
type="button"
|
|
1115
|
-
>
|
|
1116
|
-
{t('lookup.retry')}
|
|
1117
|
-
</Button>
|
|
1118
|
-
</div>
|
|
1119
|
-
)}
|
|
1120
|
-
|
|
1121
|
-
{/* Loading state (initial load only, not search refinement) */}
|
|
1122
|
-
{loading && filteredOptions.length === 0 && !error && (
|
|
1123
|
-
<div className="flex flex-col items-center gap-2 py-6" role="status" aria-live="polite">
|
|
1124
|
-
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
1125
|
-
<p className="text-sm text-muted-foreground">{t('lookup.loading')}</p>
|
|
1126
|
-
</div>
|
|
1127
|
-
)}
|
|
1128
|
-
|
|
1129
|
-
{/* Options list */}
|
|
1130
|
-
{!error && !(loading && filteredOptions.length === 0) && (
|
|
1131
|
-
<div ref={listRef} className="max-h-64 overflow-y-auto px-1 pb-1" role="listbox" id={listboxId}>
|
|
1132
|
-
{visibleOptions.length === 0 ? (
|
|
1133
|
-
<div className="py-4 text-center">
|
|
1134
|
-
<p className="text-sm text-muted-foreground">
|
|
1135
|
-
{t('lookup.noOptions')}
|
|
1136
|
-
</p>
|
|
1137
|
-
{/* Quick-create entry */}
|
|
1138
|
-
{canCreate && (
|
|
1139
|
-
<Button
|
|
1140
|
-
variant="ghost"
|
|
1141
|
-
size="sm"
|
|
1142
|
-
className="mt-2 gap-1"
|
|
1143
|
-
type="button"
|
|
1144
|
-
disabled={creating}
|
|
1145
|
-
onClick={() => handleCreateNew(searchQuery)}
|
|
1146
|
-
>
|
|
1147
|
-
{creating ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
|
1148
|
-
{searchQuery ? t('lookup.createNamed', { name: searchQuery }) : t('lookup.createNew')}
|
|
1149
|
-
</Button>
|
|
1150
|
-
)}
|
|
1151
|
-
</div>
|
|
1152
|
-
) : (
|
|
1153
|
-
<>
|
|
1154
|
-
{visibleOptions.map((option, idx) => {
|
|
1155
|
-
const isSelected = multiple
|
|
1156
|
-
? (Array.isArray(value) ? value : []).map(normalizeId).includes(option.value)
|
|
1157
|
-
: normalizeId(value) === option.value;
|
|
1158
|
-
const isActive = idx === activeIndex;
|
|
1159
|
-
const showRecentHeader = recentCount > 0 && idx === 0;
|
|
1160
|
-
const showResultsHeader = recentCount > 0 && idx === recentCount;
|
|
1161
|
-
|
|
1162
|
-
return (
|
|
1163
|
-
<React.Fragment key={option.value}>
|
|
1164
|
-
{showRecentHeader && (
|
|
1165
|
-
<div className="px-3 pt-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
1166
|
-
{t('lookup.recentlyUsed')}
|
|
1167
|
-
</div>
|
|
1168
|
-
)}
|
|
1169
|
-
{showResultsHeader && (
|
|
1170
|
-
<div className="mt-1 border-t px-3 pt-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
1171
|
-
{t('lookup.allResults')}
|
|
1172
|
-
</div>
|
|
1173
|
-
)}
|
|
1174
|
-
<button
|
|
1175
|
-
id={`${listboxId}-opt-${idx}`}
|
|
1176
|
-
data-lookup-index={idx}
|
|
1177
|
-
role="option"
|
|
1178
|
-
aria-selected={isSelected}
|
|
1179
|
-
title={previewOf(option)}
|
|
1180
|
-
onClick={() => handleSelect(option)}
|
|
1181
|
-
className={`w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center justify-between ${
|
|
1182
|
-
isActive
|
|
1183
|
-
? 'bg-accent text-accent-foreground'
|
|
1184
|
-
: isSelected
|
|
1185
|
-
? 'bg-accent/50 text-accent-foreground'
|
|
1186
|
-
: ''
|
|
1187
|
-
}`}
|
|
1188
|
-
type="button"
|
|
1189
|
-
>
|
|
1190
|
-
<div className="min-w-0 flex-1">
|
|
1191
|
-
<span className="block truncate">{option.label}</span>
|
|
1192
|
-
{option.description && (
|
|
1193
|
-
<span className="block truncate text-xs text-muted-foreground">
|
|
1194
|
-
{option.description}
|
|
1195
|
-
</span>
|
|
1196
|
-
)}
|
|
1197
|
-
</div>
|
|
1198
|
-
{isSelected && (
|
|
1199
|
-
<Badge variant="default" className="ml-2 shrink-0">{t('lookup.selectedBadge')}</Badge>
|
|
1200
|
-
)}
|
|
1201
|
-
</button>
|
|
1202
|
-
</React.Fragment>
|
|
1203
|
-
);
|
|
1204
|
-
})}
|
|
1205
|
-
{/* Show total count when fetched from DataSource */}
|
|
1206
|
-
{hasDataSource && totalCount > filteredOptions.length && (
|
|
1207
|
-
<p className="text-xs text-muted-foreground text-center py-2">
|
|
1208
|
-
{t('lookup.showingResults', { shown: filteredOptions.length, total: totalCount })}
|
|
1209
|
-
</p>
|
|
1210
|
-
)}
|
|
1211
|
-
{/* "Show All Results" button — opens the full Record Picker (Level 2) */}
|
|
1212
|
-
{hasDataSource && totalCount > filteredOptions.length && (
|
|
1213
|
-
<button
|
|
1214
|
-
type="button"
|
|
1215
|
-
className="w-full text-center px-3 py-2 rounded-md text-sm font-medium text-primary hover:bg-accent flex items-center justify-center gap-1.5"
|
|
1216
|
-
onClick={() => {
|
|
1217
|
-
setIsOpen(false);
|
|
1218
|
-
setIsPickerOpen(true);
|
|
1219
|
-
}}
|
|
1220
|
-
data-testid="show-all-results"
|
|
1221
|
-
>
|
|
1222
|
-
<TableProperties className="size-3.5" />
|
|
1223
|
-
{t('lookup.showAllResults', { count: totalCount })}
|
|
1224
|
-
</button>
|
|
1225
|
-
)}
|
|
1226
|
-
{/* Quick-create entry (below results) */}
|
|
1227
|
-
{canCreate && (
|
|
1228
|
-
<button
|
|
1229
|
-
type="button"
|
|
1230
|
-
className="w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center gap-1.5 text-muted-foreground"
|
|
1231
|
-
disabled={creating}
|
|
1232
|
-
onClick={() => handleCreateNew(searchQuery)}
|
|
1233
|
-
>
|
|
1234
|
-
{creating ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
|
|
1235
|
-
{searchQuery ? t('lookup.createNamed', { name: searchQuery }) : t('lookup.createNew')}
|
|
1236
|
-
</button>
|
|
1237
|
-
)}
|
|
1238
|
-
</>
|
|
1239
|
-
)}
|
|
1240
|
-
</div>
|
|
1241
|
-
)}
|
|
1242
|
-
</PopoverContent>
|
|
1243
|
-
</Popover>
|
|
1244
|
-
|
|
1245
|
-
{/* "Browse All" button — classic lookups only; search fields open the
|
|
1246
|
-
PeoplePicker from the trigger itself, so this would be redundant. */}
|
|
1247
|
-
{hasDataSource && pickerVariant !== 'search' && (
|
|
1248
|
-
<Button
|
|
1249
|
-
variant="outline"
|
|
1250
|
-
size="icon"
|
|
1251
|
-
className="shrink-0"
|
|
1252
|
-
type="button"
|
|
1253
|
-
// Gated exactly like the main trigger (#2215) — pre-fix this button
|
|
1254
|
-
// opened the full unscoped table while the dependency was missing.
|
|
1255
|
-
disabled={dependenciesMissing || props.disabled}
|
|
1256
|
-
onClick={() => setIsPickerOpen(true)}
|
|
1257
|
-
aria-label={t('lookup.browseAll')}
|
|
1258
|
-
title={dependenciesMissing
|
|
1259
|
-
? t('lookup.selectFirst', { fields: dependsOnFieldsText })
|
|
1260
|
-
: t('lookup.browseAll')}
|
|
1261
|
-
data-testid="browse-all-records"
|
|
1262
|
-
>
|
|
1263
|
-
<TableProperties className="size-4" />
|
|
1264
|
-
</Button>
|
|
1265
|
-
)}
|
|
1266
|
-
</div>
|
|
1267
|
-
)}
|
|
1268
|
-
|
|
1269
|
-
{/* Level 2: classic table picker — search fields use the inline combobox above. */}
|
|
1270
|
-
{hasDataSource && dataSource && referenceTo && pickerVariant !== 'search' && (
|
|
1271
|
-
<RecordPickerDialog
|
|
1272
|
-
open={isPickerOpen}
|
|
1273
|
-
onOpenChange={setIsPickerOpen}
|
|
1274
|
-
title={lookupField?.label || t('common.select')}
|
|
1275
|
-
multiple={multiple}
|
|
1276
|
-
dataSource={dataSource}
|
|
1277
|
-
objectName={referenceTo}
|
|
1278
|
-
columns={pickerColumns}
|
|
1279
|
-
displayField={displayField}
|
|
1280
|
-
titleFormat={refTitleFormat}
|
|
1281
|
-
idField={idField}
|
|
1282
|
-
pageSize={lookupPageSize}
|
|
1283
|
-
value={pickerValue}
|
|
1284
|
-
onSelect={onChange}
|
|
1285
|
-
onSelectRecords={handlePickerSelectRecords}
|
|
1286
|
-
lookupFilters={lookupFilters}
|
|
1287
|
-
baseFilter={dependentFilter}
|
|
1288
|
-
cellRenderer={getCellRendererResolver()}
|
|
1289
|
-
fieldsMeta={refObjectSchema?.fields}
|
|
1290
|
-
filterColumns={filterColumns}
|
|
1291
|
-
/>
|
|
1292
|
-
)}
|
|
1293
|
-
</div>
|
|
1294
|
-
);
|
|
1295
|
-
}
|