@object-ui/fields 17.5.0 → 17.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6150 -0
- package/README.md +108 -8
- package/dist/FieldEditWidget.d.ts +70 -8
- package/dist/coerceToSafeValue.d.ts +54 -0
- package/dist/currency.d.ts +1 -1
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.css +53 -11
- package/dist/index.d.ts +653 -128
- package/dist/index.js +4275 -3171
- package/dist/widgets/AddressField.d.ts +3 -3
- package/dist/widgets/AutoNumberField.d.ts +1 -1
- package/dist/widgets/AvatarField.d.ts +20 -4
- package/dist/widgets/BooleanField.d.ts +2 -2
- package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/DateField.d.ts +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +18 -2
- package/dist/widgets/FilterConditionField.d.ts +43 -2
- package/dist/widgets/FormulaField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
- package/dist/widgets/GeolocationField.d.ts +10 -4
- package/dist/widgets/GridField.d.ts +102 -86
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +31 -4
- package/dist/widgets/LookupField.d.ts +9 -1
- package/dist/widgets/MasterDetailField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +10 -3
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +38 -8
- package/dist/widgets/PasswordField.d.ts +15 -1
- package/dist/widgets/PeoplePicker.d.ts +17 -2
- package/dist/widgets/PercentField.d.ts +3 -2
- package/dist/widgets/PersonRow.d.ts +13 -2
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +44 -1
- package/dist/widgets/RecordPickerDialog.d.ts +33 -16
- package/dist/widgets/RichTextField.d.ts +37 -1
- package/dist/widgets/SelectField.d.ts +1 -1
- package/dist/widgets/SelectionTray.d.ts +13 -2
- package/dist/widgets/SignatureField.d.ts +32 -2
- package/dist/widgets/SliderField.d.ts +37 -2
- package/dist/widgets/SummaryField.d.ts +1 -1
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +6 -2
- package/dist/widgets/TextField.d.ts +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/UserField.d.ts +1 -1
- package/dist/widgets/VectorField.d.ts +1 -1
- package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
- package/dist/widgets/address-format.d.ts +27 -7
- package/dist/widgets/file-affordance.d.ts +33 -0
- package/dist/widgets/file-size-guard.d.ts +5 -23
- package/dist/widgets/file-value.d.ts +38 -9
- package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
- package/dist/widgets/nativeDateValue.d.ts +9 -27
- package/dist/widgets/number-format.d.ts +137 -0
- package/dist/widgets/numberBadInput.d.ts +135 -0
- package/dist/widgets/personDisplay.d.ts +22 -4
- package/dist/widgets/richTextDisplay.d.ts +116 -0
- package/dist/widgets/toDomProps.d.ts +10 -1
- package/dist/widgets/toHostGroupProps.d.ts +172 -21
- package/dist/widgets/toHostProps.d.ts +105 -0
- package/dist/widgets/types.d.ts +19 -6
- package/dist/widgets/upload-error-message.d.ts +6 -0
- package/dist/widgets/uploadingScope.d.ts +142 -0
- package/dist/widgets/useCascadingOptions.d.ts +3 -2
- package/dist/widgets/useRecordQuery.d.ts +4 -2
- package/dist/widgets/useUploadingSignal.d.ts +8 -0
- package/package.json +11 -13
- package/dist/widgets/CharacterCount.d.ts +0 -56
- package/src/FieldEditWidget.test.ts +0 -133
- package/src/FieldEditWidget.tsx +0 -207
- package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
- package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
- package/src/__tests__/DateCellRenderer.test.tsx +0 -82
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
- package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
- package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
- package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
- package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
- package/src/__tests__/cell-truncation.test.tsx +0 -96
- package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
- package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
- package/src/__tests__/date-locale-channel.test.tsx +0 -305
- package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
- package/src/__tests__/group-labelling-declaration.test.ts +0 -148
- package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
- package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
- package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
- package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
- package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
- package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
- package/src/__tests__/registry-collision.test.ts +0 -91
- package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
- package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
- package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
- package/src/__tests__/validation-feedback.test.tsx +0 -361
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
- package/src/__tests__/widget-props-contract.test.tsx +0 -142
- package/src/coerce-safe-value.test.ts +0 -34
- package/src/complex-widgets.test.tsx +0 -924
- package/src/currency.ts +0 -144
- package/src/datetime-widgets.test.tsx +0 -185
- package/src/field-type-alias.multiple.test.ts +0 -81
- package/src/field-type-alias.ts +0 -156
- package/src/field-type-coverage.test.ts +0 -67
- package/src/index.css +0 -44
- package/src/index.tsx +0 -2745
- package/src/standard-widgets.test.tsx +0 -364
- package/src/widgets/AddressField.postalCode.test.tsx +0 -257
- package/src/widgets/AddressField.tsx +0 -182
- package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
- package/src/widgets/AutoNumberField.tsx +0 -18
- package/src/widgets/AvatarField.tsx +0 -117
- package/src/widgets/BooleanField.tsx +0 -86
- package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
- package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
- package/src/widgets/CharacterCount.tsx +0 -263
- package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
- package/src/widgets/CheckboxesField.tsx +0 -169
- package/src/widgets/CodeField.tsx +0 -41
- package/src/widgets/ColorField.tsx +0 -53
- package/src/widgets/CurrencyField.tsx +0 -135
- package/src/widgets/DateField.tsx +0 -40
- package/src/widgets/DateTimeField.tsx +0 -46
- package/src/widgets/EmailField.tsx +0 -37
- package/src/widgets/FileField.tsx +0 -512
- package/src/widgets/FilterConditionField.tsx +0 -454
- package/src/widgets/FormulaField.tsx +0 -38
- package/src/widgets/FullscreenFieldEditor.tsx +0 -60
- package/src/widgets/GeolocationField.tsx +0 -193
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
- package/src/widgets/GridField.test.tsx +0 -632
- package/src/widgets/GridField.tsx +0 -1070
- package/src/widgets/ImageCropperDialog.tsx +0 -245
- package/src/widgets/ImageField.maxSize.test.tsx +0 -154
- package/src/widgets/ImageField.tsx +0 -305
- package/src/widgets/ImageLightbox.test.tsx +0 -52
- package/src/widgets/ImageLightbox.tsx +0 -108
- package/src/widgets/LocationField.tsx +0 -57
- package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
- package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
- package/src/widgets/LookupField.hydration.test.tsx +0 -136
- package/src/widgets/LookupField.idField.test.tsx +0 -61
- package/src/widgets/LookupField.tsx +0 -1295
- package/src/widgets/MarkdownContent.tsx +0 -19
- package/src/widgets/MasterDetailField.tsx +0 -131
- package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
- package/src/widgets/MultiSelectField.tsx +0 -196
- package/src/widgets/NumberField.tsx +0 -49
- package/src/widgets/ObjectField.tsx +0 -94
- package/src/widgets/ObjectRefField.tsx +0 -121
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
- package/src/widgets/OptionsEmptyState.test.tsx +0 -169
- package/src/widgets/OptionsEmptyState.tsx +0 -99
- package/src/widgets/PasswordField.tsx +0 -49
- package/src/widgets/PeoplePicker.test.tsx +0 -360
- package/src/widgets/PeoplePicker.tsx +0 -579
- package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
- package/src/widgets/PercentField.tsx +0 -95
- package/src/widgets/PersonRow.tsx +0 -117
- package/src/widgets/PhoneField.tsx +0 -37
- package/src/widgets/QRCodeField.tsx +0 -103
- package/src/widgets/RadioField.cascade.test.tsx +0 -141
- package/src/widgets/RadioField.tsx +0 -143
- package/src/widgets/RatingField.tsx +0 -94
- package/src/widgets/RecipientPickerField.test.tsx +0 -157
- package/src/widgets/RecipientPickerField.tsx +0 -188
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
- package/src/widgets/RecordPickerDialog.tsx +0 -1365
- package/src/widgets/RichTextField.tsx +0 -176
- package/src/widgets/SelectField.cascade.test.tsx +0 -138
- package/src/widgets/SelectField.tsx +0 -193
- package/src/widgets/SelectionTray.tsx +0 -110
- package/src/widgets/SignatureField.tsx +0 -140
- package/src/widgets/SliderField.tsx +0 -39
- package/src/widgets/SummaryField.tsx +0 -31
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
- package/src/widgets/TagsField.placeholder.test.tsx +0 -95
- package/src/widgets/TagsField.tsx +0 -88
- package/src/widgets/TextAreaField.tsx +0 -213
- package/src/widgets/TextField.tsx +0 -45
- package/src/widgets/TimeField.tsx +0 -31
- package/src/widgets/UrlField.tsx +0 -46
- package/src/widgets/UserField.tsx +0 -59
- package/src/widgets/UserFieldPicker.test.tsx +0 -78
- package/src/widgets/VectorField.tsx +0 -29
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
- package/src/widgets/_cell-renderer-bridge.ts +0 -19
- package/src/widgets/address-format.ts +0 -120
- package/src/widgets/deriveLookupColumns.test.ts +0 -112
- package/src/widgets/deriveLookupColumns.ts +0 -108
- package/src/widgets/file-size-guard.ts +0 -55
- package/src/widgets/file-value.test.ts +0 -230
- package/src/widgets/file-value.ts +0 -246
- package/src/widgets/nativeDateValue.test.ts +0 -115
- package/src/widgets/nativeDateValue.ts +0 -112
- package/src/widgets/openNativePicker.ts +0 -18
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
- package/src/widgets/personDisplay.test.ts +0 -113
- package/src/widgets/personDisplay.ts +0 -92
- package/src/widgets/recentLookups.test.ts +0 -45
- package/src/widgets/recentLookups.ts +0 -47
- package/src/widgets/toDomProps.ts +0 -204
- package/src/widgets/toHostGroupProps.ts +0 -102
- package/src/widgets/types.ts +0 -242
- package/src/widgets/useCascadingOptions.ts +0 -46
- package/src/widgets/useFieldTranslation.ts +0 -132
- package/src/widgets/useRecordQuery.test.ts +0 -174
- package/src/widgets/useRecordQuery.ts +0 -291
- package/src/widgets/useUploadingSignal.test.tsx +0 -62
- package/src/widgets/useUploadingSignal.ts +0 -20
- package/src/withFieldCarrier.tsx +0 -75
|
@@ -1,579 +0,0 @@
|
|
|
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, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
10
|
-
import {
|
|
11
|
-
Button,
|
|
12
|
-
cn,
|
|
13
|
-
Dialog,
|
|
14
|
-
DialogContent,
|
|
15
|
-
DialogHeader,
|
|
16
|
-
DialogTitle,
|
|
17
|
-
Input,
|
|
18
|
-
Popover,
|
|
19
|
-
PopoverContent,
|
|
20
|
-
PopoverTrigger,
|
|
21
|
-
ScrollArea,
|
|
22
|
-
Sheet,
|
|
23
|
-
SheetContent,
|
|
24
|
-
SheetHeader,
|
|
25
|
-
SheetTitle,
|
|
26
|
-
SheetTrigger,
|
|
27
|
-
Skeleton,
|
|
28
|
-
useIsMobile,
|
|
29
|
-
} from '@object-ui/components';
|
|
30
|
-
import { Search, Loader2, AlertCircle } from 'lucide-react';
|
|
31
|
-
import type { DataSource, LookupFilterDef } from '@object-ui/types';
|
|
32
|
-
import { useRecordQuery } from './useRecordQuery';
|
|
33
|
-
import { lookupFiltersToRecord } from './RecordPickerDialog';
|
|
34
|
-
import { getPersonId } from './personDisplay';
|
|
35
|
-
import { PersonRow } from './PersonRow';
|
|
36
|
-
import { SelectionTray } from './SelectionTray';
|
|
37
|
-
import { getRecentLookupIds, pushRecentLookupId } from './recentLookups';
|
|
38
|
-
import { useFieldTranslation } from './useFieldTranslation';
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* PeoplePicker — the Tier 0, search-first user picker (issue #2112).
|
|
42
|
-
*
|
|
43
|
-
* A single-column picker: search box → recent contacts → rich candidate rows
|
|
44
|
-
* (avatar + name + department·email) → a live SelectionTray for multi-select.
|
|
45
|
-
* Composed from the reusable {@link useRecordQuery} kernel and
|
|
46
|
-
* {@link SelectionTray}; a future org-tree tier reuses both beside a left tree.
|
|
47
|
-
*
|
|
48
|
-
* Container is responsive: a centered Dialog on desktop, a bottom Sheet on
|
|
49
|
-
* mobile (<768px). Interaction: full keyboard model (↑/↓ move the cursor, ↵
|
|
50
|
-
* toggles/commits, Backspace on an empty search removes the last chip, Esc
|
|
51
|
-
* closes), matched-term highlighting in rows, skeletons on first load with no
|
|
52
|
-
* flash-to-empty on refetch, and friendly empty / error+retry states.
|
|
53
|
-
*
|
|
54
|
-
* Candidate hygiene (e.g. `banned != true`), the department `$expand`, and the
|
|
55
|
-
* avatar/subtitle field config are supplied by the caller (UserField). Pinyin /
|
|
56
|
-
* employee-id search is server-side and transparent — the client only sends the
|
|
57
|
-
* term.
|
|
58
|
-
*/
|
|
59
|
-
export interface PeoplePickerProps {
|
|
60
|
-
open: boolean;
|
|
61
|
-
onOpenChange: (open: boolean) => void;
|
|
62
|
-
title?: string;
|
|
63
|
-
multiple?: boolean;
|
|
64
|
-
|
|
65
|
-
dataSource: DataSource;
|
|
66
|
-
/** Object to query — `sys_user` for user fields. */
|
|
67
|
-
objectName: string;
|
|
68
|
-
|
|
69
|
-
displayField?: string;
|
|
70
|
-
idField?: string;
|
|
71
|
-
/** Dotted field paths for the row subtitle, e.g. `['primary_business_unit_id.name','email']`. */
|
|
72
|
-
subtitleFields?: string[];
|
|
73
|
-
avatarField?: string;
|
|
74
|
-
/** Related entities to expand (e.g. `['primary_business_unit_id']` for the department name). */
|
|
75
|
-
expand?: string[];
|
|
76
|
-
/** Narrow the server searchable set (ADR-0061). */
|
|
77
|
-
searchFields?: string[];
|
|
78
|
-
pageSize?: number;
|
|
79
|
-
/** Base candidate filters (e.g. exclude banned users). */
|
|
80
|
-
lookupFilters?: LookupFilterDef[];
|
|
81
|
-
/**
|
|
82
|
-
* Hard filter constraints in QueryParams.$filter record form — the
|
|
83
|
-
* dependent (cascading) lookup chain (#2215). Always ANDed into the
|
|
84
|
-
* candidate query; spread after `lookupFilters` so it wins on conflicts.
|
|
85
|
-
*/
|
|
86
|
-
baseFilter?: Record<string, any>;
|
|
87
|
-
|
|
88
|
-
/** Current selection (id, or id[] when `multiple`). */
|
|
89
|
-
value?: any;
|
|
90
|
-
onSelect: (value: any) => void;
|
|
91
|
-
onSelectRecords?: (records: any[]) => void;
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Render as an inline combobox anchored to {@link trigger} — a Popover
|
|
95
|
-
* dropdown on desktop, a bottom Sheet on mobile — instead of a centered
|
|
96
|
-
* modal Dialog. In inline mode multi-select commits live on each toggle
|
|
97
|
-
* (the caller's field chips are the selection), so there's no staging tray
|
|
98
|
-
* or confirm button.
|
|
99
|
-
*/
|
|
100
|
-
inline?: boolean;
|
|
101
|
-
/** The element the inline dropdown/sheet anchors to (the field trigger). */
|
|
102
|
-
trigger?: React.ReactNode;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const DEFAULT_PAGE_SIZE = 25;
|
|
106
|
-
const SKELETON_ROWS = 6;
|
|
107
|
-
|
|
108
|
-
export function PeoplePicker({
|
|
109
|
-
open,
|
|
110
|
-
onOpenChange,
|
|
111
|
-
title,
|
|
112
|
-
multiple = false,
|
|
113
|
-
dataSource,
|
|
114
|
-
objectName,
|
|
115
|
-
displayField = 'name',
|
|
116
|
-
idField = 'id',
|
|
117
|
-
subtitleFields,
|
|
118
|
-
avatarField = 'image',
|
|
119
|
-
expand,
|
|
120
|
-
searchFields,
|
|
121
|
-
pageSize = DEFAULT_PAGE_SIZE,
|
|
122
|
-
lookupFilters,
|
|
123
|
-
baseFilter: baseFilterProp,
|
|
124
|
-
value,
|
|
125
|
-
onSelect,
|
|
126
|
-
onSelectRecords,
|
|
127
|
-
inline = false,
|
|
128
|
-
trigger,
|
|
129
|
-
}: PeoplePickerProps) {
|
|
130
|
-
const { t } = useFieldTranslation();
|
|
131
|
-
const isMobile = useIsMobile();
|
|
132
|
-
|
|
133
|
-
const baseFilter = useMemo<Record<string, any> | undefined>(() => {
|
|
134
|
-
const combined = {
|
|
135
|
-
...(lookupFilters?.length ? lookupFiltersToRecord(lookupFilters) : {}),
|
|
136
|
-
...(baseFilterProp ?? {}),
|
|
137
|
-
};
|
|
138
|
-
return Object.keys(combined).length > 0 ? combined : undefined;
|
|
139
|
-
}, [lookupFilters, baseFilterProp]);
|
|
140
|
-
|
|
141
|
-
// Auto-expand relation subtitles (e.g. `primary_business_unit_id.name` needs
|
|
142
|
-
// `$expand: ['primary_business_unit_id']`) unless the caller passed `expand`.
|
|
143
|
-
const effectiveExpand = useMemo<string[] | undefined>(() => {
|
|
144
|
-
if (expand && expand.length) return expand;
|
|
145
|
-
const rels = new Set<string>();
|
|
146
|
-
(subtitleFields ?? []).forEach(f => {
|
|
147
|
-
if (f.includes('.')) rels.add(f.split('.')[0]);
|
|
148
|
-
});
|
|
149
|
-
return rels.size ? Array.from(rels) : undefined;
|
|
150
|
-
}, [expand, subtitleFields]);
|
|
151
|
-
|
|
152
|
-
// Main candidate query (search + candidate hygiene).
|
|
153
|
-
const query = useRecordQuery({
|
|
154
|
-
dataSource,
|
|
155
|
-
objectName,
|
|
156
|
-
enabled: open,
|
|
157
|
-
pageSize,
|
|
158
|
-
filter: baseFilter,
|
|
159
|
-
expand: effectiveExpand,
|
|
160
|
-
searchFields,
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// Recent ids captured once per open.
|
|
164
|
-
const recentIds = useMemo(
|
|
165
|
-
() => (open ? getRecentLookupIds(objectName) : []),
|
|
166
|
-
[open, objectName],
|
|
167
|
-
);
|
|
168
|
-
|
|
169
|
-
// The current value's ids, for hydrating the SelectionTray on edit.
|
|
170
|
-
const valueIds = useMemo<any[]>(() => {
|
|
171
|
-
if (multiple) return Array.isArray(value) ? value : [];
|
|
172
|
-
return value != null && value !== '' ? [value] : [];
|
|
173
|
-
}, [multiple, value]);
|
|
174
|
-
|
|
175
|
-
// One extra query resolves both recents and the current selection to records.
|
|
176
|
-
const seedIds = useMemo(
|
|
177
|
-
() => Array.from(new Set([...valueIds, ...recentIds].map(v => v))),
|
|
178
|
-
[valueIds, recentIds],
|
|
179
|
-
);
|
|
180
|
-
const seedQuery = useRecordQuery({
|
|
181
|
-
dataSource,
|
|
182
|
-
objectName,
|
|
183
|
-
enabled: open && seedIds.length > 0,
|
|
184
|
-
pageSize: Math.max(1, seedIds.length),
|
|
185
|
-
filter: { [idField]: { $in: seedIds } },
|
|
186
|
-
expand: effectiveExpand,
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
const recordsById = useMemo(() => {
|
|
190
|
-
const m = new Map<string, any>();
|
|
191
|
-
for (const r of seedQuery.records) m.set(String(getPersonId(r, idField)), r);
|
|
192
|
-
return m;
|
|
193
|
-
}, [seedQuery.records, idField]);
|
|
194
|
-
|
|
195
|
-
// --- selection state (full records so the tray can show avatar + name) ---
|
|
196
|
-
const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
|
|
197
|
-
const seededRef = useRef(false);
|
|
198
|
-
// On open, seedQuery.loading is still false for one render (its fetch is
|
|
199
|
-
// kicked off in an effect that runs after this one), so an eager seed would
|
|
200
|
-
// read an empty recordsById, seed nothing, and lock — wiping an existing
|
|
201
|
-
// selection on confirm. Only seed after we've observed the fetch start.
|
|
202
|
-
const sawSeedLoadingRef = useRef(false);
|
|
203
|
-
|
|
204
|
-
useEffect(() => {
|
|
205
|
-
if (!open) {
|
|
206
|
-
seededRef.current = false;
|
|
207
|
-
sawSeedLoadingRef.current = false;
|
|
208
|
-
setSelectedRecords([]);
|
|
209
|
-
}
|
|
210
|
-
}, [open]);
|
|
211
|
-
|
|
212
|
-
// Seed the tray from the current value once its records resolve (once per open).
|
|
213
|
-
useEffect(() => {
|
|
214
|
-
if (!open || seededRef.current) return;
|
|
215
|
-
if (valueIds.length === 0) {
|
|
216
|
-
seededRef.current = true;
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
if (seedQuery.loading) {
|
|
220
|
-
sawSeedLoadingRef.current = true;
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
// The seed fetch hasn't started yet this open — wait for it before locking.
|
|
224
|
-
if (!sawSeedLoadingRef.current) return;
|
|
225
|
-
const seeded = valueIds.map(id => recordsById.get(String(id))).filter(Boolean);
|
|
226
|
-
setSelectedRecords(seeded);
|
|
227
|
-
seededRef.current = true;
|
|
228
|
-
}, [open, valueIds, seedQuery.loading, recordsById]);
|
|
229
|
-
|
|
230
|
-
const selectedIds = useMemo(
|
|
231
|
-
() => new Set(selectedRecords.map(r => String(getPersonId(r, idField)))),
|
|
232
|
-
[selectedRecords, idField],
|
|
233
|
-
);
|
|
234
|
-
|
|
235
|
-
const commit = useCallback(
|
|
236
|
-
(ids: any[], records: any[]) => {
|
|
237
|
-
onSelect(multiple ? ids : (ids[0] ?? null));
|
|
238
|
-
onSelectRecords?.(records);
|
|
239
|
-
ids.forEach(id => pushRecentLookupId(objectName, id));
|
|
240
|
-
onOpenChange(false);
|
|
241
|
-
},
|
|
242
|
-
[multiple, objectName, onSelect, onSelectRecords, onOpenChange],
|
|
243
|
-
);
|
|
244
|
-
|
|
245
|
-
// Inline mode has no confirm step — push the value out on every change.
|
|
246
|
-
const commitLive = useCallback(
|
|
247
|
-
(records: any[]) => {
|
|
248
|
-
onSelect(records.map(r => getPersonId(r, idField)));
|
|
249
|
-
onSelectRecords?.(records);
|
|
250
|
-
},
|
|
251
|
-
[onSelect, onSelectRecords, idField],
|
|
252
|
-
);
|
|
253
|
-
|
|
254
|
-
const handleRowSelect = useCallback(
|
|
255
|
-
(record: any) => {
|
|
256
|
-
const id = getPersonId(record, idField);
|
|
257
|
-
if (!multiple) {
|
|
258
|
-
commit([id], [record]);
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
const key = String(id);
|
|
262
|
-
const exists = selectedRecords.some(r => String(getPersonId(r, idField)) === key);
|
|
263
|
-
const next = exists
|
|
264
|
-
? selectedRecords.filter(r => String(getPersonId(r, idField)) !== key)
|
|
265
|
-
: [...selectedRecords, record];
|
|
266
|
-
setSelectedRecords(next);
|
|
267
|
-
// Inline: commit live (chips live in the field), stay open for more.
|
|
268
|
-
// Modal: stage in the tray until Confirm.
|
|
269
|
-
if (inline) {
|
|
270
|
-
if (!exists) pushRecentLookupId(objectName, id);
|
|
271
|
-
commitLive(next);
|
|
272
|
-
}
|
|
273
|
-
},
|
|
274
|
-
[multiple, idField, commit, selectedRecords, inline, objectName, commitLive],
|
|
275
|
-
);
|
|
276
|
-
|
|
277
|
-
const handleRemove = useCallback(
|
|
278
|
-
(id: any) => {
|
|
279
|
-
const key = String(id);
|
|
280
|
-
const next = selectedRecords.filter(r => String(getPersonId(r, idField)) !== key);
|
|
281
|
-
setSelectedRecords(next);
|
|
282
|
-
if (inline) commitLive(next);
|
|
283
|
-
},
|
|
284
|
-
[idField, selectedRecords, inline, commitLive],
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
const handleConfirm = useCallback(() => {
|
|
288
|
-
commit(selectedRecords.map(r => getPersonId(r, idField)), selectedRecords);
|
|
289
|
-
}, [commit, selectedRecords, idField]);
|
|
290
|
-
|
|
291
|
-
const hasSearch = query.search.trim().length > 0;
|
|
292
|
-
|
|
293
|
-
// Recent contacts (only when not searching), in MRU order.
|
|
294
|
-
const recentRecords = useMemo(() => {
|
|
295
|
-
if (hasSearch) return [];
|
|
296
|
-
return recentIds.map(id => recordsById.get(String(id))).filter(Boolean);
|
|
297
|
-
}, [hasSearch, recentIds, recordsById]);
|
|
298
|
-
|
|
299
|
-
// Candidate list; drop recents when idle to avoid showing them twice.
|
|
300
|
-
const resultRecords = useMemo(() => {
|
|
301
|
-
if (hasSearch) return query.records;
|
|
302
|
-
const recentSet = new Set(recentIds.map(String));
|
|
303
|
-
return query.records.filter(r => !recentSet.has(String(getPersonId(r, idField))));
|
|
304
|
-
}, [hasSearch, query.records, recentIds, idField]);
|
|
305
|
-
|
|
306
|
-
// Flat, in-display-order list the keyboard cursor walks.
|
|
307
|
-
const navList = useMemo(
|
|
308
|
-
() => [...recentRecords, ...resultRecords],
|
|
309
|
-
[recentRecords, resultRecords],
|
|
310
|
-
);
|
|
311
|
-
|
|
312
|
-
// --- keyboard cursor ---
|
|
313
|
-
const [activeIndex, setActiveIndex] = useState(-1);
|
|
314
|
-
// Reset the cursor when the query results are actually *replaced* — keyed on
|
|
315
|
-
// the record-id signature, not the array identity. A background refetch that
|
|
316
|
-
// returns the same records (StrictMode double-effect, refetch-on-focus)
|
|
317
|
-
// re-emits a new array; resetting on identity alone yanked the cursor to -1
|
|
318
|
-
// mid-navigation (flaky ArrowDown→Enter, and a real UX nit).
|
|
319
|
-
//
|
|
320
|
-
// The id separator is the escaped U+0000 spelling, not the byte itself
|
|
321
|
-
// (objectstack#5450): one raw NUL made this whole file binary to grep and
|
|
322
|
-
// ripgrep — a content search printed `binary file matches` and no line. The
|
|
323
|
-
// runtime value is unchanged, and it is the same separator the `cursorEpoch`
|
|
324
|
-
// key below already spelled this way.
|
|
325
|
-
const recordsSignature = useMemo(
|
|
326
|
-
() => query.records.map(r => String(getPersonId(r, idField))).join('\u0000'),
|
|
327
|
-
[query.records, idField],
|
|
328
|
-
);
|
|
329
|
-
// The reset runs in the RENDER PHASE (the "adjusting state during render"
|
|
330
|
-
// pattern), NOT in an effect. An effect flushes asynchronously after the
|
|
331
|
-
// render that delivered the records — so a reset queued by their arrival
|
|
332
|
-
// could land AFTER a subsequent ArrowDown and eat the just-set cursor.
|
|
333
|
-
// That is the residual ArrowDown→Enter flake the signature key above did
|
|
334
|
-
// not close (CI reproduced it under load), and a real fast-fingers UX bug:
|
|
335
|
-
// rows appear, the user presses ArrowDown, the highlight vanishes.
|
|
336
|
-
// Resetting during the same render that shows the new rows makes the
|
|
337
|
-
// ordering deterministic — by the time the rows are visible, the reset
|
|
338
|
-
// has already happened.
|
|
339
|
-
const cursorEpoch = `${query.search}\u0000${recordsSignature}`;
|
|
340
|
-
const [seenCursorEpoch, setSeenCursorEpoch] = useState(cursorEpoch);
|
|
341
|
-
if (seenCursorEpoch !== cursorEpoch) {
|
|
342
|
-
setSeenCursorEpoch(cursorEpoch);
|
|
343
|
-
setActiveIndex(-1);
|
|
344
|
-
}
|
|
345
|
-
// Keep it in range if the list shrinks.
|
|
346
|
-
useEffect(() => {
|
|
347
|
-
setActiveIndex(i => (i >= navList.length ? navList.length - 1 : i));
|
|
348
|
-
}, [navList.length]);
|
|
349
|
-
|
|
350
|
-
const onSearchKeyDown = useCallback(
|
|
351
|
-
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
352
|
-
if (e.key === 'ArrowDown') {
|
|
353
|
-
e.preventDefault();
|
|
354
|
-
setActiveIndex(i => (navList.length ? Math.min(navList.length - 1, i + 1) : -1));
|
|
355
|
-
} else if (e.key === 'ArrowUp') {
|
|
356
|
-
e.preventDefault();
|
|
357
|
-
setActiveIndex(i => Math.max(0, i - 1));
|
|
358
|
-
} else if (e.key === 'Enter') {
|
|
359
|
-
if (activeIndex >= 0 && activeIndex < navList.length) {
|
|
360
|
-
e.preventDefault();
|
|
361
|
-
handleRowSelect(navList[activeIndex]);
|
|
362
|
-
}
|
|
363
|
-
} else if (
|
|
364
|
-
e.key === 'Backspace' &&
|
|
365
|
-
query.search.length === 0 &&
|
|
366
|
-
multiple &&
|
|
367
|
-
selectedRecords.length > 0
|
|
368
|
-
) {
|
|
369
|
-
handleRemove(getPersonId(selectedRecords[selectedRecords.length - 1], idField));
|
|
370
|
-
}
|
|
371
|
-
},
|
|
372
|
-
[navList, activeIndex, handleRowSelect, query.search, multiple, selectedRecords, handleRemove, idField],
|
|
373
|
-
);
|
|
374
|
-
|
|
375
|
-
const initialLoading =
|
|
376
|
-
query.loading && !query.error && query.records.length === 0 && recentRecords.length === 0;
|
|
377
|
-
const refetching = query.loading && !initialLoading;
|
|
378
|
-
const isEmpty =
|
|
379
|
-
!query.loading &&
|
|
380
|
-
!query.error &&
|
|
381
|
-
resultRecords.length === 0 &&
|
|
382
|
-
recentRecords.length === 0;
|
|
383
|
-
|
|
384
|
-
const renderRow = (record: any, index: number) => {
|
|
385
|
-
const id = getPersonId(record, idField);
|
|
386
|
-
return (
|
|
387
|
-
<PersonRow
|
|
388
|
-
key={String(id)}
|
|
389
|
-
record={record}
|
|
390
|
-
displayField={displayField}
|
|
391
|
-
subtitleFields={subtitleFields}
|
|
392
|
-
avatarField={avatarField}
|
|
393
|
-
selected={selectedIds.has(String(id))}
|
|
394
|
-
active={index === activeIndex}
|
|
395
|
-
highlightQuery={query.search}
|
|
396
|
-
onSelect={handleRowSelect}
|
|
397
|
-
/>
|
|
398
|
-
);
|
|
399
|
-
};
|
|
400
|
-
|
|
401
|
-
const titleText = title || t('lookup.selectRecord');
|
|
402
|
-
|
|
403
|
-
// Container-agnostic body — rendered inside a Dialog (desktop) or Sheet (mobile).
|
|
404
|
-
const body = (
|
|
405
|
-
<>
|
|
406
|
-
{/* Search */}
|
|
407
|
-
<div className="relative">
|
|
408
|
-
<Search
|
|
409
|
-
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
|
410
|
-
aria-hidden
|
|
411
|
-
/>
|
|
412
|
-
<Input
|
|
413
|
-
autoFocus={!isMobile}
|
|
414
|
-
value={query.search}
|
|
415
|
-
onChange={e => query.setSearch(e.target.value)}
|
|
416
|
-
onKeyDown={onSearchKeyDown}
|
|
417
|
-
placeholder={t('table.search')}
|
|
418
|
-
className="px-8"
|
|
419
|
-
role="combobox"
|
|
420
|
-
aria-expanded
|
|
421
|
-
aria-controls="people-picker-list"
|
|
422
|
-
data-testid="people-picker-search"
|
|
423
|
-
/>
|
|
424
|
-
{query.loading && (
|
|
425
|
-
<Loader2
|
|
426
|
-
className="pointer-events-none absolute right-2.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-muted-foreground"
|
|
427
|
-
aria-hidden
|
|
428
|
-
/>
|
|
429
|
-
)}
|
|
430
|
-
</div>
|
|
431
|
-
|
|
432
|
-
{/* Candidate area */}
|
|
433
|
-
<ScrollArea className="min-h-0 flex-1" data-testid="people-picker-list">
|
|
434
|
-
<div
|
|
435
|
-
id="people-picker-list"
|
|
436
|
-
role="listbox"
|
|
437
|
-
aria-busy={query.loading}
|
|
438
|
-
className={cn('flex flex-col gap-0.5 pr-2 transition-opacity', refetching && 'opacity-70')}
|
|
439
|
-
>
|
|
440
|
-
{query.error ? (
|
|
441
|
-
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted-foreground">
|
|
442
|
-
<AlertCircle className="size-5 text-destructive" aria-hidden />
|
|
443
|
-
<span className="max-w-xs">{query.error}</span>
|
|
444
|
-
<Button type="button" variant="outline" size="sm" onClick={query.refetch}>
|
|
445
|
-
{t('lookup.retry')}
|
|
446
|
-
</Button>
|
|
447
|
-
</div>
|
|
448
|
-
) : initialLoading ? (
|
|
449
|
-
Array.from({ length: SKELETON_ROWS }).map((_, i) => (
|
|
450
|
-
<div key={i} className="flex items-center gap-3 px-2 py-1.5">
|
|
451
|
-
<Skeleton className="size-9 shrink-0 rounded-full" />
|
|
452
|
-
<div className="flex-1 space-y-1.5">
|
|
453
|
-
<Skeleton className="h-3 w-1/3" />
|
|
454
|
-
<Skeleton className="h-2.5 w-1/2" />
|
|
455
|
-
</div>
|
|
456
|
-
</div>
|
|
457
|
-
))
|
|
458
|
-
) : (
|
|
459
|
-
<>
|
|
460
|
-
{recentRecords.length > 0 && (
|
|
461
|
-
<>
|
|
462
|
-
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
|
463
|
-
{t('lookup.recentlyUsed')}
|
|
464
|
-
</div>
|
|
465
|
-
{recentRecords.map((r, i) => renderRow(r, i))}
|
|
466
|
-
{resultRecords.length > 0 && (
|
|
467
|
-
<div className="mt-1 flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-muted-foreground">
|
|
468
|
-
<span>{t('lookup.allResults')}</span>
|
|
469
|
-
<span className="font-normal opacity-70">· {query.total}</span>
|
|
470
|
-
</div>
|
|
471
|
-
)}
|
|
472
|
-
</>
|
|
473
|
-
)}
|
|
474
|
-
|
|
475
|
-
{resultRecords.map((r, i) => renderRow(r, recentRecords.length + i))}
|
|
476
|
-
|
|
477
|
-
{isEmpty && (
|
|
478
|
-
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
479
|
-
{t('lookup.noRecords')}
|
|
480
|
-
</div>
|
|
481
|
-
)}
|
|
482
|
-
</>
|
|
483
|
-
)}
|
|
484
|
-
</div>
|
|
485
|
-
</ScrollArea>
|
|
486
|
-
|
|
487
|
-
{/* Multi-select tray + confirm — modal only; inline commits live and
|
|
488
|
-
shows the selection as chips in the caller's field. */}
|
|
489
|
-
{multiple && !inline && (
|
|
490
|
-
<>
|
|
491
|
-
<SelectionTray
|
|
492
|
-
records={selectedRecords}
|
|
493
|
-
onRemove={handleRemove}
|
|
494
|
-
onClear={() => setSelectedRecords([])}
|
|
495
|
-
clearLabel={t('lookup.clear')}
|
|
496
|
-
displayField={displayField}
|
|
497
|
-
avatarField={avatarField}
|
|
498
|
-
idField={idField}
|
|
499
|
-
label={t('table.selected', { count: selectedRecords.length })}
|
|
500
|
-
className={cn('border-t pt-3')}
|
|
501
|
-
/>
|
|
502
|
-
<div className="flex justify-end gap-2">
|
|
503
|
-
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
504
|
-
{t('common.cancel')}
|
|
505
|
-
</Button>
|
|
506
|
-
<Button type="button" onClick={handleConfirm}>
|
|
507
|
-
{t('common.confirm')}
|
|
508
|
-
</Button>
|
|
509
|
-
</div>
|
|
510
|
-
</>
|
|
511
|
-
)}
|
|
512
|
-
</>
|
|
513
|
-
);
|
|
514
|
-
|
|
515
|
-
// Inline combobox: anchored Popover on desktop, bottom Sheet (opened from the
|
|
516
|
-
// same trigger) on mobile. The trigger is the caller's field control.
|
|
517
|
-
if (inline) {
|
|
518
|
-
if (isMobile) {
|
|
519
|
-
return (
|
|
520
|
-
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
521
|
-
<SheetTrigger asChild>{trigger}</SheetTrigger>
|
|
522
|
-
<SheetContent
|
|
523
|
-
side="bottom"
|
|
524
|
-
className="flex h-[85vh] flex-col gap-3"
|
|
525
|
-
data-testid="people-picker-sheet"
|
|
526
|
-
>
|
|
527
|
-
<SheetHeader className="text-left">
|
|
528
|
-
<SheetTitle>{titleText}</SheetTitle>
|
|
529
|
-
</SheetHeader>
|
|
530
|
-
{body}
|
|
531
|
-
</SheetContent>
|
|
532
|
-
</Sheet>
|
|
533
|
-
);
|
|
534
|
-
}
|
|
535
|
-
return (
|
|
536
|
-
<Popover open={open} onOpenChange={onOpenChange}>
|
|
537
|
-
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
|
538
|
-
<PopoverContent
|
|
539
|
-
align="start"
|
|
540
|
-
className="flex max-h-[min(28rem,60vh)] w-[var(--radix-popover-trigger-width)] min-w-72 flex-col gap-3 p-3"
|
|
541
|
-
data-testid="people-picker-inline"
|
|
542
|
-
>
|
|
543
|
-
{body}
|
|
544
|
-
</PopoverContent>
|
|
545
|
-
</Popover>
|
|
546
|
-
);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
if (isMobile) {
|
|
550
|
-
return (
|
|
551
|
-
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
552
|
-
<SheetContent
|
|
553
|
-
side="bottom"
|
|
554
|
-
className="flex h-[85vh] flex-col gap-3"
|
|
555
|
-
data-testid="people-picker-sheet"
|
|
556
|
-
>
|
|
557
|
-
<SheetHeader className="text-left">
|
|
558
|
-
<SheetTitle>{titleText}</SheetTitle>
|
|
559
|
-
</SheetHeader>
|
|
560
|
-
{body}
|
|
561
|
-
</SheetContent>
|
|
562
|
-
</Sheet>
|
|
563
|
-
);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
return (
|
|
567
|
-
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
568
|
-
<DialogContent
|
|
569
|
-
className="flex max-h-[80vh] flex-col gap-3 sm:max-w-lg"
|
|
570
|
-
data-testid="people-picker-dialog"
|
|
571
|
-
>
|
|
572
|
-
<DialogHeader>
|
|
573
|
-
<DialogTitle>{titleText}</DialogTitle>
|
|
574
|
-
</DialogHeader>
|
|
575
|
-
{body}
|
|
576
|
-
</DialogContent>
|
|
577
|
-
</Dialog>
|
|
578
|
-
);
|
|
579
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
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 } from '@testing-library/react';
|
|
10
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
11
|
-
import { PersonRow } from './PersonRow';
|
|
12
|
-
import { SelectionTray } from './SelectionTray';
|
|
13
|
-
|
|
14
|
-
const amy = {
|
|
15
|
-
id: 'u1',
|
|
16
|
-
name: 'Amy Lin',
|
|
17
|
-
email: 'amy@x.io',
|
|
18
|
-
image: 'http://x/amy.png',
|
|
19
|
-
primary_business_unit_id: { name: 'Sales' },
|
|
20
|
-
};
|
|
21
|
-
const bob = { id: 'u2', name: 'Bob Wu', email: 'bob@x.io' };
|
|
22
|
-
|
|
23
|
-
describe('PersonRow', () => {
|
|
24
|
-
it('renders name, subtitle (dept · email) and initials fallback', () => {
|
|
25
|
-
render(
|
|
26
|
-
<PersonRow record={amy} subtitleFields={['primary_business_unit_id.name', 'email']} />,
|
|
27
|
-
);
|
|
28
|
-
expect(screen.getByText('Amy Lin')).toBeTruthy();
|
|
29
|
-
expect(screen.getByText('Sales · amy@x.io')).toBeTruthy();
|
|
30
|
-
// Avatar fallback initials render (image does not load in jsdom).
|
|
31
|
-
expect(screen.getByText('AL')).toBeTruthy();
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('fires onSelect with the record on click', () => {
|
|
35
|
-
const onSelect = vi.fn();
|
|
36
|
-
render(<PersonRow record={amy} onSelect={onSelect} />);
|
|
37
|
-
fireEvent.click(screen.getByTestId('person-row'));
|
|
38
|
-
expect(onSelect).toHaveBeenCalledWith(amy);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('reflects selected state via aria-pressed', () => {
|
|
42
|
-
const { rerender } = render(<PersonRow record={amy} selected={false} />);
|
|
43
|
-
expect(screen.getByTestId('person-row').getAttribute('aria-pressed')).toBe('false');
|
|
44
|
-
rerender(<PersonRow record={amy} selected />);
|
|
45
|
-
expect(screen.getByTestId('person-row').getAttribute('aria-pressed')).toBe('true');
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
describe('SelectionTray', () => {
|
|
50
|
-
it('shows a chip per selected record with a label', () => {
|
|
51
|
-
render(<SelectionTray records={[amy, bob]} onRemove={vi.fn()} label="Selected (2)" />);
|
|
52
|
-
expect(screen.getByText('Selected (2)')).toBeTruthy();
|
|
53
|
-
expect(screen.getAllByTestId('selection-chip')).toHaveLength(2);
|
|
54
|
-
expect(screen.getByText('Amy Lin')).toBeTruthy();
|
|
55
|
-
expect(screen.getByText('Bob Wu')).toBeTruthy();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('renders emptyText when nothing is selected', () => {
|
|
59
|
-
render(<SelectionTray records={[]} onRemove={vi.fn()} emptyText="No one selected" />);
|
|
60
|
-
expect(screen.getByText('No one selected')).toBeTruthy();
|
|
61
|
-
expect(screen.queryByTestId('selection-chip')).toBeNull();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('calls onRemove with the record id when the ✕ is clicked', () => {
|
|
65
|
-
const onRemove = vi.fn();
|
|
66
|
-
render(<SelectionTray records={[amy, bob]} onRemove={onRemove} />);
|
|
67
|
-
fireEvent.click(screen.getByLabelText('Remove Amy Lin'));
|
|
68
|
-
expect(onRemove).toHaveBeenCalledWith('u1');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('shows Clear all only when non-empty and fires onClear', () => {
|
|
72
|
-
const onClear = vi.fn();
|
|
73
|
-
const { rerender } = render(
|
|
74
|
-
<SelectionTray records={[]} onRemove={vi.fn()} onClear={onClear} clearLabel="Clear all" />,
|
|
75
|
-
);
|
|
76
|
-
expect(screen.queryByTestId('selection-clear')).toBeNull();
|
|
77
|
-
rerender(
|
|
78
|
-
<SelectionTray records={[amy, bob]} onRemove={vi.fn()} onClear={onClear} clearLabel="Clear all" />,
|
|
79
|
-
);
|
|
80
|
-
fireEvent.click(screen.getByTestId('selection-clear'));
|
|
81
|
-
expect(onClear).toHaveBeenCalledTimes(1);
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
describe('PersonRow — highlight + active', () => {
|
|
86
|
-
it('wraps the matched substring in a <mark>', () => {
|
|
87
|
-
const { container } = render(<PersonRow record={amy} highlightQuery="am" />);
|
|
88
|
-
const mark = container.querySelector('mark');
|
|
89
|
-
expect(mark?.textContent).toBe('Am');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('renders plain text (no mark) when there is no query', () => {
|
|
93
|
-
const { container } = render(<PersonRow record={amy} />);
|
|
94
|
-
expect(container.querySelector('mark')).toBeNull();
|
|
95
|
-
expect(screen.getByText('Amy Lin')).toBeTruthy();
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('exposes the keyboard-active state via data-active', () => {
|
|
99
|
-
const { rerender } = render(<PersonRow record={amy} active={false} />);
|
|
100
|
-
expect(screen.getByTestId('person-row').getAttribute('data-active')).toBeNull();
|
|
101
|
-
rerender(<PersonRow record={amy} active />);
|
|
102
|
-
expect(screen.getByTestId('person-row').getAttribute('data-active')).toBe('true');
|
|
103
|
-
});
|
|
104
|
-
});
|