@object-ui/fields 11.2.0 → 11.4.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 +153 -0
- package/dist/FieldEditWidget.d.ts +21 -0
- package/dist/FieldEditWidget.test.d.ts +8 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/index.cjs +4 -4
- package/dist/index.d.ts +8 -0
- package/dist/index.js +2155 -1438
- package/dist/widgets/LookupField.dependsOn.test.d.ts +8 -0
- package/dist/widgets/PeoplePicker.d.ts +63 -0
- package/dist/widgets/PeoplePicker.test.d.ts +8 -0
- package/dist/widgets/PeoplePickerComponents.test.d.ts +8 -0
- package/dist/widgets/PersonRow.d.ts +23 -0
- package/dist/widgets/RecordPickerDialog.d.ts +9 -1
- package/dist/widgets/SelectionTray.d.ts +27 -0
- package/dist/widgets/UserField.d.ts +0 -13
- package/dist/widgets/UserFieldPicker.test.d.ts +8 -0
- package/dist/widgets/deriveLookupColumns.d.ts +12 -4
- package/dist/widgets/personDisplay.d.ts +37 -0
- package/dist/widgets/personDisplay.test.d.ts +8 -0
- package/dist/widgets/useRecordQuery.d.ts +87 -0
- package/dist/widgets/useRecordQuery.test.d.ts +8 -0
- package/package.json +11 -11
- package/src/FieldEditWidget.test.ts +61 -0
- package/src/FieldEditWidget.tsx +146 -0
- package/src/__tests__/NumberCellRenderer.test.tsx +60 -0
- package/src/__tests__/lookupCellReferenceKey.test.tsx +57 -0
- package/src/complex-widgets.test.tsx +241 -0
- package/src/index.tsx +33 -4
- package/src/widgets/GridField.test.tsx +14 -0
- package/src/widgets/GridField.tsx +7 -8
- package/src/widgets/LookupField.dependsOn.test.tsx +233 -0
- package/src/widgets/LookupField.tsx +397 -199
- package/src/widgets/NumberField.tsx +4 -2
- package/src/widgets/PeoplePicker.test.tsx +334 -0
- package/src/widgets/PeoplePicker.tsx +552 -0
- package/src/widgets/PeoplePickerComponents.test.tsx +104 -0
- package/src/widgets/PersonRow.tsx +117 -0
- package/src/widgets/RecordPickerDialog.tsx +58 -129
- package/src/widgets/SelectionTray.tsx +110 -0
- package/src/widgets/UserField.tsx +20 -1
- package/src/widgets/UserFieldPicker.test.tsx +78 -0
- package/src/widgets/deriveLookupColumns.test.ts +25 -0
- package/src/widgets/deriveLookupColumns.ts +24 -10
- package/src/widgets/personDisplay.test.ts +113 -0
- package/src/widgets/personDisplay.ts +92 -0
- package/src/widgets/useRecordQuery.test.ts +174 -0
- package/src/widgets/useRecordQuery.ts +264 -0
|
@@ -3,6 +3,9 @@ import { cn,
|
|
|
3
3
|
Button,
|
|
4
4
|
Input,
|
|
5
5
|
Badge,
|
|
6
|
+
Avatar,
|
|
7
|
+
AvatarFallback,
|
|
8
|
+
AvatarImage,
|
|
6
9
|
Popover,
|
|
7
10
|
PopoverTrigger,
|
|
8
11
|
PopoverContent, EmptyValue } from '@object-ui/components';
|
|
@@ -11,10 +14,14 @@ import { FieldWidgetProps } from './types';
|
|
|
11
14
|
import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
|
|
12
15
|
import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog';
|
|
13
16
|
import type { RecordPickerFilterColumn } from './RecordPickerDialog';
|
|
17
|
+
import { PeoplePicker } from './PeoplePicker';
|
|
18
|
+
import { useRecordQuery } from './useRecordQuery';
|
|
14
19
|
import { deriveLookupColumns } from './deriveLookupColumns';
|
|
20
|
+
import { getRecordDisplayName } from '@object-ui/core';
|
|
15
21
|
import { getRecentLookupIds, pushRecentLookupId } from './recentLookups';
|
|
22
|
+
import { getPersonInitials } from './personDisplay';
|
|
16
23
|
import { getCellRendererResolver } from './_cell-renderer-bridge';
|
|
17
|
-
import { SchemaRendererContext as ImportedSchemaRendererContext } from '@object-ui/react';
|
|
24
|
+
import { SchemaRendererContext as ImportedSchemaRendererContext, useAction, useHasActionProvider } from '@object-ui/react';
|
|
18
25
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
19
26
|
|
|
20
27
|
export interface LookupOption {
|
|
@@ -33,6 +40,22 @@ const LOOKUP_PAGE_SIZE = 50;
|
|
|
33
40
|
*/
|
|
34
41
|
const SchemaRendererContext: React.Context<any> = ImportedSchemaRendererContext;
|
|
35
42
|
|
|
43
|
+
/**
|
|
44
|
+
* A relation whose picker should offer inline "create the referenced record" by
|
|
45
|
+
* default. Inline quick-create is a STANDARD capability so a freshly-built app
|
|
46
|
+
* isn't a dead end (an empty required picker → you can create the FIRST related
|
|
47
|
+
* record right here). Platform/system objects are excluded: the user directory
|
|
48
|
+
* (`sys_user` and its bare `user`/`users` aliases) and everything under the
|
|
49
|
+
* `sys_`/`cloud_`/`ai_` namespaces are pre-populated plumbing you must not create
|
|
50
|
+
* from a field picker. A user-authored business entity (customer, pet, book, …)
|
|
51
|
+
* never matches, so it gets the capability.
|
|
52
|
+
*/
|
|
53
|
+
const SYSTEM_REFERENCE_RX = /^(sys_|cloud_|ai_)/;
|
|
54
|
+
const USER_DIRECTORY_REFS = new Set(['user', 'users']);
|
|
55
|
+
function isUserFacingReference(reference: string | undefined): boolean {
|
|
56
|
+
return !!reference && !SYSTEM_REFERENCE_RX.test(reference) && !USER_DIRECTORY_REFS.has(reference);
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
/**
|
|
37
60
|
* Render a record title from a `titleFormat` template (e.g. `{full_name}` or
|
|
38
61
|
* `{case_number} - {subject}`). When a templated key resolves to an empty
|
|
@@ -69,10 +92,17 @@ function formatRecordTitle(record: any, titleFormat: string): string | null {
|
|
|
69
92
|
/**
|
|
70
93
|
* Map a raw record to a LookupOption using a display field and an id field.
|
|
71
94
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
95
|
+
* Label precedence (ADR-0079):
|
|
96
|
+
* 1. `titleFormat` template (when supplied, derived from the referenced
|
|
97
|
+
* object's schema) — e.g. `"Acme - John Doe"`.
|
|
98
|
+
* 2. the explicit `displayField` value on the record.
|
|
99
|
+
* 3. the unified `@object-ui/core#getRecordDisplayName` against the referenced
|
|
100
|
+
* object's schema (`objectDef`) — adds `displayNameField` + type-aware
|
|
101
|
+
* field derivation, so a lookup to an object whose name lives in e.g.
|
|
102
|
+
* `activity_name` resolves a real name instead of the raw id. We stop short
|
|
103
|
+
* of the resolver's `Record #<id>` floor here so the chip still falls
|
|
104
|
+
* through to the bare id when nothing nameable exists.
|
|
105
|
+
* 4. the legacy hard-coded name list, then the raw id.
|
|
76
106
|
*/
|
|
77
107
|
function recordToOption(
|
|
78
108
|
record: any,
|
|
@@ -80,22 +110,57 @@ function recordToOption(
|
|
|
80
110
|
idField: string,
|
|
81
111
|
descriptionField?: string,
|
|
82
112
|
titleFormat?: string | null,
|
|
113
|
+
objectDef?: any,
|
|
83
114
|
): LookupOption {
|
|
84
|
-
const val = record[idField] ?? record.id ?? record._id;
|
|
115
|
+
const val = record[idField] ?? record.id ?? record._id ?? record.externalId;
|
|
85
116
|
const templated = titleFormat ? formatRecordTitle(record, titleFormat) : null;
|
|
117
|
+
|
|
118
|
+
// Object-level resolver fallback (displayNameField + derivation), excluding
|
|
119
|
+
// its id floor so we don't shadow the explicit `String(val)` tail.
|
|
120
|
+
let unified: string | undefined;
|
|
121
|
+
if (objectDef) {
|
|
122
|
+
const resolved = getRecordDisplayName(objectDef, record);
|
|
123
|
+
const id = record?.id ?? record?._id;
|
|
124
|
+
const isFloor =
|
|
125
|
+
resolved === 'Untitled' ||
|
|
126
|
+
(id !== null && id !== undefined && resolved === `Record #${id}`);
|
|
127
|
+
if (!isFloor) unified = resolved;
|
|
128
|
+
}
|
|
129
|
+
|
|
86
130
|
const label =
|
|
87
131
|
templated ??
|
|
88
132
|
record[displayField] ??
|
|
133
|
+
unified ??
|
|
89
134
|
record.label ??
|
|
90
135
|
record.name ??
|
|
91
136
|
record.full_name ??
|
|
92
137
|
record.title ??
|
|
93
138
|
record.subject ??
|
|
139
|
+
record.externalId ??
|
|
94
140
|
String(val);
|
|
95
141
|
const description = descriptionField ? record[descriptionField] : undefined;
|
|
96
142
|
return { value: val, label: String(label), description, ...record };
|
|
97
143
|
}
|
|
98
144
|
|
|
145
|
+
/**
|
|
146
|
+
* A reference value can arrive JSON-encoded — e.g. an unresolved external-id
|
|
147
|
+
* reference `'{"externalId":"Website Relaunch"}'`. Parse such a string into its
|
|
148
|
+
* object form so the inline editor resolves it through the same path as a
|
|
149
|
+
* server-`$expand`ed record. Returns null for anything that isn't a JSON object
|
|
150
|
+
* string. Mirrors the read cell (`LookupCellRenderer`) so the two stay aligned.
|
|
151
|
+
*/
|
|
152
|
+
function parseReferenceObjectString(v: any): Record<string, any> | null {
|
|
153
|
+
if (typeof v !== 'string') return null;
|
|
154
|
+
const s = v.trim();
|
|
155
|
+
if (!s.startsWith('{') || !s.endsWith('}')) return null;
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse(s);
|
|
158
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
99
164
|
/**
|
|
100
165
|
* Map a LookupColumnDef.type to a filter input type for the filter bar.
|
|
101
166
|
* Returns undefined if the field type is not filterable.
|
|
@@ -128,16 +193,12 @@ function mapFieldTypeToFilterType(
|
|
|
128
193
|
*/
|
|
129
194
|
export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
130
195
|
const [isOpen, setIsOpen] = useState(false);
|
|
131
|
-
const [searchQuery, setSearchQuery] = useState('');
|
|
132
196
|
const { t } = useFieldTranslation();
|
|
133
197
|
const listboxId = React.useId();
|
|
134
198
|
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
const [
|
|
138
|
-
const [error, setError] = useState<string | null>(null);
|
|
139
|
-
const [totalCount, setTotalCount] = useState(0);
|
|
140
|
-
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
199
|
+
// Create-new error is local; the popover's fetch state (search/loading/error/
|
|
200
|
+
// total/options) is sourced from the shared useRecordQuery kernel below.
|
|
201
|
+
const [createError, setCreateError] = useState<string | null>(null);
|
|
141
202
|
|
|
142
203
|
// Records selected via RecordPickerDialog (Level 2).
|
|
143
204
|
// Stored as LookupOption so that findOption can resolve display labels
|
|
@@ -167,15 +228,28 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
167
228
|
const idField = fieldMeta?.id_field || 'id';
|
|
168
229
|
// ObjectStack convention uses `reference`; types define `reference_to` — support both
|
|
169
230
|
const referenceTo: string | undefined = fieldMeta?.reference_to || fieldMeta?.reference;
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
231
|
+
// Inline quick-create — a STANDARD capability, default ON for user-facing
|
|
232
|
+
// relations: an empty/zero-result picker offers to create the referenced
|
|
233
|
+
// record (opening its create form; see handleCreateNew) so the first related
|
|
234
|
+
// record can be made right here. An explicit `allowCreate` (either casing)
|
|
235
|
+
// wins — set it `false` to opt a field out; system/user-directory references
|
|
236
|
+
// are excluded from the default (isUserFacingReference).
|
|
237
|
+
const explicitAllowCreate = fieldMeta?.allow_create ?? fieldMeta?.allowCreate;
|
|
238
|
+
const allowCreate: boolean =
|
|
239
|
+
explicitAllowCreate != null ? !!explicitAllowCreate : isUserFacingReference(referenceTo);
|
|
173
240
|
|
|
174
241
|
// Enterprise Record Picker configuration
|
|
175
242
|
const lookupColumns: Array<string | LookupColumnDef> | undefined = fieldMeta?.lookup_columns ?? fieldMeta?.lookupColumns;
|
|
176
243
|
const lookupPageSize: number | undefined = fieldMeta?.lookup_page_size ?? fieldMeta?.lookupPageSize;
|
|
177
244
|
const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookup_filters ?? fieldMeta?.lookupFilters;
|
|
178
245
|
|
|
246
|
+
// Search-first PeoplePicker opt-in (user fields). When `picker === 'search'`
|
|
247
|
+
// the Level-2 picker is the rich PeoplePicker (avatar rows + selection tray)
|
|
248
|
+
// instead of the classic table dialog. `subtitle`/`avatar_field` drive the rows.
|
|
249
|
+
const pickerVariant: string | undefined = fieldMeta?.picker;
|
|
250
|
+
const subtitleFields: string[] | undefined = fieldMeta?.subtitle;
|
|
251
|
+
const avatarField: string = fieldMeta?.avatar_field ?? fieldMeta?.avatarField ?? 'image';
|
|
252
|
+
|
|
179
253
|
/**
|
|
180
254
|
* Dependent lookups — restrict candidates based on values of *other* fields
|
|
181
255
|
* in the same form. Two shapes are accepted:
|
|
@@ -294,7 +368,54 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
294
368
|
// State for the full Record Picker dialog (Level 2)
|
|
295
369
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
|
296
370
|
|
|
371
|
+
// Dependent-lookup chain as a hard QueryParams.$filter record. Shared by
|
|
372
|
+
// EVERY candidate surface — quick-select popover, Level-2 table picker and
|
|
373
|
+
// the search-first PeoplePicker — so no picker can bypass the cascade
|
|
374
|
+
// (#2215: the table picker used to list the full unfiltered set).
|
|
375
|
+
const dependentFilter = useMemo<Record<string, any> | undefined>(() => {
|
|
376
|
+
const f: Record<string, any> = {};
|
|
377
|
+
for (const { field, param } of dependsOn) {
|
|
378
|
+
const v = resolvedDependentValues[field];
|
|
379
|
+
if (v === undefined || v === null || v === '') continue;
|
|
380
|
+
f[param] = typeof v === 'number' ? v : String(v);
|
|
381
|
+
}
|
|
382
|
+
return Object.keys(f).length > 0 ? f : undefined;
|
|
383
|
+
}, [dependsOn, resolvedDependentValues]);
|
|
384
|
+
|
|
297
385
|
// Determine which options to display
|
|
386
|
+
// Quick-select popover fetch — the shared record-query kernel (same one the
|
|
387
|
+
// Record Picker dialog and PeoplePicker use). Filter = dependent-lookup chain
|
|
388
|
+
// + base lookupFilters, so the popover matches the full picker.
|
|
389
|
+
const popoverFilter = useMemo<Record<string, any> | undefined>(() => {
|
|
390
|
+
const f: Record<string, any> = {
|
|
391
|
+
...(lookupFilters && lookupFilters.length > 0 ? lookupFiltersToRecord(lookupFilters) : {}),
|
|
392
|
+
...(dependentFilter ?? {}),
|
|
393
|
+
};
|
|
394
|
+
return Object.keys(f).length > 0 ? f : undefined;
|
|
395
|
+
}, [dependentFilter, lookupFilters]);
|
|
396
|
+
|
|
397
|
+
const popoverQuery = useRecordQuery({
|
|
398
|
+
dataSource,
|
|
399
|
+
objectName: referenceTo,
|
|
400
|
+
enabled: isOpen && hasDataSource && !dependenciesMissing,
|
|
401
|
+
pageSize: LOOKUP_PAGE_SIZE,
|
|
402
|
+
filter: popoverFilter,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Re-source the popover's fetch state from the kernel; all existing read sites
|
|
406
|
+
// (searchQuery / loading / error / totalCount / fetchedOptions) stay unchanged.
|
|
407
|
+
const searchQuery = popoverQuery.search;
|
|
408
|
+
const loading = popoverQuery.loading;
|
|
409
|
+
const totalCount = popoverQuery.total;
|
|
410
|
+
const error = popoverQuery.error ?? createError;
|
|
411
|
+
const fetchedOptions = useMemo(
|
|
412
|
+
() =>
|
|
413
|
+
popoverQuery.records.map(r =>
|
|
414
|
+
recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema),
|
|
415
|
+
),
|
|
416
|
+
[popoverQuery.records, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema],
|
|
417
|
+
);
|
|
418
|
+
|
|
298
419
|
const allOptions = hasDataSource ? fetchedOptions : staticOptions;
|
|
299
420
|
|
|
300
421
|
// For static options, filter locally based on search
|
|
@@ -313,124 +434,19 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
313
434
|
setActiveIndex(-1);
|
|
314
435
|
}, [filteredOptions.length]);
|
|
315
436
|
|
|
316
|
-
// Fetch
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (!dataSource || !referenceTo) return;
|
|
320
|
-
// Don't issue a request that ignores configured dependencies.
|
|
321
|
-
if (dependenciesMissing) {
|
|
322
|
-
setFetchedOptions([]);
|
|
323
|
-
setTotalCount(0);
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
setLoading(true);
|
|
328
|
-
setError(null);
|
|
329
|
-
|
|
330
|
-
try {
|
|
331
|
-
const params: QueryParams = {
|
|
332
|
-
$top: LOOKUP_PAGE_SIZE,
|
|
333
|
-
};
|
|
334
|
-
if (search && search.trim()) {
|
|
335
|
-
params.$search = search.trim();
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// Build a dependent-lookup filter chain: AND of `param eq value`.
|
|
339
|
-
// QueryParams.$filter is a Record<string, any> — adapters convert to
|
|
340
|
-
// the underlying query language (OData, ObjectQL, etc).
|
|
341
|
-
if (dependsOn.length > 0) {
|
|
342
|
-
const filterEntries: Record<string, any> = {};
|
|
343
|
-
for (const { field, param } of dependsOn) {
|
|
344
|
-
const v = resolvedDependentValues[field];
|
|
345
|
-
if (v === undefined || v === null || v === '') continue;
|
|
346
|
-
filterEntries[param] = typeof v === 'number' ? v : String(v);
|
|
347
|
-
}
|
|
348
|
-
if (Object.keys(filterEntries).length > 0) {
|
|
349
|
-
params.$filter = filterEntries;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Apply base scoping filters so the quick-select popover matches the
|
|
354
|
-
// full picker dialog (lookupFilters were previously dialog-only).
|
|
355
|
-
if (lookupFilters && lookupFilters.length > 0) {
|
|
356
|
-
params.$filter = { ...(params.$filter ?? {}), ...lookupFiltersToRecord(lookupFilters) };
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
const result = await dataSource.find(referenceTo, params);
|
|
360
|
-
const records: any[] = result?.data ?? result ?? [];
|
|
361
|
-
const mapped = records.map(r => recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat));
|
|
362
|
-
|
|
363
|
-
setFetchedOptions(mapped);
|
|
364
|
-
setTotalCount(result?.total ?? records.length);
|
|
365
|
-
} catch (err) {
|
|
366
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
367
|
-
setError(msg);
|
|
368
|
-
setFetchedOptions([]);
|
|
369
|
-
} finally {
|
|
370
|
-
setLoading(false);
|
|
371
|
-
}
|
|
372
|
-
},
|
|
373
|
-
[dataSource, referenceTo, displayField, idField, effectiveDescriptionField, refTitleFormat, dependenciesMissing, dependsOn, resolvedDependentValues, lookupFilters],
|
|
374
|
-
);
|
|
375
|
-
|
|
376
|
-
// Re-fetch when dependent values change while the picker is open. This keeps
|
|
377
|
-
// a "City" dropdown reactive when the user changes "Country" without closing.
|
|
378
|
-
const dependencySignature = useMemo(
|
|
379
|
-
() => dependsOn.map(d => `${d.param}=${resolvedDependentValues[d.field] ?? ''}`).join('|'),
|
|
380
|
-
[dependsOn, resolvedDependentValues],
|
|
381
|
-
);
|
|
382
|
-
useEffect(() => {
|
|
383
|
-
if (isOpen && hasDataSource && dependsOn.length > 0) {
|
|
384
|
-
fetchLookupData(searchQuery || undefined);
|
|
385
|
-
// Clear local selection if it no longer satisfies the new filter chain —
|
|
386
|
-
// out of scope here; consumer should validate on submit.
|
|
387
|
-
}
|
|
388
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
389
|
-
}, [dependencySignature]);
|
|
390
|
-
|
|
391
|
-
// Fetch data when dialog opens.
|
|
392
|
-
// We intentionally depend only on `isOpen` so the effect fires once per
|
|
393
|
-
// open/close transition. `fetchLookupData` is stable-enough via its own
|
|
394
|
-
// useCallback deps; including it here would cause spurious re-fetches.
|
|
437
|
+
// Reset the keyboard cursor when the popover closes. Fetch state (records,
|
|
438
|
+
// search, error, total) is owned by `popoverQuery` and resets automatically
|
|
439
|
+
// when it becomes disabled (via `enabled`), including its debounced search.
|
|
395
440
|
useEffect(() => {
|
|
396
|
-
if (isOpen
|
|
397
|
-
fetchLookupData(searchQuery || undefined);
|
|
398
|
-
}
|
|
399
|
-
// Clean up fetched data when dialog closes
|
|
400
|
-
if (!isOpen) {
|
|
401
|
-
setSearchQuery('');
|
|
402
|
-
setError(null);
|
|
403
|
-
setActiveIndex(-1);
|
|
404
|
-
}
|
|
405
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
441
|
+
if (!isOpen) setActiveIndex(-1);
|
|
406
442
|
}, [isOpen]);
|
|
407
443
|
|
|
408
|
-
//
|
|
444
|
+
// Search is the kernel's debounced setter.
|
|
409
445
|
const handleSearchChange = useCallback(
|
|
410
|
-
(query: string) =>
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if (!hasDataSource) return;
|
|
414
|
-
|
|
415
|
-
if (debounceTimer.current) {
|
|
416
|
-
clearTimeout(debounceTimer.current);
|
|
417
|
-
}
|
|
418
|
-
debounceTimer.current = setTimeout(() => {
|
|
419
|
-
fetchLookupData(query || undefined);
|
|
420
|
-
}, 300);
|
|
421
|
-
},
|
|
422
|
-
[hasDataSource, fetchLookupData],
|
|
446
|
+
(query: string) => popoverQuery.setSearch(query),
|
|
447
|
+
[popoverQuery.setSearch],
|
|
423
448
|
);
|
|
424
449
|
|
|
425
|
-
// Clean up debounce timer
|
|
426
|
-
useEffect(() => {
|
|
427
|
-
return () => {
|
|
428
|
-
if (debounceTimer.current) {
|
|
429
|
-
clearTimeout(debounceTimer.current);
|
|
430
|
-
}
|
|
431
|
-
};
|
|
432
|
-
}, []);
|
|
433
|
-
|
|
434
450
|
/**
|
|
435
451
|
* Hydrate the picker's display when the field already has a value (e.g.
|
|
436
452
|
* edit-mode load, prefill via query-string from a related-list "+ New")
|
|
@@ -440,9 +456,17 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
440
456
|
*/
|
|
441
457
|
useEffect(() => {
|
|
442
458
|
if (!hasDataSource || !dataSource || !referenceTo) return;
|
|
443
|
-
const
|
|
459
|
+
const raw: any[] = multiple
|
|
444
460
|
? Array.isArray(value) ? value : []
|
|
445
461
|
: value != null && value !== '' ? [value] : [];
|
|
462
|
+
// Expanded-reference values (server `$expand`, or their JSON-encoded string
|
|
463
|
+
// form) already carry their display fields and resolve directly in
|
|
464
|
+
// `resolveSelectedOption` — only bare ids need a fetch. Passing an object (or
|
|
465
|
+
// a JSON string) to `findOne` would query for a bogus id and leave the
|
|
466
|
+
// trigger stuck on the placeholder.
|
|
467
|
+
const ids = raw.filter(
|
|
468
|
+
(v) => v != null && v !== '' && typeof v !== 'object' && !parseReferenceObjectString(v),
|
|
469
|
+
);
|
|
446
470
|
if (!ids.length) return;
|
|
447
471
|
// Only fetch records we haven't resolved yet.
|
|
448
472
|
const unresolved = ids.filter((v) => !findOption(v));
|
|
@@ -455,14 +479,14 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
455
479
|
for (const id of unresolved) {
|
|
456
480
|
if (typeof (dataSource as any).findOne === 'function') {
|
|
457
481
|
const rec = await (dataSource as any).findOne(referenceTo, id);
|
|
458
|
-
if (rec) fetched.push(recordToOption(rec, displayField, idField, effectiveDescriptionField, refTitleFormat));
|
|
482
|
+
if (rec) fetched.push(recordToOption(rec, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
|
|
459
483
|
} else {
|
|
460
484
|
const res = await dataSource.find(referenceTo, {
|
|
461
485
|
$filter: { [idField]: id },
|
|
462
486
|
$top: 1,
|
|
463
487
|
} as QueryParams);
|
|
464
488
|
const rows = res?.data ?? res ?? [];
|
|
465
|
-
if (rows[0]) fetched.push(recordToOption(rows[0], displayField, idField, effectiveDescriptionField, refTitleFormat));
|
|
489
|
+
if (rows[0]) fetched.push(recordToOption(rows[0], displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
|
|
466
490
|
}
|
|
467
491
|
}
|
|
468
492
|
if (!cancelled && fetched.length) {
|
|
@@ -494,9 +518,57 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
494
518
|
[staticOptions, fetchedOptions, pickerResolvedRecords],
|
|
495
519
|
);
|
|
496
520
|
|
|
521
|
+
// String-coerced fallback for `findOption` — matches the read cell's tolerant
|
|
522
|
+
// `String(a) === String(b)` comparison so a numeric cell value still resolves
|
|
523
|
+
// against a string-keyed option (and vice versa). Only consulted when the
|
|
524
|
+
// strict match misses, so homogeneous option lists are unaffected.
|
|
525
|
+
const findOptionLoose = useCallback(
|
|
526
|
+
(v: any): LookupOption | undefined => {
|
|
527
|
+
const key = String(v);
|
|
528
|
+
return (
|
|
529
|
+
staticOptions.find(opt => String(opt.value) === key) ??
|
|
530
|
+
fetchedOptions.find(opt => String(opt.value) === key) ??
|
|
531
|
+
pickerResolvedRecords.find(opt => String(opt.value) === key)
|
|
532
|
+
);
|
|
533
|
+
},
|
|
534
|
+
[staticOptions, fetchedOptions, pickerResolvedRecords],
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
// Collapse an expanded-reference value (the related record object returned by
|
|
538
|
+
// server `$expand`) to its bare id — used for option matching / highlighting.
|
|
539
|
+
const normalizeId = useCallback(
|
|
540
|
+
(raw: any): any => {
|
|
541
|
+
const obj = raw != null && typeof raw === 'object' ? raw : parseReferenceObjectString(raw);
|
|
542
|
+
return obj ? (obj[idField] ?? obj.id ?? obj._id ?? obj.externalId) : raw;
|
|
543
|
+
},
|
|
544
|
+
[idField],
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
// Resolve a raw field value into its display option. An expanded-reference
|
|
548
|
+
// object is mapped directly (mirroring the read cell's display-name path) so
|
|
549
|
+
// the inline editor shows the record's name instead of the placeholder; a bare
|
|
550
|
+
// id resolves through the static / fetched / picker-hydrated option lists.
|
|
551
|
+
const resolveSelectedOption = useCallback(
|
|
552
|
+
(raw: any): LookupOption | undefined => {
|
|
553
|
+
if (raw == null || raw === '') return undefined;
|
|
554
|
+
// An expanded-reference object (server `$expand`) — or its JSON-encoded
|
|
555
|
+
// string form, e.g. an external-id reference `'{"externalId":"…"}'` — is
|
|
556
|
+
// mapped directly, mirroring the read cell (`LookupCellRenderer`).
|
|
557
|
+
const asObject = typeof raw === 'object' ? raw : parseReferenceObjectString(raw);
|
|
558
|
+
if (asObject) {
|
|
559
|
+
return recordToOption(asObject, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema);
|
|
560
|
+
}
|
|
561
|
+
// Bare id: strict match first, then a String()-coerced fallback so a
|
|
562
|
+
// numeric cell value still resolves against a string-keyed option (and
|
|
563
|
+
// vice versa) — matching the read cell's tolerant comparison.
|
|
564
|
+
return findOption(raw) ?? findOptionLoose(raw);
|
|
565
|
+
},
|
|
566
|
+
[findOption, findOptionLoose, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema],
|
|
567
|
+
);
|
|
568
|
+
|
|
497
569
|
const selectedOptions = multiple
|
|
498
|
-
? (Array.isArray(value) ? value : []).map(
|
|
499
|
-
: value ? [
|
|
570
|
+
? (Array.isArray(value) ? value : []).map(resolveSelectedOption).filter(Boolean)
|
|
571
|
+
: value ? [resolveSelectedOption(value)].filter(Boolean) : [];
|
|
500
572
|
|
|
501
573
|
// Optional: receive the FULL selected record (not just its id) so a host can
|
|
502
574
|
// auto-fill sibling fields from it — e.g. a line-item grid copying a product's
|
|
@@ -506,8 +578,22 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
506
578
|
|
|
507
579
|
const handleSelect = useCallback(
|
|
508
580
|
(option: LookupOption) => {
|
|
581
|
+
// Cache the picked option so its label resolves synchronously and durably,
|
|
582
|
+
// independent of the popover's `fetchedOptions` (which the editor may have
|
|
583
|
+
// remounted away, or which a slow/contended re-render hasn't surfaced yet —
|
|
584
|
+
// the intermittent CI failure where a just-picked lookup showed no label,
|
|
585
|
+
// #2150). `selectedOptions` consults `pickerResolvedRecords` in `findOption`.
|
|
586
|
+
if (option && option.value != null) {
|
|
587
|
+
setPickerResolvedRecords((prev) => {
|
|
588
|
+
const map = new Map(prev.map((o) => [o.value, o]));
|
|
589
|
+
map.set(option.value, option);
|
|
590
|
+
return Array.from(map.values());
|
|
591
|
+
});
|
|
592
|
+
}
|
|
509
593
|
if (multiple) {
|
|
510
|
-
|
|
594
|
+
// Normalise any expanded-reference objects to bare ids so toggling
|
|
595
|
+
// compares like-for-like and always persists ids (never mixed shapes).
|
|
596
|
+
const currentValues = (Array.isArray(value) ? value : []).map(normalizeId);
|
|
511
597
|
const isSelected = currentValues.includes(option.value);
|
|
512
598
|
|
|
513
599
|
if (isSelected) {
|
|
@@ -523,12 +609,12 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
523
609
|
setIsOpen(false);
|
|
524
610
|
}
|
|
525
611
|
},
|
|
526
|
-
[multiple, value, onChange, onSelectRecord, referenceTo],
|
|
612
|
+
[multiple, value, onChange, onSelectRecord, referenceTo, normalizeId],
|
|
527
613
|
);
|
|
528
614
|
|
|
529
615
|
const handleRemove = (optionValue: any) => {
|
|
530
616
|
if (multiple) {
|
|
531
|
-
const currentValues = Array.isArray(value) ? value : [];
|
|
617
|
+
const currentValues = (Array.isArray(value) ? value : []).map(normalizeId);
|
|
532
618
|
onChange(currentValues.filter((v: any) => v !== optionValue));
|
|
533
619
|
} else {
|
|
534
620
|
onChange(null);
|
|
@@ -539,7 +625,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
539
625
|
// findOption can resolve display labels after the dialog closes.
|
|
540
626
|
const handlePickerSelectRecords = useCallback(
|
|
541
627
|
(records: any[]) => {
|
|
542
|
-
const mapped = records.map(r => recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat));
|
|
628
|
+
const mapped = records.map(r => recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
|
|
543
629
|
if (referenceTo) mapped.forEach((o) => pushRecentLookupId(referenceTo, o.value));
|
|
544
630
|
setPickerResolvedRecords(mapped);
|
|
545
631
|
},
|
|
@@ -561,7 +647,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
561
647
|
if (cached) { recs.push(cached); continue; }
|
|
562
648
|
if (typeof (dataSource as any).findOne === 'function') {
|
|
563
649
|
const r = await (dataSource as any).findOne(referenceTo, id);
|
|
564
|
-
if (r) recs.push(recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat));
|
|
650
|
+
if (r) recs.push(recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
|
|
565
651
|
}
|
|
566
652
|
}
|
|
567
653
|
if (!cancelled) setRecentOptions(recs);
|
|
@@ -582,26 +668,72 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
582
668
|
useEffect(() => { setActiveIndex(-1); }, [visibleOptions.length]);
|
|
583
669
|
|
|
584
670
|
const [creating, setCreating] = useState(false);
|
|
585
|
-
|
|
671
|
+
// Open the referenced object's create form through the ActionProvider's modal
|
|
672
|
+
// handler (when a form host wired one). Safe outside a provider — useAction
|
|
673
|
+
// returns a local runner and useHasActionProvider is false.
|
|
674
|
+
const { execute } = useAction();
|
|
675
|
+
const hasActionProvider = useHasActionProvider();
|
|
676
|
+
const canCreate = !!onCreateNew || (allowCreate && (hasActionProvider || hasDataSource));
|
|
586
677
|
const handleCreateNew = useCallback(
|
|
587
678
|
async (q: string) => {
|
|
588
679
|
const label = (q || '').trim();
|
|
589
680
|
if (onCreateNew) { onCreateNew(label); setIsOpen(false); return; }
|
|
590
|
-
if (!allowCreate || !
|
|
681
|
+
if (!allowCreate || !referenceTo) return;
|
|
682
|
+
setCreateError(null);
|
|
683
|
+
|
|
684
|
+
// Preferred path — open the referenced object's FULL create form so the
|
|
685
|
+
// user fills every required field themselves, then select the record they
|
|
686
|
+
// created. This is the standard "create related record" behaviour: it
|
|
687
|
+
// works for ANY object (not only ones whose sole required field is the
|
|
688
|
+
// title) and turns an empty required picker from a dead end into a way to
|
|
689
|
+
// author the first related record.
|
|
690
|
+
if (hasActionProvider) {
|
|
691
|
+
setIsOpen(false); // close the picker popover first
|
|
692
|
+
// Defer opening the create dialog until the popover has fully closed and
|
|
693
|
+
// returned focus to the field trigger. Two nested-modal bugs come from
|
|
694
|
+
// opening it in the same tick as the triggering click:
|
|
695
|
+
// 1) the just-mounted Dialog treats this click's release (and the
|
|
696
|
+
// popover's own dismiss) as an outside-interaction and flashes shut
|
|
697
|
+
// on the FIRST click (works on the second);
|
|
698
|
+
// 2) with the popover already gone, the "+ create" button that Radix
|
|
699
|
+
// would return focus to is unmounted, so when the nested modal later
|
|
700
|
+
// closes focus leaks to <body> and dismisses the PARENT form's dialog
|
|
701
|
+
// too ("Cancel closes both").
|
|
702
|
+
// A macrotask runs after Radix's focus-return layout effects, so focus is
|
|
703
|
+
// safely back on the still-mounted field trigger before the Dialog opens.
|
|
704
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
705
|
+
const result: any = await execute({
|
|
706
|
+
type: 'modal',
|
|
707
|
+
modal: { objectName: referenceTo, mode: 'create' },
|
|
708
|
+
} as any);
|
|
709
|
+
if (result?.success && result.data) {
|
|
710
|
+
const opt = recordToOption(result.data, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema);
|
|
711
|
+
setPickerResolvedRecords((prev) => [opt, ...prev.filter((o) => o.value !== opt.value)]);
|
|
712
|
+
handleSelect(opt);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
// `success` + an echoed `modal` schema means no modal handler was wired
|
|
716
|
+
// in this tree → fall through to the legacy inline create. Anything else
|
|
717
|
+
// (cancel / no data) means the user backed out → stop.
|
|
718
|
+
if (!(result?.success && result.modal)) return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Fallback (no modal handler in scope): the legacy one-field inline create
|
|
722
|
+
// from the typed text. Best-effort; surfaces any validation error inline.
|
|
723
|
+
if (!dataSource || !label) return;
|
|
591
724
|
setCreating(true);
|
|
592
|
-
setError(null);
|
|
593
725
|
try {
|
|
594
726
|
const created = await (dataSource as any).create(referenceTo, { [displayField]: label });
|
|
595
|
-
const opt = recordToOption(created, displayField, idField, effectiveDescriptionField, refTitleFormat);
|
|
727
|
+
const opt = recordToOption(created, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema);
|
|
596
728
|
setPickerResolvedRecords((prev) => [opt, ...prev.filter((o) => o.value !== opt.value)]);
|
|
597
729
|
handleSelect(opt);
|
|
598
730
|
} catch (err) {
|
|
599
|
-
|
|
731
|
+
setCreateError(err instanceof Error ? err.message : String(err));
|
|
600
732
|
} finally {
|
|
601
733
|
setCreating(false);
|
|
602
734
|
}
|
|
603
735
|
},
|
|
604
|
-
[onCreateNew, allowCreate,
|
|
736
|
+
[onCreateNew, allowCreate, referenceTo, hasActionProvider, execute, dataSource, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema, handleSelect],
|
|
605
737
|
);
|
|
606
738
|
|
|
607
739
|
// Compact one-line preview of an option's extra (non-display) columns —
|
|
@@ -683,63 +815,121 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
683
815
|
const compact = !!(props as any).compact;
|
|
684
816
|
const singleSelectedLabel = selectedOptions[0]?.label || selectedOptions[0]?.[displayField];
|
|
685
817
|
|
|
818
|
+
// Shared field trigger — the anchor for either the inline PeoplePicker
|
|
819
|
+
// (search fields) or the classic quick-select popover. No onClick: the Radix
|
|
820
|
+
// trigger it is slotted into (PopoverTrigger / SheetTrigger) owns open/close.
|
|
821
|
+
const triggerButton = (
|
|
822
|
+
<Button
|
|
823
|
+
variant="outline"
|
|
824
|
+
className={cn(
|
|
825
|
+
'min-w-0 flex-1 justify-start text-left font-normal',
|
|
826
|
+
compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
|
|
827
|
+
)}
|
|
828
|
+
type="button"
|
|
829
|
+
disabled={dependenciesMissing || (props as any).disabled}
|
|
830
|
+
data-testid={dependenciesMissing ? 'lookup-trigger-gated' : (((props as any).name || lookupField?.name) ? `lookup-trigger-${(props as any).name || lookupField.name}` : 'lookup-trigger')}
|
|
831
|
+
title={dependenciesMissing
|
|
832
|
+
? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
|
|
833
|
+
: undefined}
|
|
834
|
+
>
|
|
835
|
+
<Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
|
|
836
|
+
<span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
|
|
837
|
+
{dependenciesMissing
|
|
838
|
+
? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
|
|
839
|
+
: compact && !multiple && selectedOptions.length > 0
|
|
840
|
+
? singleSelectedLabel
|
|
841
|
+
: selectedOptions.length === 0
|
|
842
|
+
? lookupField?.placeholder || t('common.select')
|
|
843
|
+
: multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')}
|
|
844
|
+
</span>
|
|
845
|
+
</Button>
|
|
846
|
+
);
|
|
847
|
+
|
|
686
848
|
return (
|
|
687
849
|
<div className={compact ? '' : 'space-y-2'}>
|
|
688
850
|
{/* Selected values display (full mode only — compact shows it in-trigger) */}
|
|
689
851
|
{selectedOptions.length > 0 && !compact && (
|
|
690
852
|
<div className="flex flex-wrap gap-1">
|
|
691
|
-
{selectedOptions.map((opt, idx) =>
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
853
|
+
{selectedOptions.map((opt, idx) => {
|
|
854
|
+
const chipLabel = opt?.label || opt?.[displayField];
|
|
855
|
+
// Search-first (people) fields show avatar chips; classic lookups
|
|
856
|
+
// keep the plain text Badge.
|
|
857
|
+
if (pickerVariant === 'search') {
|
|
858
|
+
const avatarUrl = (opt as any)?.[avatarField] || (opt as any)?.image;
|
|
859
|
+
return (
|
|
860
|
+
<span
|
|
861
|
+
key={idx}
|
|
862
|
+
data-testid="people-field-chip"
|
|
863
|
+
className="inline-flex items-center gap-1.5 rounded-full border bg-background py-0.5 pl-0.5 pr-1.5 text-sm"
|
|
864
|
+
>
|
|
865
|
+
<Avatar className="size-6 shrink-0">
|
|
866
|
+
{avatarUrl && <AvatarImage src={avatarUrl} alt={String(chipLabel || '')} />}
|
|
867
|
+
<AvatarFallback className="text-[10px]">
|
|
868
|
+
{getPersonInitials(String(chipLabel || ''))}
|
|
869
|
+
</AvatarFallback>
|
|
870
|
+
</Avatar>
|
|
871
|
+
<span className="max-w-[10rem] truncate">{chipLabel}</span>
|
|
872
|
+
<button
|
|
873
|
+
onClick={() => handleRemove(opt?.value)}
|
|
874
|
+
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
875
|
+
type="button"
|
|
876
|
+
aria-label={t('lookup.remove', { label: chipLabel })}
|
|
877
|
+
>
|
|
878
|
+
<X className="size-3" />
|
|
879
|
+
</button>
|
|
880
|
+
</span>
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
return (
|
|
884
|
+
<Badge key={idx} variant="outline" className="gap-1">
|
|
885
|
+
{chipLabel}
|
|
886
|
+
<button
|
|
887
|
+
onClick={() => handleRemove(opt?.value)}
|
|
888
|
+
className="ml-1 hover:text-destructive"
|
|
889
|
+
type="button"
|
|
890
|
+
aria-label={t('lookup.remove', { label: chipLabel })}
|
|
891
|
+
>
|
|
892
|
+
<X className="size-3" />
|
|
893
|
+
</button>
|
|
894
|
+
</Badge>
|
|
895
|
+
);
|
|
896
|
+
})}
|
|
708
897
|
</div>
|
|
709
898
|
)}
|
|
710
899
|
|
|
711
|
-
{/*
|
|
900
|
+
{/* Field control: search-first inline combobox (anchored dropdown / mobile
|
|
901
|
+
sheet), else the classic quick-select popover. */}
|
|
902
|
+
{pickerVariant === 'search' && hasDataSource && dataSource && referenceTo ? (
|
|
903
|
+
<PeoplePicker
|
|
904
|
+
inline
|
|
905
|
+
trigger={triggerButton}
|
|
906
|
+
open={isPickerOpen}
|
|
907
|
+
onOpenChange={setIsPickerOpen}
|
|
908
|
+
title={lookupField?.label || t('common.select')}
|
|
909
|
+
multiple={multiple}
|
|
910
|
+
dataSource={dataSource}
|
|
911
|
+
objectName={referenceTo}
|
|
912
|
+
displayField={displayField}
|
|
913
|
+
idField={idField}
|
|
914
|
+
subtitleFields={subtitleFields}
|
|
915
|
+
avatarField={avatarField}
|
|
916
|
+
pageSize={lookupPageSize}
|
|
917
|
+
value={value}
|
|
918
|
+
onSelect={onChange}
|
|
919
|
+
onSelectRecords={handlePickerSelectRecords}
|
|
920
|
+
lookupFilters={lookupFilters}
|
|
921
|
+
baseFilter={dependentFilter}
|
|
922
|
+
/>
|
|
923
|
+
) : (
|
|
712
924
|
<div className="flex items-center gap-1.5">
|
|
713
|
-
<Popover
|
|
925
|
+
<Popover
|
|
926
|
+
open={isOpen}
|
|
927
|
+
onOpenChange={(o) => {
|
|
928
|
+
if (!dependenciesMissing) setIsOpen(o);
|
|
929
|
+
}}
|
|
930
|
+
>
|
|
714
931
|
<PopoverTrigger asChild>
|
|
715
|
-
|
|
716
|
-
variant="outline"
|
|
717
|
-
className={cn(
|
|
718
|
-
'min-w-0 flex-1 justify-start text-left font-normal',
|
|
719
|
-
compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
|
|
720
|
-
)}
|
|
721
|
-
type="button"
|
|
722
|
-
aria-haspopup="listbox"
|
|
723
|
-
aria-expanded={isOpen}
|
|
724
|
-
aria-controls={listboxId}
|
|
725
|
-
disabled={dependenciesMissing || (props as any).disabled}
|
|
726
|
-
data-testid={dependenciesMissing ? 'lookup-trigger-gated' : (((props as any).name || lookupField?.name) ? `lookup-trigger-${(props as any).name || lookupField.name}` : 'lookup-trigger')}
|
|
727
|
-
title={dependenciesMissing
|
|
728
|
-
? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
|
|
729
|
-
: undefined}
|
|
730
|
-
>
|
|
731
|
-
<Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
|
|
732
|
-
<span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
|
|
733
|
-
{dependenciesMissing
|
|
734
|
-
? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
|
|
735
|
-
: compact && !multiple && selectedOptions.length > 0
|
|
736
|
-
? singleSelectedLabel
|
|
737
|
-
: selectedOptions.length === 0
|
|
738
|
-
? lookupField?.placeholder || t('common.select')
|
|
739
|
-
: multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')
|
|
740
|
-
}
|
|
741
|
-
</span>
|
|
742
|
-
</Button>
|
|
932
|
+
{triggerButton}
|
|
743
933
|
</PopoverTrigger>
|
|
744
934
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
745
935
|
{/* Search input */}
|
|
@@ -775,7 +965,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
775
965
|
<Button
|
|
776
966
|
variant="outline"
|
|
777
967
|
size="sm"
|
|
778
|
-
onClick={() =>
|
|
968
|
+
onClick={() => popoverQuery.refetch()}
|
|
779
969
|
type="button"
|
|
780
970
|
>
|
|
781
971
|
{t('lookup.retry')}
|
|
@@ -818,8 +1008,8 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
818
1008
|
<>
|
|
819
1009
|
{visibleOptions.map((option, idx) => {
|
|
820
1010
|
const isSelected = multiple
|
|
821
|
-
? (Array.isArray(value) ? value : []).includes(option.value)
|
|
822
|
-
: value === option.value;
|
|
1011
|
+
? (Array.isArray(value) ? value : []).map(normalizeId).includes(option.value)
|
|
1012
|
+
: normalizeId(value) === option.value;
|
|
823
1013
|
const isActive = idx === activeIndex;
|
|
824
1014
|
const showRecentHeader = recentCount > 0 && idx === 0;
|
|
825
1015
|
const showResultsHeader = recentCount > 0 && idx === recentCount;
|
|
@@ -907,25 +1097,32 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
907
1097
|
</PopoverContent>
|
|
908
1098
|
</Popover>
|
|
909
1099
|
|
|
910
|
-
{/* "Browse All" button —
|
|
911
|
-
|
|
1100
|
+
{/* "Browse All" button — classic lookups only; search fields open the
|
|
1101
|
+
PeoplePicker from the trigger itself, so this would be redundant. */}
|
|
1102
|
+
{hasDataSource && pickerVariant !== 'search' && (
|
|
912
1103
|
<Button
|
|
913
1104
|
variant="outline"
|
|
914
1105
|
size="icon"
|
|
915
1106
|
className="shrink-0"
|
|
916
1107
|
type="button"
|
|
1108
|
+
// Gated exactly like the main trigger (#2215) — pre-fix this button
|
|
1109
|
+
// opened the full unscoped table while the dependency was missing.
|
|
1110
|
+
disabled={dependenciesMissing || (props as any).disabled}
|
|
917
1111
|
onClick={() => setIsPickerOpen(true)}
|
|
918
1112
|
aria-label={t('lookup.browseAll')}
|
|
919
|
-
title={
|
|
1113
|
+
title={dependenciesMissing
|
|
1114
|
+
? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
|
|
1115
|
+
: t('lookup.browseAll')}
|
|
920
1116
|
data-testid="browse-all-records"
|
|
921
1117
|
>
|
|
922
1118
|
<TableProperties className="size-4" />
|
|
923
1119
|
</Button>
|
|
924
1120
|
)}
|
|
925
1121
|
</div>
|
|
1122
|
+
)}
|
|
926
1123
|
|
|
927
|
-
{/* Level 2:
|
|
928
|
-
{hasDataSource && dataSource && referenceTo && (
|
|
1124
|
+
{/* Level 2: classic table picker — search fields use the inline combobox above. */}
|
|
1125
|
+
{hasDataSource && dataSource && referenceTo && pickerVariant !== 'search' && (
|
|
929
1126
|
<RecordPickerDialog
|
|
930
1127
|
open={isPickerOpen}
|
|
931
1128
|
onOpenChange={setIsPickerOpen}
|
|
@@ -942,6 +1139,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
942
1139
|
onSelect={onChange}
|
|
943
1140
|
onSelectRecords={handlePickerSelectRecords}
|
|
944
1141
|
lookupFilters={lookupFilters}
|
|
1142
|
+
baseFilter={dependentFilter}
|
|
945
1143
|
cellRenderer={getCellRendererResolver()}
|
|
946
1144
|
filterColumns={filterColumns}
|
|
947
1145
|
/>
|