Skip to content

Commit d6a253b

Browse files
authored
Merge pull request #97872 from marufsharifi/fix/assign-card-assignee-selected-order
Move selected cardholder to the top of the assignee list
2 parents 99233ce + 787468a commit d6a253b

2 files changed

Lines changed: 207 additions & 4 deletions

File tree

src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import UserListItem from '@components/SelectionList/ListItem/UserListItem';
44
import type {ListItem} from '@components/SelectionList/types';
55
import Text from '@components/Text';
66

7+
import useInitialSelection from '@hooks/useInitialSelection';
78
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
89
import useLocalize from '@hooks/useLocalize';
910
import useNetwork from '@hooks/useNetwork';
@@ -22,6 +23,7 @@ import {getSearchValueForPhoneOrEmail, sortAlphabetically} from '@libs/OptionsLi
2223
import {getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils';
2324
import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils';
2425
import {canMemberWrite, filterGuideAndAccountManager, getGuideAndAccountManagerInfo, getIneligibleInvitees, isDeletedPolicyEmployee} from '@libs/PolicyUtils';
26+
import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils';
2527
import tokenizedSearch from '@libs/tokenizedSearch';
2628

2729
import Navigation from '@navigation/Navigation';
@@ -42,6 +44,10 @@ import {Keyboard} from 'react-native';
4244

4345
type AssigneeStepProps = PlatformStackScreenProps<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARDS_ASSIGN_CARD_ASSIGNEE>;
4446

47+
type AssigneeListItem = ListItem & {
48+
value: string;
49+
};
50+
4551
function AssigneeStep({route}: AssigneeStepProps) {
4652
const policyID = route.params.policyID;
4753
const feed = route.params.feed;
@@ -82,6 +88,8 @@ function AssigneeStep({route}: AssigneeStepProps) {
8288
});
8389

8490
const isEditing = assignCard?.isEditing;
91+
// Freeze the assignee selected when the list opened so it can be pinned to the top of long member lists.
92+
const initialAssigneeEmail = useInitialSelection(assignCard?.cardToAssign?.email, {resetOnFocus: true});
8593

8694
const submit = (assignee: ListItem) => {
8795
const personalDetail = getPersonalDetailByEmail(assignee?.login ?? '');
@@ -162,7 +170,7 @@ function AssigneeStep({route}: AssigneeStepProps) {
162170
Navigation.goBack();
163171
};
164172

165-
const membersDetails: ListItem[] = [];
173+
const membersDetails: AssigneeListItem[] = [];
166174
if (policy?.employeeList) {
167175
for (const [email, policyEmployee] of Object.entries(policy.employeeList ?? {})) {
168176
if (isDeletedPolicyEmployee(policyEmployee, isOffline)) {
@@ -175,6 +183,7 @@ function AssigneeStep({route}: AssigneeStepProps) {
175183
text: personalDetail?.displayName,
176184
alternateText: email,
177185
login: email,
186+
value: email,
178187
accountID: personalDetail?.accountID,
179188
isSelected: assignCard?.cardToAssign?.email === email,
180189
icons: [
@@ -191,10 +200,14 @@ function AssigneeStep({route}: AssigneeStepProps) {
191200
sortAlphabetically(membersDetails, 'text', localeCompare);
192201
}
193202

194-
let assignees = filterGuideAndAccountManager(membersDetails, assignedGuideEmail, accountManagerLogin);
203+
// Pin the currently-assigned member to the top of the full member list, then reuse the pinned list for both
204+
// the base list and the search source below so it stays pinned while searching (when it still matches).
205+
// moveInitialSelectionToTop no-ops for lists under the search-box threshold.
206+
const orderedMembersDetails = moveInitialSelectionToTop(membersDetails, initialAssigneeEmail ? [initialAssigneeEmail] : []);
207+
let assignees: ListItem[] = filterGuideAndAccountManager(orderedMembersDetails, assignedGuideEmail, accountManagerLogin);
195208
if (debouncedSearchTerm && areOptionsInitialized) {
196209
const searchValueForOptions = getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode).toLowerCase();
197-
const filteredMembers = filterGuideAndAccountManager(membersDetails, assignedGuideEmail, accountManagerLogin);
210+
const filteredMembers = filterGuideAndAccountManager(orderedMembersDetails, assignedGuideEmail, accountManagerLogin);
198211
const filteredOptions = tokenizedSearch(filteredMembers, searchValueForOptions, (option) => [option.text ?? '', option.alternateText ?? '']);
199212

200213
const options = canInviteMembers
@@ -257,11 +270,15 @@ function AssigneeStep({route}: AssigneeStepProps) {
257270
>
258271
<Text style={[styles.textHeadlineLineHeightXXL, styles.ph5, styles.mv3]}>{translate('workspace.companyCards.chooseTheCardholder')}</Text>
259272
<SelectionList
273+
// Reset the list instance when the frozen selection changes on re-entry, so returning via the back
274+
// button remounts the list scrolled to the top with the selected assignee pinned and visible.
275+
key={initialAssigneeEmail ?? ''}
260276
data={assignees}
261277
onSelectRow={submit}
262278
ListItem={UserListItem}
263279
textInputOptions={textInputOptions}
264-
initiallyFocusedItemKey={assignCard?.cardToAssign?.email}
280+
initiallyFocusedItemKey={initialAssigneeEmail}
281+
shouldScrollToFocusedIndexOnMount={false}
265282
shouldShowLoadingPlaceholder={!areOptionsInitialized}
266283
isLoadingNewOptions={canInviteMembers && !!isSearchingForReports}
267284
disableMaintainingScrollPosition
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import {act, render} from '@testing-library/react-native';
2+
3+
import SelectionList from '@components/SelectionList';
4+
5+
import AssigneeStep from '@pages/workspace/companyCards/assignCard/AssigneeStep';
6+
7+
import CONST from '@src/CONST';
8+
import type * as OnyxKeysModule from '@src/ONYXKEYS';
9+
import type {Policy} from '@src/types/onyx';
10+
11+
import type * as ReactNavigation from '@react-navigation/native';
12+
import type {PropsWithChildren} from 'react';
13+
14+
import React from 'react';
15+
16+
const mockUseState = React.useState;
17+
18+
const POLICY_ID = 'policy1';
19+
const FEED = 'feed1';
20+
const CARD_ID = 'card1';
21+
// "user09" sorts to the middle by display name, so seeing it first proves pinning (not the sort) put it there.
22+
const INITIAL_ASSIGNEE = 'user09@example.com';
23+
24+
// The current assignee comes from Onyx; a mutable holder lets each test set it (and clear it) before render.
25+
let mockAssigneeEmail: string | undefined;
26+
let mockPolicy: Policy | undefined;
27+
28+
/** Build a policy whose employeeList has `count` members keyed user00..user{count-1} (zero-padded so the display-name sort is stable). */
29+
function buildPolicy(count: number): Policy {
30+
const employeeList: Record<string, {email: string; role: string}> = {};
31+
for (let index = 0; index < count; index++) {
32+
const email = `user${String(index).padStart(2, '0')}@example.com`;
33+
employeeList[email] = {email, role: CONST.POLICY.ROLE.USER};
34+
}
35+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only minimal Policy stub
36+
return {id: POLICY_ID, owner: 'owner@example.com', employeeList} as unknown as Policy;
37+
}
38+
39+
jest.mock('@react-navigation/native', () => {
40+
const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native');
41+
return {
42+
...actualNavigation,
43+
// No-op focus effect: useInitialSelection still freezes via its useState seed, which is what we assert on.
44+
useFocusEffect: jest.fn(),
45+
};
46+
});
47+
48+
jest.mock('@components/SelectionList', () => jest.fn(() => null));
49+
jest.mock('@components/SelectionList/ListItem/UserListItem', () => jest.fn(() => null));
50+
jest.mock('@components/InteractiveStepWrapper', () => jest.fn(({children}: PropsWithChildren) => children));
51+
jest.mock('@components/Text', () => jest.fn(() => null));
52+
jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => jest.fn(({children}: PropsWithChildren) => children));
53+
54+
jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})})));
55+
jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false})));
56+
jest.mock('@hooks/useLazyAsset', () => ({
57+
useMemoizedLazyExpensifyIcons: jest.fn(() => ({FallbackAvatar: 'fallback-avatar'})),
58+
}));
59+
jest.mock('@hooks/useLocalize', () =>
60+
jest.fn(() => ({
61+
translate: (key: string) => key,
62+
localeCompare: (a: string, b: string) => a.localeCompare(b),
63+
formatPhoneNumber: (value: string) => value,
64+
})),
65+
);
66+
jest.mock('@hooks/usePolicy', () => jest.fn(() => mockPolicy));
67+
jest.mock('@hooks/useOnyx', () => {
68+
const OnyxKeys = jest.requireActual<typeof OnyxKeysModule>('@src/ONYXKEYS').default;
69+
return jest.fn((key: string) => {
70+
if (key === OnyxKeys.ASSIGN_CARD) {
71+
return [{cardToAssign: {email: mockAssigneeEmail}}];
72+
}
73+
if (key === OnyxKeys.SESSION) {
74+
return [{email: 'current@example.com'}];
75+
}
76+
if (key === OnyxKeys.COUNTRY_CODE) {
77+
return ['US'];
78+
}
79+
return [undefined];
80+
});
81+
});
82+
jest.mock('@hooks/usePersonalDetailSearchSelector', () =>
83+
jest.fn(() => {
84+
const [searchTerm, setSearchTerm] = mockUseState('');
85+
return {
86+
searchTerm,
87+
setSearchTerm,
88+
debouncedSearchTerm: searchTerm,
89+
availableOptions: {selectedOptions: [], recentOptions: [], personalDetails: [], userToInvite: null},
90+
areOptionsInitialized: true,
91+
};
92+
}),
93+
);
94+
95+
jest.mock('@libs/PersonalDetailsUtils', () => ({
96+
getPersonalDetailByEmail: jest.fn((email: string) => {
97+
const index = Number(email.replace('user', '').replace('@example.com', ''));
98+
return {displayName: `User ${email.replace('user', '').replace('@example.com', '')}`, accountID: index, login: email, avatar: ''};
99+
}),
100+
}));
101+
jest.mock('@libs/PolicyUtils', () => ({
102+
canMemberWrite: jest.fn(() => false),
103+
filterGuideAndAccountManager: jest.fn((items: unknown[]) => items),
104+
getGuideAndAccountManagerInfo: jest.fn(() => ({assignedGuideEmail: undefined, accountManagerLogin: undefined, exclusions: {}})),
105+
getIneligibleInvitees: jest.fn(() => []),
106+
isDeletedPolicyEmployee: jest.fn(() => false),
107+
}));
108+
jest.mock('@libs/OptionsListUtils', () => ({
109+
sortAlphabetically: (items: Array<Record<string, string>>, key: string, cmp: (a: string, b: string) => number) => [...items].sort((a, b) => cmp(a[key] ?? '', b[key] ?? '')),
110+
getSearchValueForPhoneOrEmail: (value: string) => value,
111+
}));
112+
jest.mock('@libs/PersonalDetailOptionsListUtils', () => ({
113+
getHeaderMessage: jest.fn(() => ''),
114+
}));
115+
jest.mock('@navigation/Navigation', () => ({navigate: jest.fn(), goBack: jest.fn()}));
116+
jest.mock('@libs/actions/Report', () => ({searchUserInServer: jest.fn()}));
117+
jest.mock('@libs/actions/Card', () => ({setDraftInviteAccountID: jest.fn()}));
118+
jest.mock('@userActions/CompanyCards', () => ({setAssignCardStepAndData: jest.fn()}));
119+
jest.mock('@libs/CardUtils', () => ({
120+
getCardAssignmentDateOption: jest.fn(),
121+
getCardAssignmentStartDate: jest.fn(),
122+
getDefaultCardName: jest.fn(() => ''),
123+
}));
124+
125+
type MockSelectionListProps = {
126+
data: Array<{value?: string; keyForList?: string; isSelected?: boolean; text?: string}>;
127+
initiallyFocusedItemKey?: string;
128+
shouldScrollToFocusedIndexOnMount?: boolean;
129+
shouldUpdateFocusedIndex?: boolean;
130+
textInputOptions?: {onChangeText?: (value: string) => void};
131+
};
132+
133+
function renderStep() {
134+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only route stub; the page only reads route.params
135+
const props = {route: {params: {policyID: POLICY_ID, feed: FEED, cardID: CARD_ID}}} as unknown as React.ComponentProps<typeof AssigneeStep>;
136+
return render(<AssigneeStep {...props} />);
137+
}
138+
139+
describe('AssignCard AssigneeStep', () => {
140+
const mockedSelectionList = jest.mocked(SelectionList);
141+
const getSelectionListProps = () => mockedSelectionList.mock.lastCall?.[0] as MockSelectionListProps | undefined;
142+
143+
beforeEach(() => {
144+
mockedSelectionList.mockClear();
145+
mockPolicy = buildPolicy(CONST.STANDARD_LIST_ITEM_LIMIT + 2);
146+
mockAssigneeEmail = INITIAL_ASSIGNEE;
147+
});
148+
149+
it('pins the current assignee to the top and disables mount-time focused scroll', () => {
150+
renderStep();
151+
152+
const props = getSelectionListProps();
153+
154+
expect(props?.data.at(0)?.value).toBe(INITIAL_ASSIGNEE);
155+
expect(props?.data.at(0)?.isSelected).toBe(true);
156+
// Alphabetically "user00" would be first if nothing were pinned.
157+
expect(props?.data.at(0)?.value).not.toBe('user00@example.com');
158+
expect(props?.initiallyFocusedItemKey).toBe(INITIAL_ASSIGNEE);
159+
// Not scrolling to the focused item on mount keeps the pinned assignee visible at the top when returning via back.
160+
expect(props?.shouldScrollToFocusedIndexOnMount).toBe(false);
161+
expect(props?.shouldUpdateFocusedIndex).toBe(true);
162+
});
163+
164+
it('does not reorder when the member list is under the item-limit threshold', () => {
165+
mockPolicy = buildPolicy(CONST.STANDARD_LIST_ITEM_LIMIT - 2);
166+
mockAssigneeEmail = 'user05@example.com';
167+
168+
renderStep();
169+
170+
const props = getSelectionListProps();
171+
172+
// Below the threshold moveInitialSelectionToTop is a no-op, so the natural alphabetical order is preserved.
173+
expect(props?.data.at(0)?.value).toBe('user00@example.com');
174+
});
175+
176+
it('keeps the pinned assignee at the top while searching', () => {
177+
renderStep();
178+
179+
act(() => {
180+
getSelectionListProps()?.textInputOptions?.onChangeText?.('User 09');
181+
});
182+
183+
const props = getSelectionListProps();
184+
expect(props?.data.at(0)?.value).toBe(INITIAL_ASSIGNEE);
185+
});
186+
});

0 commit comments

Comments
 (0)