@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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { useEffect, useRef } from 'react';
|
|
10
|
+
import { Avatar, AvatarFallback, AvatarImage, cn } from '@object-ui/components';
|
|
11
|
+
import { Check } from 'lucide-react';
|
|
12
|
+
import {
|
|
13
|
+
getPersonName,
|
|
14
|
+
getPersonInitials,
|
|
15
|
+
getPersonSubtitle,
|
|
16
|
+
getPersonAvatarUrl,
|
|
17
|
+
matchRanges,
|
|
18
|
+
} from './personDisplay';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A rich, single-line candidate row for the search-first PeoplePicker:
|
|
22
|
+
* avatar (image with initials fallback) + name + subtitle (department · email).
|
|
23
|
+
* The subtitle is what lets search stand in for an org tree — it disambiguates
|
|
24
|
+
* same-named people inline. The typed query is highlighted in both lines.
|
|
25
|
+
* Selectable/toggleable; a Check marks the selected state, and `active` reflects
|
|
26
|
+
* the keyboard cursor (scrolls itself into view).
|
|
27
|
+
*/
|
|
28
|
+
export interface PersonRowProps {
|
|
29
|
+
record: any;
|
|
30
|
+
displayField?: string;
|
|
31
|
+
subtitleFields?: string[];
|
|
32
|
+
avatarField?: string;
|
|
33
|
+
selected?: boolean;
|
|
34
|
+
/** Keyboard cursor is on this row — highlight + scroll into view. */
|
|
35
|
+
active?: boolean;
|
|
36
|
+
/** Typed search term; matches are highlighted in name + subtitle. */
|
|
37
|
+
highlightQuery?: string;
|
|
38
|
+
onSelect?: (record: any) => void;
|
|
39
|
+
className?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Wrap literal matches of `query` in a subtle highlight. */
|
|
43
|
+
function Highlighted({ text, query }: { text: string; query?: string }): React.ReactElement {
|
|
44
|
+
const ranges = query ? matchRanges(text, query) : [];
|
|
45
|
+
if (ranges.length === 0) return <>{text}</>;
|
|
46
|
+
const out: React.ReactNode[] = [];
|
|
47
|
+
let cursor = 0;
|
|
48
|
+
ranges.forEach(([start, end], i) => {
|
|
49
|
+
if (start > cursor) out.push(text.slice(cursor, start));
|
|
50
|
+
out.push(
|
|
51
|
+
<mark key={i} className="rounded-[2px] bg-primary/20 px-px text-inherit">
|
|
52
|
+
{text.slice(start, end)}
|
|
53
|
+
</mark>,
|
|
54
|
+
);
|
|
55
|
+
cursor = end;
|
|
56
|
+
});
|
|
57
|
+
if (cursor < text.length) out.push(text.slice(cursor));
|
|
58
|
+
return <>{out}</>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function PersonRow({
|
|
62
|
+
record,
|
|
63
|
+
displayField = 'name',
|
|
64
|
+
subtitleFields,
|
|
65
|
+
avatarField = 'image',
|
|
66
|
+
selected = false,
|
|
67
|
+
active = false,
|
|
68
|
+
highlightQuery,
|
|
69
|
+
onSelect,
|
|
70
|
+
className,
|
|
71
|
+
}: PersonRowProps) {
|
|
72
|
+
const name = getPersonName(record, displayField);
|
|
73
|
+
const subtitle = getPersonSubtitle(record, subtitleFields);
|
|
74
|
+
const avatarUrl = getPersonAvatarUrl(record, avatarField);
|
|
75
|
+
const initials = getPersonInitials(name);
|
|
76
|
+
|
|
77
|
+
const ref = useRef<HTMLButtonElement>(null);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
// Optional-call the method too — jsdom doesn't implement scrollIntoView.
|
|
80
|
+
if (active) ref.current?.scrollIntoView?.({ block: 'nearest' });
|
|
81
|
+
}, [active]);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<button
|
|
85
|
+
ref={ref}
|
|
86
|
+
type="button"
|
|
87
|
+
tabIndex={-1}
|
|
88
|
+
onClick={() => onSelect?.(record)}
|
|
89
|
+
aria-pressed={selected}
|
|
90
|
+
data-testid="person-row"
|
|
91
|
+
data-active={active || undefined}
|
|
92
|
+
className={cn(
|
|
93
|
+
'flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left transition-colors',
|
|
94
|
+
'hover:bg-accent focus-visible:outline-none',
|
|
95
|
+
selected && 'bg-accent',
|
|
96
|
+
active && 'bg-accent ring-1 ring-inset ring-ring',
|
|
97
|
+
className,
|
|
98
|
+
)}
|
|
99
|
+
>
|
|
100
|
+
<Avatar className="size-9 shrink-0">
|
|
101
|
+
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
|
|
102
|
+
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
|
|
103
|
+
</Avatar>
|
|
104
|
+
<div className="min-w-0 flex-1">
|
|
105
|
+
<div className="truncate text-sm font-medium">
|
|
106
|
+
{name ? <Highlighted text={name} query={highlightQuery} /> : '—'}
|
|
107
|
+
</div>
|
|
108
|
+
{subtitle && (
|
|
109
|
+
<div className="truncate text-xs text-muted-foreground">
|
|
110
|
+
<Highlighted text={subtitle} query={highlightQuery} />
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
{selected && <Check className="size-4 shrink-0 text-primary" aria-hidden />}
|
|
115
|
+
</button>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -37,8 +37,9 @@ import {
|
|
|
37
37
|
SlidersHorizontal,
|
|
38
38
|
X,
|
|
39
39
|
} from 'lucide-react';
|
|
40
|
-
import type { DataSource,
|
|
40
|
+
import type { DataSource, LookupColumnDef, LookupFilterDef } from '@object-ui/types';
|
|
41
41
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
42
|
+
import { useRecordQuery } from './useRecordQuery';
|
|
42
43
|
|
|
43
44
|
/** Default page size for the Record Picker dialog */
|
|
44
45
|
const DEFAULT_PAGE_SIZE = 10;
|
|
@@ -260,6 +261,15 @@ export interface RecordPickerDialogProps {
|
|
|
260
261
|
*/
|
|
261
262
|
lookupFilters?: LookupFilterDef[];
|
|
262
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Hard filter constraints applied to every query, in QueryParams.$filter
|
|
266
|
+
* record form. Unlike `lookupFilters`, entries here never surface in the
|
|
267
|
+
* filter bar and cannot be overridden by user filter input — used for the
|
|
268
|
+
* dependent (cascading) lookup chain, where the parent field's value MUST
|
|
269
|
+
* scope the candidate set (#2215).
|
|
270
|
+
*/
|
|
271
|
+
baseFilter?: Record<string, any>;
|
|
272
|
+
|
|
263
273
|
/**
|
|
264
274
|
* Cell renderer resolver function.
|
|
265
275
|
* When provided, columns with a `type` property will be rendered using the
|
|
@@ -332,23 +342,16 @@ export function RecordPickerDialog({
|
|
|
332
342
|
onSelect,
|
|
333
343
|
onSelectRecords,
|
|
334
344
|
lookupFilters,
|
|
345
|
+
baseFilter,
|
|
335
346
|
cellRenderer,
|
|
336
347
|
filterColumns,
|
|
337
348
|
renderFilterBar,
|
|
338
349
|
renderGrid,
|
|
339
350
|
}: RecordPickerDialogProps) {
|
|
340
351
|
const { t } = useFieldTranslation();
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const [totalCount, setTotalCount] = useState(0);
|
|
345
|
-
const [searchQuery, setSearchQuery] = useState('');
|
|
346
|
-
const [currentPage, setCurrentPage] = useState(1);
|
|
347
|
-
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
348
|
-
|
|
349
|
-
// Column sorting state
|
|
350
|
-
const [sortField, setSortField] = useState<string | null>(null);
|
|
351
|
-
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
|
352
|
+
|
|
353
|
+
// Query state (records/loading/error/total + page/search/sort) lives in the
|
|
354
|
+
// shared useRecordQuery kernel — instantiated after mergedFilter below.
|
|
352
355
|
|
|
353
356
|
// For multi-select, track pending selections before confirming
|
|
354
357
|
const [pendingSelection, setPendingSelection] = useState<Set<any>>(new Set());
|
|
@@ -422,79 +425,49 @@ export function RecordPickerDialog({
|
|
|
422
425
|
return undefined;
|
|
423
426
|
}, [filterColumns, lookupFilters]);
|
|
424
427
|
|
|
425
|
-
// Merge base lookup_filters with user filter bar values
|
|
428
|
+
// Merge base lookup_filters with user filter bar values. The hard
|
|
429
|
+
// `baseFilter` constraint (dependent-lookup chain, #2215) is spread LAST so
|
|
430
|
+
// user filter-bar input can never widen it back out.
|
|
426
431
|
const mergedFilter = useMemo<Record<string, any> | undefined>(() => {
|
|
427
|
-
const
|
|
432
|
+
const lookupBase = lookupFilters?.length
|
|
428
433
|
? lookupFiltersToRecord(lookupFilters)
|
|
429
434
|
: {};
|
|
430
435
|
const userFilter = effectiveFilterColumns?.length
|
|
431
436
|
? filterValuesToRecord(filterValues, effectiveFilterColumns)
|
|
432
437
|
: {};
|
|
433
|
-
const combined = { ...
|
|
438
|
+
const combined = { ...lookupBase, ...userFilter, ...(baseFilter ?? {}) };
|
|
434
439
|
return Object.keys(combined).length > 0 ? combined : undefined;
|
|
435
|
-
}, [lookupFilters, effectiveFilterColumns, filterValues]);
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const result = await dataSource.find(objectName, params);
|
|
465
|
-
const data: any[] = result?.data ?? result ?? [];
|
|
466
|
-
|
|
467
|
-
setRecords(data);
|
|
468
|
-
setTotalCount(result?.total ?? data.length);
|
|
469
|
-
setFocusedRow(-1);
|
|
470
|
-
} catch (err) {
|
|
471
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
472
|
-
setError(msg);
|
|
473
|
-
setRecords([]);
|
|
474
|
-
} finally {
|
|
475
|
-
setLoading(false);
|
|
476
|
-
}
|
|
477
|
-
},
|
|
478
|
-
[dataSource, objectName, pageSize, mergedFilter],
|
|
479
|
-
);
|
|
480
|
-
|
|
481
|
-
// Build current sort object for passing to fetchRecords
|
|
482
|
-
const currentSort = useMemo(
|
|
483
|
-
() => sortField ? { field: sortField, direction: sortDirection } : null,
|
|
484
|
-
[sortField, sortDirection],
|
|
485
|
-
);
|
|
486
|
-
|
|
487
|
-
// Reset state when dialog closes — separate from the fetch effect so that
|
|
488
|
-
// resetting currentPage / sortField (which are fetch-effect deps) does not
|
|
489
|
-
// re-trigger the fetch effect and cause cascading re-renders (React #185).
|
|
440
|
+
}, [lookupFilters, effectiveFilterColumns, filterValues, baseFilter]);
|
|
441
|
+
|
|
442
|
+
// Shared query kernel: builds params, fetches, and owns records/loading/error/
|
|
443
|
+
// total plus the page/search/sort controls. Selection state stays local (above).
|
|
444
|
+
const query = useRecordQuery({
|
|
445
|
+
dataSource,
|
|
446
|
+
objectName,
|
|
447
|
+
enabled: open,
|
|
448
|
+
pageSize,
|
|
449
|
+
paginate: true,
|
|
450
|
+
filter: mergedFilter,
|
|
451
|
+
});
|
|
452
|
+
// Preserve the previous local names so the handlers and render below are
|
|
453
|
+
// unchanged (the migration is a pure refactor).
|
|
454
|
+
const { records, loading, error } = query;
|
|
455
|
+
const totalCount = query.total;
|
|
456
|
+
const totalPages = query.totalPages;
|
|
457
|
+
const searchQuery = query.search;
|
|
458
|
+
const currentPage = query.page;
|
|
459
|
+
const sortField = query.sort?.field ?? null;
|
|
460
|
+
const sortDirection: 'asc' | 'desc' = query.sort?.direction ?? 'asc';
|
|
461
|
+
const setCurrentPage = query.setPage;
|
|
462
|
+
const handleSort = query.toggleSort;
|
|
463
|
+
const handleSearchChange = query.setSearch;
|
|
464
|
+
|
|
465
|
+
// Reset non-query UI/selection state when the dialog closes. Query state
|
|
466
|
+
// (search/page/sort/records) is cleared by useRecordQuery when `enabled`
|
|
467
|
+
// (== open) goes false, kept separate so resets never cascade into a fetch
|
|
468
|
+
// (React #185).
|
|
490
469
|
useEffect(() => {
|
|
491
470
|
if (!open) {
|
|
492
|
-
setSearchQuery('');
|
|
493
|
-
setCurrentPage(1);
|
|
494
|
-
setError(null);
|
|
495
|
-
setRecords([]);
|
|
496
|
-
setSortField(null);
|
|
497
|
-
setSortDirection('asc');
|
|
498
471
|
setFocusedRow(-1);
|
|
499
472
|
setFilterBarOpen(false);
|
|
500
473
|
setFilterValues({});
|
|
@@ -510,17 +483,6 @@ export function RecordPickerDialog({
|
|
|
510
483
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
511
484
|
}, [open]);
|
|
512
485
|
|
|
513
|
-
// Fetch when dialog is open and pagination / sort / filter deps change
|
|
514
|
-
useEffect(() => {
|
|
515
|
-
if (open) {
|
|
516
|
-
fetchRecords(searchQuery || undefined, currentPage, currentSort);
|
|
517
|
-
}
|
|
518
|
-
// `fetchRecords` and `searchQuery` are intentionally excluded:
|
|
519
|
-
// fetchRecords is stable (useCallback), searchQuery triggers its own
|
|
520
|
-
// debounced fetch in handleSearchChange.
|
|
521
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
522
|
-
}, [open, currentPage, currentSort, mergedFilter]);
|
|
523
|
-
|
|
524
486
|
// Initialize pending selection when dialog opens
|
|
525
487
|
useEffect(() => {
|
|
526
488
|
if (open && multiple) {
|
|
@@ -528,44 +490,11 @@ export function RecordPickerDialog({
|
|
|
528
490
|
}
|
|
529
491
|
}, [open, multiple, value]);
|
|
530
492
|
|
|
531
|
-
//
|
|
532
|
-
|
|
533
|
-
(query: string) => {
|
|
534
|
-
setSearchQuery(query);
|
|
535
|
-
setCurrentPage(1);
|
|
536
|
-
|
|
537
|
-
if (debounceTimer.current) {
|
|
538
|
-
clearTimeout(debounceTimer.current);
|
|
539
|
-
}
|
|
540
|
-
debounceTimer.current = setTimeout(() => {
|
|
541
|
-
fetchRecords(query || undefined, 1, currentSort);
|
|
542
|
-
}, 300);
|
|
543
|
-
},
|
|
544
|
-
[fetchRecords, currentSort],
|
|
545
|
-
);
|
|
546
|
-
|
|
547
|
-
// Clean up debounce timer
|
|
493
|
+
// Reset keyboard focus whenever the result set changes (previously done
|
|
494
|
+
// inline at the end of fetchRecords).
|
|
548
495
|
useEffect(() => {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
clearTimeout(debounceTimer.current);
|
|
552
|
-
}
|
|
553
|
-
};
|
|
554
|
-
}, []);
|
|
555
|
-
|
|
556
|
-
// Column sort handler
|
|
557
|
-
const handleSort = useCallback((field: string) => {
|
|
558
|
-
setSortField(prev => {
|
|
559
|
-
if (prev === field) {
|
|
560
|
-
// Toggle direction
|
|
561
|
-
setSortDirection(d => d === 'asc' ? 'desc' : 'asc');
|
|
562
|
-
return field;
|
|
563
|
-
}
|
|
564
|
-
setSortDirection('asc');
|
|
565
|
-
return field;
|
|
566
|
-
});
|
|
567
|
-
setCurrentPage(1);
|
|
568
|
-
}, []);
|
|
496
|
+
setFocusedRow(-1);
|
|
497
|
+
}, [records]);
|
|
569
498
|
|
|
570
499
|
// Get record id
|
|
571
500
|
const getRecordId = useCallback(
|
|
@@ -626,12 +555,12 @@ export function RecordPickerDialog({
|
|
|
626
555
|
|
|
627
556
|
// Page navigation
|
|
628
557
|
const handlePrevPage = useCallback(() => {
|
|
629
|
-
setCurrentPage(
|
|
630
|
-
}, []);
|
|
558
|
+
setCurrentPage(Math.max(1, currentPage - 1));
|
|
559
|
+
}, [setCurrentPage, currentPage]);
|
|
631
560
|
|
|
632
561
|
const handleNextPage = useCallback(() => {
|
|
633
|
-
setCurrentPage(
|
|
634
|
-
}, [totalPages]);
|
|
562
|
+
setCurrentPage(Math.min(totalPages, currentPage + 1));
|
|
563
|
+
}, [setCurrentPage, currentPage, totalPages]);
|
|
635
564
|
|
|
636
565
|
// Page jump handler
|
|
637
566
|
const handlePageJump = useCallback(
|
|
@@ -978,7 +907,7 @@ export function RecordPickerDialog({
|
|
|
978
907
|
<Button
|
|
979
908
|
variant="outline"
|
|
980
909
|
size="sm"
|
|
981
|
-
onClick={() =>
|
|
910
|
+
onClick={() => query.refetch()}
|
|
982
911
|
type="button"
|
|
983
912
|
>
|
|
984
913
|
{t('lookup.retry')}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React from 'react';
|
|
10
|
+
import { Avatar, AvatarFallback, AvatarImage, cn } from '@object-ui/components';
|
|
11
|
+
import { X } from 'lucide-react';
|
|
12
|
+
import {
|
|
13
|
+
getPersonName,
|
|
14
|
+
getPersonInitials,
|
|
15
|
+
getPersonAvatarUrl,
|
|
16
|
+
getPersonId,
|
|
17
|
+
} from './personDisplay';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* "Selected" transfer-box area (穿梭框式已选区): live echo of the current
|
|
21
|
+
* multi-selection as removable avatar chips. Reusable kernel — the future
|
|
22
|
+
* org-tree tier composes the same tray beside its right-hand list. Purely
|
|
23
|
+
* presentational: it takes the resolved records + an onRemove callback, and
|
|
24
|
+
* accepts already-translated `label`/`emptyText` so it stays i18n-agnostic.
|
|
25
|
+
*/
|
|
26
|
+
export interface SelectionTrayProps {
|
|
27
|
+
/** Full record objects for the current selection. */
|
|
28
|
+
records: any[];
|
|
29
|
+
/** Remove a selected record by its id. */
|
|
30
|
+
onRemove: (id: any) => void;
|
|
31
|
+
displayField?: string;
|
|
32
|
+
avatarField?: string;
|
|
33
|
+
idField?: string;
|
|
34
|
+
/** Header label, e.g. "Selected (3)" — omit to hide the header. */
|
|
35
|
+
label?: string;
|
|
36
|
+
/** When provided, a "Clear all" action shows in the header while non-empty. */
|
|
37
|
+
onClear?: () => void;
|
|
38
|
+
/** Localized "Clear all" text (i18n-agnostic — caller passes it). */
|
|
39
|
+
clearLabel?: string;
|
|
40
|
+
/** Shown when the selection is empty. */
|
|
41
|
+
emptyText?: string;
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function SelectionTray({
|
|
46
|
+
records,
|
|
47
|
+
onRemove,
|
|
48
|
+
displayField = 'name',
|
|
49
|
+
avatarField = 'image',
|
|
50
|
+
idField = 'id',
|
|
51
|
+
label,
|
|
52
|
+
onClear,
|
|
53
|
+
clearLabel,
|
|
54
|
+
emptyText,
|
|
55
|
+
className,
|
|
56
|
+
}: SelectionTrayProps) {
|
|
57
|
+
const showHeader = !!label || (!!onClear && records.length > 0);
|
|
58
|
+
return (
|
|
59
|
+
<div className={cn('flex flex-col gap-1.5', className)} data-testid="selection-tray">
|
|
60
|
+
{showHeader && (
|
|
61
|
+
<div className="flex items-center justify-between">
|
|
62
|
+
{label && <div className="text-xs font-medium text-muted-foreground">{label}</div>}
|
|
63
|
+
{onClear && records.length > 0 && (
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
onClick={onClear}
|
|
67
|
+
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
68
|
+
data-testid="selection-clear"
|
|
69
|
+
>
|
|
70
|
+
{clearLabel ?? 'Clear all'}
|
|
71
|
+
</button>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
{records.length === 0 ? (
|
|
76
|
+
<div className="text-xs text-muted-foreground">{emptyText ?? ''}</div>
|
|
77
|
+
) : (
|
|
78
|
+
<div className="flex flex-wrap gap-1.5">
|
|
79
|
+
{records.map(record => {
|
|
80
|
+
const id = getPersonId(record, idField);
|
|
81
|
+
const name = getPersonName(record, displayField);
|
|
82
|
+
const avatarUrl = getPersonAvatarUrl(record, avatarField);
|
|
83
|
+
const initials = getPersonInitials(name);
|
|
84
|
+
return (
|
|
85
|
+
<span
|
|
86
|
+
key={String(id)}
|
|
87
|
+
data-testid="selection-chip"
|
|
88
|
+
className="inline-flex items-center gap-1.5 rounded-full border bg-background py-0.5 pl-0.5 pr-1.5 text-sm"
|
|
89
|
+
>
|
|
90
|
+
<Avatar className="size-6 shrink-0">
|
|
91
|
+
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
|
|
92
|
+
<AvatarFallback className="text-[10px]">{initials}</AvatarFallback>
|
|
93
|
+
</Avatar>
|
|
94
|
+
<span className="max-w-[10rem] truncate">{name || '—'}</span>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={() => onRemove(id)}
|
|
98
|
+
aria-label={`Remove ${name}`}
|
|
99
|
+
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
100
|
+
>
|
|
101
|
+
<X className="size-3.5" aria-hidden />
|
|
102
|
+
</button>
|
|
103
|
+
</span>
|
|
104
|
+
);
|
|
105
|
+
})}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -14,7 +14,21 @@ import { LookupField } from './LookupField';
|
|
|
14
14
|
*
|
|
15
15
|
* Table-cell display (avatars / initials) is handled separately by
|
|
16
16
|
* `UserCellRenderer`; this is the form/editor widget.
|
|
17
|
+
*
|
|
18
|
+
* By default the user picker is the search-first {@link PeoplePicker}
|
|
19
|
+
* (`picker: 'search'`) with a department·email subtitle, avatar, and banned
|
|
20
|
+
* users excluded from candidates. Authors can override any of these, or opt
|
|
21
|
+
* back to the classic table dialog with `picker: 'default'`.
|
|
17
22
|
*/
|
|
23
|
+
|
|
24
|
+
/** Exclude deactivated (`banned`) users unless the author already filters on it. */
|
|
25
|
+
function withBannedFilter(filters?: any[]): any[] {
|
|
26
|
+
const base = Array.isArray(filters) ? filters : [];
|
|
27
|
+
return base.some(f => f?.field === 'banned')
|
|
28
|
+
? base
|
|
29
|
+
: [...base, { field: 'banned', operator: 'ne', value: true }];
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
export function UserField(props: FieldWidgetProps<any>) {
|
|
19
33
|
const raw = (props.field || (props as any).schema) as any;
|
|
20
34
|
|
|
@@ -26,11 +40,16 @@ export function UserField(props: FieldWidgetProps<any>) {
|
|
|
26
40
|
const meta = metaIsNested ? raw.field : raw;
|
|
27
41
|
|
|
28
42
|
// Ensure the picker always targets sys_user (even if the author omitted an
|
|
29
|
-
// explicit reference)
|
|
43
|
+
// explicit reference), presents user names by default, and defaults to the
|
|
44
|
+
// search-first PeoplePicker with sensible person display + candidate hygiene.
|
|
30
45
|
const normalized = {
|
|
31
46
|
...(meta || {}),
|
|
32
47
|
reference: meta?.reference || meta?.reference_to || 'sys_user',
|
|
33
48
|
display_field: meta?.display_field || meta?.displayField || meta?.reference_field || 'name',
|
|
49
|
+
picker: meta?.picker ?? 'search',
|
|
50
|
+
subtitle: meta?.subtitle ?? ['primary_business_unit_id.name', 'email'],
|
|
51
|
+
avatar_field: meta?.avatar_field ?? meta?.avatarField ?? 'image',
|
|
52
|
+
lookup_filters: withBannedFilter(meta?.lookup_filters ?? meta?.lookupFilters),
|
|
34
53
|
};
|
|
35
54
|
|
|
36
55
|
const fieldProp = metaIsNested ? { ...raw, field: normalized } : normalized;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
10
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
11
|
+
import { UserField } from './UserField';
|
|
12
|
+
|
|
13
|
+
const USERS = [
|
|
14
|
+
{ id: 'u1', name: 'Amy Lin', email: 'amy@x.io', image: 'http://x/amy.png', primary_business_unit_id: { name: 'Sales' } },
|
|
15
|
+
{ id: 'u2', name: 'Bob Wu', email: 'bob@x.io', primary_business_unit_id: { name: 'Eng' } },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function makeDataSource() {
|
|
19
|
+
return {
|
|
20
|
+
find: vi.fn(async (_obj: string, params: any = {}) => {
|
|
21
|
+
const idIn = params?.$filter?.id?.$in as any[] | undefined;
|
|
22
|
+
if (idIn) {
|
|
23
|
+
const s = new Set(idIn.map(String));
|
|
24
|
+
const data = USERS.filter(u => s.has(String(u.id)));
|
|
25
|
+
return { data, total: data.length };
|
|
26
|
+
}
|
|
27
|
+
const search: string | undefined = params?.$search;
|
|
28
|
+
const data = search
|
|
29
|
+
? USERS.filter(u => u.name.toLowerCase().includes(search.toLowerCase()))
|
|
30
|
+
: USERS;
|
|
31
|
+
return { data, total: data.length };
|
|
32
|
+
}),
|
|
33
|
+
} as any;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 });
|
|
38
|
+
window.matchMedia = ((query: string) => ({
|
|
39
|
+
matches: window.innerWidth < 768,
|
|
40
|
+
media: query,
|
|
41
|
+
onchange: null,
|
|
42
|
+
addEventListener: () => {},
|
|
43
|
+
removeEventListener: () => {},
|
|
44
|
+
addListener: () => {},
|
|
45
|
+
removeListener: () => {},
|
|
46
|
+
dispatchEvent: () => false,
|
|
47
|
+
})) as any;
|
|
48
|
+
try {
|
|
49
|
+
window.localStorage.clear();
|
|
50
|
+
} catch {
|
|
51
|
+
/* ignore */
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('UserField — search-first entry (P0)', () => {
|
|
56
|
+
it('opens the PeoplePicker from the primary trigger (no separate browse-all button)', async () => {
|
|
57
|
+
const ds = makeDataSource();
|
|
58
|
+
render(<UserField field={{ type: 'user' } as any} dataSource={ds} value={null} onChange={vi.fn()} />);
|
|
59
|
+
|
|
60
|
+
// The redundant "browse all" icon is not rendered for search-first fields.
|
|
61
|
+
expect(screen.queryByTestId('browse-all-records')).toBeNull();
|
|
62
|
+
|
|
63
|
+
// Primary click opens the rich PeoplePicker (its search box appears).
|
|
64
|
+
fireEvent.click(screen.getByTestId('lookup-trigger'));
|
|
65
|
+
await waitFor(() => expect(screen.getByTestId('people-picker-search')).toBeTruthy());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('renders the current selection as avatar chips', async () => {
|
|
69
|
+
const ds = makeDataSource();
|
|
70
|
+
render(
|
|
71
|
+
<UserField field={{ type: 'user', multiple: true } as any} dataSource={ds} value={['u1']} onChange={vi.fn()} />,
|
|
72
|
+
);
|
|
73
|
+
await waitFor(() => expect(screen.getByTestId('people-field-chip')).toBeTruthy());
|
|
74
|
+
expect(screen.getByText('Amy Lin')).toBeTruthy();
|
|
75
|
+
// Initials fallback renders (image does not load in jsdom).
|
|
76
|
+
expect(screen.getByText('AL')).toBeTruthy();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -71,4 +71,29 @@ describe('deriveLookupColumns', () => {
|
|
|
71
71
|
const cols = deriveLookupColumns(schema, { displayField: 'name' });
|
|
72
72
|
expect(cols.map((c) => c.field)).toEqual(['name', 'region']);
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
it('prefers ADR-0085 highlightFields over the declaration-order walk', () => {
|
|
76
|
+
const schema = {
|
|
77
|
+
fields: {
|
|
78
|
+
name: { type: 'text', label: 'Name' },
|
|
79
|
+
code: { type: 'text', label: 'Code' },
|
|
80
|
+
region: { type: 'select', label: 'Region' },
|
|
81
|
+
notes: { type: 'text', label: 'Notes' },
|
|
82
|
+
},
|
|
83
|
+
highlightFields: ['region', 'code'],
|
|
84
|
+
};
|
|
85
|
+
const cols = deriveLookupColumns(schema, { displayField: 'name', max: 4 });
|
|
86
|
+
// Display field leads, then highlightFields in order — not the raw field walk.
|
|
87
|
+
expect(cols.map((c) => c.field)).toEqual(['name', 'region', 'code']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('lets highlightFields win over legacy displayFields when both are present', () => {
|
|
91
|
+
const schema = {
|
|
92
|
+
fields: { name: { type: 'text' }, code: { type: 'text' }, region: { type: 'select' } },
|
|
93
|
+
highlightFields: ['region'],
|
|
94
|
+
displayFields: ['code'],
|
|
95
|
+
};
|
|
96
|
+
const cols = deriveLookupColumns(schema, { displayField: 'name' });
|
|
97
|
+
expect(cols.map((c) => c.field)).toEqual(['name', 'region']);
|
|
98
|
+
});
|
|
74
99
|
});
|