@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/dist/FieldEditWidget.d.ts +21 -0
  3. package/dist/FieldEditWidget.test.d.ts +8 -0
  4. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  5. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.d.ts +8 -0
  8. package/dist/index.js +2155 -1438
  9. package/dist/widgets/LookupField.dependsOn.test.d.ts +8 -0
  10. package/dist/widgets/PeoplePicker.d.ts +63 -0
  11. package/dist/widgets/PeoplePicker.test.d.ts +8 -0
  12. package/dist/widgets/PeoplePickerComponents.test.d.ts +8 -0
  13. package/dist/widgets/PersonRow.d.ts +23 -0
  14. package/dist/widgets/RecordPickerDialog.d.ts +9 -1
  15. package/dist/widgets/SelectionTray.d.ts +27 -0
  16. package/dist/widgets/UserField.d.ts +0 -13
  17. package/dist/widgets/UserFieldPicker.test.d.ts +8 -0
  18. package/dist/widgets/deriveLookupColumns.d.ts +12 -4
  19. package/dist/widgets/personDisplay.d.ts +37 -0
  20. package/dist/widgets/personDisplay.test.d.ts +8 -0
  21. package/dist/widgets/useRecordQuery.d.ts +87 -0
  22. package/dist/widgets/useRecordQuery.test.d.ts +8 -0
  23. package/package.json +11 -11
  24. package/src/FieldEditWidget.test.ts +61 -0
  25. package/src/FieldEditWidget.tsx +146 -0
  26. package/src/__tests__/NumberCellRenderer.test.tsx +60 -0
  27. package/src/__tests__/lookupCellReferenceKey.test.tsx +57 -0
  28. package/src/complex-widgets.test.tsx +241 -0
  29. package/src/index.tsx +33 -4
  30. package/src/widgets/GridField.test.tsx +14 -0
  31. package/src/widgets/GridField.tsx +7 -8
  32. package/src/widgets/LookupField.dependsOn.test.tsx +233 -0
  33. package/src/widgets/LookupField.tsx +397 -199
  34. package/src/widgets/NumberField.tsx +4 -2
  35. package/src/widgets/PeoplePicker.test.tsx +334 -0
  36. package/src/widgets/PeoplePicker.tsx +552 -0
  37. package/src/widgets/PeoplePickerComponents.test.tsx +104 -0
  38. package/src/widgets/PersonRow.tsx +117 -0
  39. package/src/widgets/RecordPickerDialog.tsx +58 -129
  40. package/src/widgets/SelectionTray.tsx +110 -0
  41. package/src/widgets/UserField.tsx +20 -1
  42. package/src/widgets/UserFieldPicker.test.tsx +78 -0
  43. package/src/widgets/deriveLookupColumns.test.ts +25 -0
  44. package/src/widgets/deriveLookupColumns.ts +24 -10
  45. package/src/widgets/personDisplay.test.ts +113 -0
  46. package/src/widgets/personDisplay.ts +92 -0
  47. package/src/widgets/useRecordQuery.test.ts +174 -0
  48. 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, QueryParams, LookupColumnDef, LookupFilterDef } from '@object-ui/types';
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
- const [records, setRecords] = useState<any[]>([]);
342
- const [loading, setLoading] = useState(false);
343
- const [error, setError] = useState<string | null>(null);
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 baseFilter = lookupFilters?.length
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 = { ...baseFilter, ...userFilter };
438
+ const combined = { ...lookupBase, ...userFilter, ...(baseFilter ?? {}) };
434
439
  return Object.keys(combined).length > 0 ? combined : undefined;
435
- }, [lookupFilters, effectiveFilterColumns, filterValues]);
436
-
437
- const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
438
-
439
- // Fetch records
440
- const fetchRecords = useCallback(
441
- async (search?: string, page = 1, sort?: { field: string; direction: 'asc' | 'desc' } | null, customFilter?: Record<string, any>) => {
442
- if (!dataSource || !objectName) return;
443
-
444
- setLoading(true);
445
- setError(null);
446
-
447
- try {
448
- const params: QueryParams = {
449
- $top: pageSize,
450
- $skip: (page - 1) * pageSize,
451
- };
452
- if (search && search.trim()) {
453
- params.$search = search.trim();
454
- }
455
- if (sort) {
456
- params.$orderby = { [sort.field]: sort.direction };
457
- }
458
- // Inject filters (lookup_filters + filter bar values)
459
- const effectiveFilter = customFilter !== undefined ? customFilter : mergedFilter;
460
- if (effectiveFilter && Object.keys(effectiveFilter).length > 0) {
461
- params.$filter = effectiveFilter;
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
- // Debounced search
532
- const handleSearchChange = useCallback(
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
- return () => {
550
- if (debounceTimer.current) {
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(p => Math.max(1, p - 1));
630
- }, []);
558
+ setCurrentPage(Math.max(1, currentPage - 1));
559
+ }, [setCurrentPage, currentPage]);
631
560
 
632
561
  const handleNextPage = useCallback(() => {
633
- setCurrentPage(p => Math.min(totalPages, p + 1));
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={() => fetchRecords(searchQuery || undefined, currentPage, currentSort)}
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) and presents user names by default.
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
  });