@object-ui/fields 11.3.0 → 11.5.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 +155 -0
- package/dist/FieldEditWidget.d.ts +8 -0
- package/dist/FieldEditWidget.test.d.ts +8 -0
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -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 +17 -2
- package/dist/index.js +2015 -1346
- 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 +7 -7
- package/src/FieldEditWidget.test.ts +61 -0
- package/src/FieldEditWidget.tsx +56 -1
- package/src/__tests__/DateCellRenderer.test.tsx +50 -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 +61 -18
- 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 +365 -190
- 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,11 +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';
|
|
15
20
|
import { getRecordDisplayName } from '@object-ui/core';
|
|
16
21
|
import { getRecentLookupIds, pushRecentLookupId } from './recentLookups';
|
|
22
|
+
import { getPersonInitials } from './personDisplay';
|
|
17
23
|
import { getCellRendererResolver } from './_cell-renderer-bridge';
|
|
18
|
-
import { SchemaRendererContext as ImportedSchemaRendererContext } from '@object-ui/react';
|
|
24
|
+
import { SchemaRendererContext as ImportedSchemaRendererContext, useAction, useHasActionProvider } from '@object-ui/react';
|
|
19
25
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
20
26
|
|
|
21
27
|
export interface LookupOption {
|
|
@@ -34,6 +40,22 @@ const LOOKUP_PAGE_SIZE = 50;
|
|
|
34
40
|
*/
|
|
35
41
|
const SchemaRendererContext: React.Context<any> = ImportedSchemaRendererContext;
|
|
36
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
|
+
|
|
37
59
|
/**
|
|
38
60
|
* Render a record title from a `titleFormat` template (e.g. `{full_name}` or
|
|
39
61
|
* `{case_number} - {subject}`). When a templated key resolves to an empty
|
|
@@ -90,7 +112,7 @@ function recordToOption(
|
|
|
90
112
|
titleFormat?: string | null,
|
|
91
113
|
objectDef?: any,
|
|
92
114
|
): LookupOption {
|
|
93
|
-
const val = record[idField] ?? record.id ?? record._id;
|
|
115
|
+
const val = record[idField] ?? record.id ?? record._id ?? record.externalId;
|
|
94
116
|
const templated = titleFormat ? formatRecordTitle(record, titleFormat) : null;
|
|
95
117
|
|
|
96
118
|
// Object-level resolver fallback (displayNameField + derivation), excluding
|
|
@@ -114,11 +136,31 @@ function recordToOption(
|
|
|
114
136
|
record.full_name ??
|
|
115
137
|
record.title ??
|
|
116
138
|
record.subject ??
|
|
139
|
+
record.externalId ??
|
|
117
140
|
String(val);
|
|
118
141
|
const description = descriptionField ? record[descriptionField] : undefined;
|
|
119
142
|
return { value: val, label: String(label), description, ...record };
|
|
120
143
|
}
|
|
121
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
|
+
|
|
122
164
|
/**
|
|
123
165
|
* Map a LookupColumnDef.type to a filter input type for the filter bar.
|
|
124
166
|
* Returns undefined if the field type is not filterable.
|
|
@@ -151,16 +193,12 @@ function mapFieldTypeToFilterType(
|
|
|
151
193
|
*/
|
|
152
194
|
export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
153
195
|
const [isOpen, setIsOpen] = useState(false);
|
|
154
|
-
const [searchQuery, setSearchQuery] = useState('');
|
|
155
196
|
const { t } = useFieldTranslation();
|
|
156
197
|
const listboxId = React.useId();
|
|
157
198
|
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
const [
|
|
161
|
-
const [error, setError] = useState<string | null>(null);
|
|
162
|
-
const [totalCount, setTotalCount] = useState(0);
|
|
163
|
-
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);
|
|
164
202
|
|
|
165
203
|
// Records selected via RecordPickerDialog (Level 2).
|
|
166
204
|
// Stored as LookupOption so that findOption can resolve display labels
|
|
@@ -190,15 +228,28 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
190
228
|
const idField = fieldMeta?.id_field || 'id';
|
|
191
229
|
// ObjectStack convention uses `reference`; types define `reference_to` — support both
|
|
192
230
|
const referenceTo: string | undefined = fieldMeta?.reference_to || fieldMeta?.reference;
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
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);
|
|
196
240
|
|
|
197
241
|
// Enterprise Record Picker configuration
|
|
198
242
|
const lookupColumns: Array<string | LookupColumnDef> | undefined = fieldMeta?.lookup_columns ?? fieldMeta?.lookupColumns;
|
|
199
243
|
const lookupPageSize: number | undefined = fieldMeta?.lookup_page_size ?? fieldMeta?.lookupPageSize;
|
|
200
244
|
const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookup_filters ?? fieldMeta?.lookupFilters;
|
|
201
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
|
+
|
|
202
253
|
/**
|
|
203
254
|
* Dependent lookups — restrict candidates based on values of *other* fields
|
|
204
255
|
* in the same form. Two shapes are accepted:
|
|
@@ -317,7 +368,54 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
317
368
|
// State for the full Record Picker dialog (Level 2)
|
|
318
369
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
|
319
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
|
+
|
|
320
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
|
+
|
|
321
419
|
const allOptions = hasDataSource ? fetchedOptions : staticOptions;
|
|
322
420
|
|
|
323
421
|
// For static options, filter locally based on search
|
|
@@ -336,124 +434,19 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
336
434
|
setActiveIndex(-1);
|
|
337
435
|
}, [filteredOptions.length]);
|
|
338
436
|
|
|
339
|
-
// Fetch
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (!dataSource || !referenceTo) return;
|
|
343
|
-
// Don't issue a request that ignores configured dependencies.
|
|
344
|
-
if (dependenciesMissing) {
|
|
345
|
-
setFetchedOptions([]);
|
|
346
|
-
setTotalCount(0);
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
setLoading(true);
|
|
351
|
-
setError(null);
|
|
352
|
-
|
|
353
|
-
try {
|
|
354
|
-
const params: QueryParams = {
|
|
355
|
-
$top: LOOKUP_PAGE_SIZE,
|
|
356
|
-
};
|
|
357
|
-
if (search && search.trim()) {
|
|
358
|
-
params.$search = search.trim();
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Build a dependent-lookup filter chain: AND of `param eq value`.
|
|
362
|
-
// QueryParams.$filter is a Record<string, any> — adapters convert to
|
|
363
|
-
// the underlying query language (OData, ObjectQL, etc).
|
|
364
|
-
if (dependsOn.length > 0) {
|
|
365
|
-
const filterEntries: Record<string, any> = {};
|
|
366
|
-
for (const { field, param } of dependsOn) {
|
|
367
|
-
const v = resolvedDependentValues[field];
|
|
368
|
-
if (v === undefined || v === null || v === '') continue;
|
|
369
|
-
filterEntries[param] = typeof v === 'number' ? v : String(v);
|
|
370
|
-
}
|
|
371
|
-
if (Object.keys(filterEntries).length > 0) {
|
|
372
|
-
params.$filter = filterEntries;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Apply base scoping filters so the quick-select popover matches the
|
|
377
|
-
// full picker dialog (lookupFilters were previously dialog-only).
|
|
378
|
-
if (lookupFilters && lookupFilters.length > 0) {
|
|
379
|
-
params.$filter = { ...(params.$filter ?? {}), ...lookupFiltersToRecord(lookupFilters) };
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const result = await dataSource.find(referenceTo, params);
|
|
383
|
-
const records: any[] = result?.data ?? result ?? [];
|
|
384
|
-
const mapped = records.map(r => recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
|
|
385
|
-
|
|
386
|
-
setFetchedOptions(mapped);
|
|
387
|
-
setTotalCount(result?.total ?? records.length);
|
|
388
|
-
} catch (err) {
|
|
389
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
390
|
-
setError(msg);
|
|
391
|
-
setFetchedOptions([]);
|
|
392
|
-
} finally {
|
|
393
|
-
setLoading(false);
|
|
394
|
-
}
|
|
395
|
-
},
|
|
396
|
-
[dataSource, referenceTo, displayField, idField, effectiveDescriptionField, refTitleFormat, dependenciesMissing, dependsOn, resolvedDependentValues, lookupFilters],
|
|
397
|
-
);
|
|
398
|
-
|
|
399
|
-
// Re-fetch when dependent values change while the picker is open. This keeps
|
|
400
|
-
// a "City" dropdown reactive when the user changes "Country" without closing.
|
|
401
|
-
const dependencySignature = useMemo(
|
|
402
|
-
() => dependsOn.map(d => `${d.param}=${resolvedDependentValues[d.field] ?? ''}`).join('|'),
|
|
403
|
-
[dependsOn, resolvedDependentValues],
|
|
404
|
-
);
|
|
405
|
-
useEffect(() => {
|
|
406
|
-
if (isOpen && hasDataSource && dependsOn.length > 0) {
|
|
407
|
-
fetchLookupData(searchQuery || undefined);
|
|
408
|
-
// Clear local selection if it no longer satisfies the new filter chain —
|
|
409
|
-
// out of scope here; consumer should validate on submit.
|
|
410
|
-
}
|
|
411
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
412
|
-
}, [dependencySignature]);
|
|
413
|
-
|
|
414
|
-
// Fetch data when dialog opens.
|
|
415
|
-
// We intentionally depend only on `isOpen` so the effect fires once per
|
|
416
|
-
// open/close transition. `fetchLookupData` is stable-enough via its own
|
|
417
|
-
// 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.
|
|
418
440
|
useEffect(() => {
|
|
419
|
-
if (isOpen
|
|
420
|
-
fetchLookupData(searchQuery || undefined);
|
|
421
|
-
}
|
|
422
|
-
// Clean up fetched data when dialog closes
|
|
423
|
-
if (!isOpen) {
|
|
424
|
-
setSearchQuery('');
|
|
425
|
-
setError(null);
|
|
426
|
-
setActiveIndex(-1);
|
|
427
|
-
}
|
|
428
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
441
|
+
if (!isOpen) setActiveIndex(-1);
|
|
429
442
|
}, [isOpen]);
|
|
430
443
|
|
|
431
|
-
//
|
|
444
|
+
// Search is the kernel's debounced setter.
|
|
432
445
|
const handleSearchChange = useCallback(
|
|
433
|
-
(query: string) =>
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
if (!hasDataSource) return;
|
|
437
|
-
|
|
438
|
-
if (debounceTimer.current) {
|
|
439
|
-
clearTimeout(debounceTimer.current);
|
|
440
|
-
}
|
|
441
|
-
debounceTimer.current = setTimeout(() => {
|
|
442
|
-
fetchLookupData(query || undefined);
|
|
443
|
-
}, 300);
|
|
444
|
-
},
|
|
445
|
-
[hasDataSource, fetchLookupData],
|
|
446
|
+
(query: string) => popoverQuery.setSearch(query),
|
|
447
|
+
[popoverQuery.setSearch],
|
|
446
448
|
);
|
|
447
449
|
|
|
448
|
-
// Clean up debounce timer
|
|
449
|
-
useEffect(() => {
|
|
450
|
-
return () => {
|
|
451
|
-
if (debounceTimer.current) {
|
|
452
|
-
clearTimeout(debounceTimer.current);
|
|
453
|
-
}
|
|
454
|
-
};
|
|
455
|
-
}, []);
|
|
456
|
-
|
|
457
450
|
/**
|
|
458
451
|
* Hydrate the picker's display when the field already has a value (e.g.
|
|
459
452
|
* edit-mode load, prefill via query-string from a related-list "+ New")
|
|
@@ -463,9 +456,17 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
463
456
|
*/
|
|
464
457
|
useEffect(() => {
|
|
465
458
|
if (!hasDataSource || !dataSource || !referenceTo) return;
|
|
466
|
-
const
|
|
459
|
+
const raw: any[] = multiple
|
|
467
460
|
? Array.isArray(value) ? value : []
|
|
468
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
|
+
);
|
|
469
470
|
if (!ids.length) return;
|
|
470
471
|
// Only fetch records we haven't resolved yet.
|
|
471
472
|
const unresolved = ids.filter((v) => !findOption(v));
|
|
@@ -517,9 +518,57 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
517
518
|
[staticOptions, fetchedOptions, pickerResolvedRecords],
|
|
518
519
|
);
|
|
519
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
|
+
|
|
520
569
|
const selectedOptions = multiple
|
|
521
|
-
? (Array.isArray(value) ? value : []).map(
|
|
522
|
-
: value ? [
|
|
570
|
+
? (Array.isArray(value) ? value : []).map(resolveSelectedOption).filter(Boolean)
|
|
571
|
+
: value ? [resolveSelectedOption(value)].filter(Boolean) : [];
|
|
523
572
|
|
|
524
573
|
// Optional: receive the FULL selected record (not just its id) so a host can
|
|
525
574
|
// auto-fill sibling fields from it — e.g. a line-item grid copying a product's
|
|
@@ -529,8 +578,22 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
529
578
|
|
|
530
579
|
const handleSelect = useCallback(
|
|
531
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
|
+
}
|
|
532
593
|
if (multiple) {
|
|
533
|
-
|
|
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);
|
|
534
597
|
const isSelected = currentValues.includes(option.value);
|
|
535
598
|
|
|
536
599
|
if (isSelected) {
|
|
@@ -546,12 +609,12 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
546
609
|
setIsOpen(false);
|
|
547
610
|
}
|
|
548
611
|
},
|
|
549
|
-
[multiple, value, onChange, onSelectRecord, referenceTo],
|
|
612
|
+
[multiple, value, onChange, onSelectRecord, referenceTo, normalizeId],
|
|
550
613
|
);
|
|
551
614
|
|
|
552
615
|
const handleRemove = (optionValue: any) => {
|
|
553
616
|
if (multiple) {
|
|
554
|
-
const currentValues = Array.isArray(value) ? value : [];
|
|
617
|
+
const currentValues = (Array.isArray(value) ? value : []).map(normalizeId);
|
|
555
618
|
onChange(currentValues.filter((v: any) => v !== optionValue));
|
|
556
619
|
} else {
|
|
557
620
|
onChange(null);
|
|
@@ -605,26 +668,72 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
605
668
|
useEffect(() => { setActiveIndex(-1); }, [visibleOptions.length]);
|
|
606
669
|
|
|
607
670
|
const [creating, setCreating] = useState(false);
|
|
608
|
-
|
|
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));
|
|
609
677
|
const handleCreateNew = useCallback(
|
|
610
678
|
async (q: string) => {
|
|
611
679
|
const label = (q || '').trim();
|
|
612
680
|
if (onCreateNew) { onCreateNew(label); setIsOpen(false); return; }
|
|
613
|
-
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;
|
|
614
724
|
setCreating(true);
|
|
615
|
-
setError(null);
|
|
616
725
|
try {
|
|
617
726
|
const created = await (dataSource as any).create(referenceTo, { [displayField]: label });
|
|
618
727
|
const opt = recordToOption(created, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema);
|
|
619
728
|
setPickerResolvedRecords((prev) => [opt, ...prev.filter((o) => o.value !== opt.value)]);
|
|
620
729
|
handleSelect(opt);
|
|
621
730
|
} catch (err) {
|
|
622
|
-
|
|
731
|
+
setCreateError(err instanceof Error ? err.message : String(err));
|
|
623
732
|
} finally {
|
|
624
733
|
setCreating(false);
|
|
625
734
|
}
|
|
626
735
|
},
|
|
627
|
-
[onCreateNew, allowCreate,
|
|
736
|
+
[onCreateNew, allowCreate, referenceTo, hasActionProvider, execute, dataSource, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema, handleSelect],
|
|
628
737
|
);
|
|
629
738
|
|
|
630
739
|
// Compact one-line preview of an option's extra (non-display) columns —
|
|
@@ -706,63 +815,121 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
706
815
|
const compact = !!(props as any).compact;
|
|
707
816
|
const singleSelectedLabel = selectedOptions[0]?.label || selectedOptions[0]?.[displayField];
|
|
708
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
|
+
|
|
709
848
|
return (
|
|
710
849
|
<div className={compact ? '' : 'space-y-2'}>
|
|
711
850
|
{/* Selected values display (full mode only — compact shows it in-trigger) */}
|
|
712
851
|
{selectedOptions.length > 0 && !compact && (
|
|
713
852
|
<div className="flex flex-wrap gap-1">
|
|
714
|
-
{selectedOptions.map((opt, idx) =>
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
+
})}
|
|
731
897
|
</div>
|
|
732
898
|
)}
|
|
733
899
|
|
|
734
|
-
{/*
|
|
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
|
+
) : (
|
|
735
924
|
<div className="flex items-center gap-1.5">
|
|
736
|
-
<Popover
|
|
925
|
+
<Popover
|
|
926
|
+
open={isOpen}
|
|
927
|
+
onOpenChange={(o) => {
|
|
928
|
+
if (!dependenciesMissing) setIsOpen(o);
|
|
929
|
+
}}
|
|
930
|
+
>
|
|
737
931
|
<PopoverTrigger asChild>
|
|
738
|
-
|
|
739
|
-
variant="outline"
|
|
740
|
-
className={cn(
|
|
741
|
-
'min-w-0 flex-1 justify-start text-left font-normal',
|
|
742
|
-
compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
|
|
743
|
-
)}
|
|
744
|
-
type="button"
|
|
745
|
-
aria-haspopup="listbox"
|
|
746
|
-
aria-expanded={isOpen}
|
|
747
|
-
aria-controls={listboxId}
|
|
748
|
-
disabled={dependenciesMissing || (props as any).disabled}
|
|
749
|
-
data-testid={dependenciesMissing ? 'lookup-trigger-gated' : (((props as any).name || lookupField?.name) ? `lookup-trigger-${(props as any).name || lookupField.name}` : 'lookup-trigger')}
|
|
750
|
-
title={dependenciesMissing
|
|
751
|
-
? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
|
|
752
|
-
: undefined}
|
|
753
|
-
>
|
|
754
|
-
<Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
|
|
755
|
-
<span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
|
|
756
|
-
{dependenciesMissing
|
|
757
|
-
? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
|
|
758
|
-
: compact && !multiple && selectedOptions.length > 0
|
|
759
|
-
? singleSelectedLabel
|
|
760
|
-
: selectedOptions.length === 0
|
|
761
|
-
? lookupField?.placeholder || t('common.select')
|
|
762
|
-
: multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')
|
|
763
|
-
}
|
|
764
|
-
</span>
|
|
765
|
-
</Button>
|
|
932
|
+
{triggerButton}
|
|
766
933
|
</PopoverTrigger>
|
|
767
934
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
768
935
|
{/* Search input */}
|
|
@@ -798,7 +965,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
798
965
|
<Button
|
|
799
966
|
variant="outline"
|
|
800
967
|
size="sm"
|
|
801
|
-
onClick={() =>
|
|
968
|
+
onClick={() => popoverQuery.refetch()}
|
|
802
969
|
type="button"
|
|
803
970
|
>
|
|
804
971
|
{t('lookup.retry')}
|
|
@@ -841,8 +1008,8 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
841
1008
|
<>
|
|
842
1009
|
{visibleOptions.map((option, idx) => {
|
|
843
1010
|
const isSelected = multiple
|
|
844
|
-
? (Array.isArray(value) ? value : []).includes(option.value)
|
|
845
|
-
: value === option.value;
|
|
1011
|
+
? (Array.isArray(value) ? value : []).map(normalizeId).includes(option.value)
|
|
1012
|
+
: normalizeId(value) === option.value;
|
|
846
1013
|
const isActive = idx === activeIndex;
|
|
847
1014
|
const showRecentHeader = recentCount > 0 && idx === 0;
|
|
848
1015
|
const showResultsHeader = recentCount > 0 && idx === recentCount;
|
|
@@ -930,25 +1097,32 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
930
1097
|
</PopoverContent>
|
|
931
1098
|
</Popover>
|
|
932
1099
|
|
|
933
|
-
{/* "Browse All" button —
|
|
934
|
-
|
|
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' && (
|
|
935
1103
|
<Button
|
|
936
1104
|
variant="outline"
|
|
937
1105
|
size="icon"
|
|
938
1106
|
className="shrink-0"
|
|
939
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}
|
|
940
1111
|
onClick={() => setIsPickerOpen(true)}
|
|
941
1112
|
aria-label={t('lookup.browseAll')}
|
|
942
|
-
title={
|
|
1113
|
+
title={dependenciesMissing
|
|
1114
|
+
? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
|
|
1115
|
+
: t('lookup.browseAll')}
|
|
943
1116
|
data-testid="browse-all-records"
|
|
944
1117
|
>
|
|
945
1118
|
<TableProperties className="size-4" />
|
|
946
1119
|
</Button>
|
|
947
1120
|
)}
|
|
948
1121
|
</div>
|
|
1122
|
+
)}
|
|
949
1123
|
|
|
950
|
-
{/* Level 2:
|
|
951
|
-
{hasDataSource && dataSource && referenceTo && (
|
|
1124
|
+
{/* Level 2: classic table picker — search fields use the inline combobox above. */}
|
|
1125
|
+
{hasDataSource && dataSource && referenceTo && pickerVariant !== 'search' && (
|
|
952
1126
|
<RecordPickerDialog
|
|
953
1127
|
open={isPickerOpen}
|
|
954
1128
|
onOpenChange={setIsPickerOpen}
|
|
@@ -965,6 +1139,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
965
1139
|
onSelect={onChange}
|
|
966
1140
|
onSelectRecords={handlePickerSelectRecords}
|
|
967
1141
|
lookupFilters={lookupFilters}
|
|
1142
|
+
baseFilter={dependentFilter}
|
|
968
1143
|
cellRenderer={getCellRendererResolver()}
|
|
969
1144
|
filterColumns={filterColumns}
|
|
970
1145
|
/>
|