Skip to content

Commit 6f24ae1

Browse files
authored
Merge pull request Expensify#76318 from TaduJR/feat-Add-an-exposed-filter-for-workspace-when-a-user-is-a-member-of-several-workspaces
follow-up: Reports - Workspace quick filter has no search field when there are at least 12 workspaces
2 parents 22f7828 + 203bdc0 commit 6f24ae1

10 files changed

Lines changed: 242 additions & 34 deletions

File tree

src/CONST/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3901,7 +3901,7 @@ const CONST = {
39013901
// Character Limits
39023902
FORM_CHARACTER_LIMIT: 50,
39033903
STANDARD_LENGTH_LIMIT: 100,
3904-
STANDARD_LIST_ITEM_LIMIT: 8,
3904+
STANDARD_LIST_ITEM_LIMIT: 12,
39053905
LEGAL_NAMES_CHARACTER_LIMIT: 150,
39063906
LOGIN_CHARACTER_LIMIT: 254,
39073907
CATEGORY_NAME_LIMIT: 256,

src/components/Search/FilterDropdowns/MultiSelectPopup.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ import SelectionList from '@components/SelectionList';
55
import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem';
66
import type {ListItem} from '@components/SelectionList/ListItem/types';
77
import Text from '@components/Text';
8+
import useDebouncedState from '@hooks/useDebouncedState';
89
import useLocalize from '@hooks/useLocalize';
910
import useResponsiveLayout from '@hooks/useResponsiveLayout';
1011
import useThemeStyles from '@hooks/useThemeStyles';
1112
import useWindowDimensions from '@hooks/useWindowDimensions';
13+
import type {Icon} from '@src/types/onyx/OnyxCommon';
1214

1315
type MultiSelectItem<T> = {
1416
text: string;
1517
value: T;
18+
icons?: Icon[];
1619
};
1720

1821
type MultiSelectPopupProps<T> = {
@@ -30,23 +33,34 @@ type MultiSelectPopupProps<T> = {
3033

3134
/** Function to call when changes are applied */
3235
onChange: (item: Array<MultiSelectItem<T>>) => void;
36+
37+
/** Whether the search input should be displayed. */
38+
isSearchable?: boolean;
39+
40+
/** Search input placeholder. Defaults to 'common.search' when not provided. */
41+
searchPlaceholder?: string;
3342
};
3443

35-
function MultiSelectPopup<T extends string>({label, value, items, closeOverlay, onChange}: MultiSelectPopupProps<T>) {
44+
function MultiSelectPopup<T extends string>({label, value, items, closeOverlay, onChange, isSearchable, searchPlaceholder}: MultiSelectPopupProps<T>) {
3645
const {translate} = useLocalize();
3746
const styles = useThemeStyles();
3847
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
3948
const {isSmallScreenWidth} = useResponsiveLayout();
4049
const {windowHeight} = useWindowDimensions();
4150
const [selectedItems, setSelectedItems] = useState(value);
51+
const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
4252

4353
const listData: ListItem[] = useMemo(() => {
44-
return items.map((item) => ({
54+
const filteredItems = isSearchable ? items.filter((item) => item.text.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) : items;
55+
return filteredItems.map((item) => ({
4556
text: item.text,
4657
keyForList: item.value,
4758
isSelected: !!selectedItems.find((i) => i.value === item.value),
59+
icons: item.icons,
4860
}));
49-
}, [items, selectedItems]);
61+
}, [items, selectedItems, isSearchable, debouncedSearchTerm]);
62+
63+
const headerMessage = isSearchable && listData.length === 0 ? translate('common.noResultsFound') : undefined;
5064

5165
const updateSelectedItems = useCallback(
5266
(item: ListItem) => {
@@ -74,16 +88,27 @@ function MultiSelectPopup<T extends string>({label, value, items, closeOverlay,
7488
closeOverlay();
7589
}, [closeOverlay, onChange]);
7690

91+
const textInputOptions = useMemo(
92+
() => ({
93+
value: searchTerm,
94+
label: isSearchable ? (searchPlaceholder ?? translate('common.search')) : undefined,
95+
onChangeText: setSearchTerm,
96+
headerMessage,
97+
}),
98+
[searchTerm, isSearchable, searchPlaceholder, translate, setSearchTerm, headerMessage],
99+
);
100+
77101
return (
78102
<View style={[!isSmallScreenWidth && styles.pv4, styles.gap2]}>
79103
{isSmallScreenWidth && <Text style={[styles.textLabel, styles.textSupporting, styles.ph5, styles.pv1]}>{label}</Text>}
80104

81-
<View style={[styles.getSelectionListPopoverHeight(items.length, windowHeight, false)]}>
105+
<View style={[styles.getSelectionListPopoverHeight(listData.length || 1, windowHeight, isSearchable ?? false)]}>
82106
<SelectionList
83107
shouldSingleExecuteRowSelect
84108
data={listData}
85109
ListItem={MultiSelectListItem}
86110
onSelectRow={updateSelectedItems}
111+
textInputOptions={textInputOptions}
87112
/>
88113
</View>
89114

src/components/Search/FilterDropdowns/UserSelectPopup.tsx

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,16 @@ type UserSelectPopupProps = {
4242

4343
/** Function to call when changes are applied */
4444
onChange: (value: string[]) => void;
45+
46+
/**
47+
* Whether the search input should be displayed.
48+
* When undefined, defaults to showing search when user count >= CONST.STANDARD_LIST_ITEM_LIMIT (12 users).
49+
* Set to true to always show search, or false to never show search regardless of user count.
50+
*/
51+
isSearchable?: boolean;
4552
};
4653

47-
function UserSelectPopup({value, closeOverlay, onChange}: UserSelectPopupProps) {
54+
function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSelectPopupProps) {
4855
const selectionListRef = useRef<SelectionListHandle | null>(null);
4956
const styles = useThemeStyles();
5057
const {translate} = useLocalize();
@@ -171,21 +178,25 @@ function UserSelectPopup({value, closeOverlay, onChange}: UserSelectPopupProps)
171178
}, [closeOverlay, onChange]);
172179

173180
const isLoadingNewOptions = !!isSearchingForReports;
174-
const dataLength = listData.length;
181+
const totalOptionsCount = optionsList.personalDetails.length + optionsList.recentReports.length;
182+
const shouldShowSearchInput = isSearchable ?? totalOptionsCount >= CONST.STANDARD_LIST_ITEM_LIMIT;
175183

176184
const textInputOptions = useMemo(
177-
() => ({
178-
value: searchTerm,
179-
label: translate('selectionList.searchForSomeone'),
180-
onChangeText: setSearchTerm,
181-
headerMessage,
182-
disableAutoFocus: !shouldFocusInputOnScreenFocus,
183-
}),
184-
[searchTerm, translate, headerMessage, shouldFocusInputOnScreenFocus],
185+
() =>
186+
shouldShowSearchInput
187+
? {
188+
value: searchTerm,
189+
label: translate('selectionList.searchForSomeone'),
190+
onChangeText: setSearchTerm,
191+
headerMessage,
192+
disableAutoFocus: !shouldFocusInputOnScreenFocus,
193+
}
194+
: undefined,
195+
[searchTerm, translate, headerMessage, shouldFocusInputOnScreenFocus, shouldShowSearchInput],
185196
);
186197

187198
return (
188-
<View style={[styles.getUserSelectionListPopoverHeight(dataLength || 1, windowHeight, shouldUseNarrowLayout)]}>
199+
<View style={[styles.getUserSelectionListPopoverHeight(listData.length || 1, windowHeight, shouldUseNarrowLayout, shouldShowSearchInput)]}>
189200
<SelectionList
190201
data={listData}
191202
ref={selectionListRef}

src/components/Search/SearchPageHeader/SearchFiltersBar.tsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import type {SearchAdvancedFiltersForm} from '@src/types/form';
5454
import FILTER_KEYS, {AMOUNT_FILTER_KEYS, DATE_FILTER_KEYS} from '@src/types/form/SearchAdvancedFiltersForm';
5555
import type {SearchAdvancedFiltersKey} from '@src/types/form/SearchAdvancedFiltersForm';
5656
import type {CurrencyList, Policy} from '@src/types/onyx';
57+
import type {Icon} from '@src/types/onyx/OnyxCommon';
5758
import {getEmptyObject} from '@src/types/utils/EmptyObject';
5859
import type {SearchHeaderOptionValue} from './SearchPageHeader';
5960

@@ -117,7 +118,7 @@ function SearchFiltersBar({
117118
const taxRates = getAllTaxRates(allPolicies);
118119

119120
// Get workspace data for the filter
120-
const {sections: workspaces} = useWorkspaceList({
121+
const {sections: workspaces, shouldShowSearchInput: shouldShowWorkspaceSearchInput} = useWorkspaceList({
121122
policies: allPolicies,
122123
currentUserLogin: email,
123124
shouldShowPendingDeletePolicy: false,
@@ -131,10 +132,11 @@ function SearchFiltersBar({
131132
const workspaceOptions = useMemo<Array<MultiSelectItem<string>>>(() => {
132133
return workspaces
133134
.flatMap((section) => section.data)
134-
.filter((workspace): workspace is typeof workspace & {policyID: string} => !!workspace.policyID)
135+
.filter((workspace): workspace is typeof workspace & {policyID: string; icons: Icon[]} => !!workspace.policyID && !!workspace.icons)
135136
.map((workspace) => ({
136137
text: workspace.text,
137138
value: workspace.policyID,
139+
icons: workspace.icons,
138140
}));
139141
}, [workspaces]);
140142

@@ -424,6 +426,7 @@ function SearchFiltersBar({
424426
items: Array<MultiSelectItem<T>>,
425427
value: Array<MultiSelectItem<T>>,
426428
onChangeCallback: (selectedItems: Array<MultiSelectItem<T>>) => void,
429+
isSearchable?: boolean,
427430
) => {
428431
return ({closeOverlay}: PopoverComponentProps) => {
429432
return (
@@ -433,6 +436,7 @@ function SearchFiltersBar({
433436
value={value}
434437
closeOverlay={closeOverlay}
435438
onChange={onChangeCallback}
439+
isSearchable={isSearchable}
436440
/>
437441
);
438442
};
@@ -508,12 +512,28 @@ function SearchFiltersBar({
508512
[filterFormValues.from, updateFilterForm],
509513
);
510514

511-
const workspaceComponent = useMemo(() => {
512-
const updateWorkspaceFilterForm = (items: Array<MultiSelectItem<string>>) => {
515+
const handleWorkspaceChange = useCallback(
516+
(items: Array<MultiSelectItem<string>>) => {
513517
updateFilterForm({policyID: items.map((item) => item.value)});
514-
};
515-
return createMultiSelectComponent('workspace.common.workspace', workspaceOptions, selectedWorkspaceOptions, updateWorkspaceFilterForm);
516-
}, [createMultiSelectComponent, workspaceOptions, selectedWorkspaceOptions, updateFilterForm]);
518+
},
519+
[updateFilterForm],
520+
);
521+
522+
const workspaceComponent = useCallback(
523+
({closeOverlay}: PopoverComponentProps) => {
524+
return (
525+
<MultiSelectPopup
526+
label={translate('workspace.common.workspace')}
527+
items={workspaceOptions}
528+
value={selectedWorkspaceOptions}
529+
closeOverlay={closeOverlay}
530+
onChange={handleWorkspaceChange}
531+
isSearchable={shouldShowWorkspaceSearchInput}
532+
/>
533+
);
534+
},
535+
[workspaceOptions, selectedWorkspaceOptions, handleWorkspaceChange, shouldShowWorkspaceSearchInput, translate],
536+
);
517537

518538
const workspaceValue = useMemo(() => selectedWorkspaceOptions.map((option) => option.text), [selectedWorkspaceOptions]);
519539

src/components/SelectionList/ListItem/MultiSelectListItem.tsx

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import React, {useCallback} from 'react';
1+
import React, {useCallback, useMemo} from 'react';
2+
import {View} from 'react-native';
3+
import Avatar from '@components/Avatar';
24
import Checkbox from '@components/Checkbox';
35
import useThemeStyles from '@hooks/useThemeStyles';
6+
import CONST from '@src/CONST';
47
import RadioListItem from './RadioListItem';
58
import type {ListItem, MultiSelectListItemProps} from './types';
69

710
/**
8-
* MultiSelectListItem mirrors the behavior of a default RadioListItem, but adds support
9-
* for the new style of multi selection lists.
11+
* MultiSelectListItem extends RadioListItem with multi-selection support.
12+
* Renders an avatar when icons are provided.
1013
*/
1114
function MultiSelectListItem<TItem extends ListItem>({
1215
item,
@@ -25,6 +28,7 @@ function MultiSelectListItem<TItem extends ListItem>({
2528
titleStyles,
2629
}: MultiSelectListItemProps<TItem>) {
2730
const styles = useThemeStyles();
31+
const icon = item.icons?.at(0);
2832

2933
const checkboxComponent = useCallback(() => {
3034
return (
@@ -37,9 +41,36 @@ function MultiSelectListItem<TItem extends ListItem>({
3741
);
3842
}, [item, onSelectRow]);
3943

44+
const {itemWithAvatar, computedWrapperStyle} = useMemo(() => {
45+
if (!icon) {
46+
return {
47+
itemWithAvatar: item,
48+
computedWrapperStyle: [wrapperStyle, styles.optionRowCompact],
49+
};
50+
}
51+
52+
const avatarElement = (
53+
<View style={[styles.mentionSuggestionsAvatarContainer, styles.mr3]}>
54+
<Avatar
55+
source={icon.source}
56+
size={CONST.AVATAR_SIZE.SMALLER}
57+
name={icon.name}
58+
avatarID={icon.id}
59+
type={icon.type ?? CONST.ICON_TYPE_AVATAR}
60+
fallbackIcon={icon.fallbackIcon}
61+
/>
62+
</View>
63+
);
64+
65+
return {
66+
itemWithAvatar: {...item, leftElement: avatarElement},
67+
computedWrapperStyle: [wrapperStyle, styles.pv0, styles.mnh13],
68+
};
69+
}, [icon, item, wrapperStyle, styles.mentionSuggestionsAvatarContainer, styles.mr3, styles.optionRowCompact, styles.pv0, styles.mnh13]);
70+
4071
return (
4172
<RadioListItem
42-
item={item}
73+
item={itemWithAvatar}
4374
keyForList={item.keyForList}
4475
isFocused={isFocused}
4576
showTooltip={showTooltip}
@@ -53,7 +84,7 @@ function MultiSelectListItem<TItem extends ListItem>({
5384
alternateTextNumberOfLines={alternateTextNumberOfLines}
5485
onFocus={onFocus}
5586
shouldSyncFocus={shouldSyncFocus}
56-
wrapperStyle={[wrapperStyle, styles.optionRowCompact]}
87+
wrapperStyle={computedWrapperStyle}
5788
titleStyles={titleStyles}
5889
/>
5990
);

src/pages/ReportParticipantsPage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
124124
return !pendingMember || isOffline || pendingMember.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
125125
});
126126

127-
// Include the search bar when there are 8 or more active members in the selection list
127+
// Include the search bar when there are STANDARD_LIST_ITEM_LIMIT or more active members in the selection list
128128
const shouldShowTextInput = activeParticipants.length >= CONST.STANDARD_LIST_ITEM_LIMIT;
129129

130130
useEffect(() => {
@@ -417,6 +417,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
417417
Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID, backTo));
418418
}
419419
}}
420+
// eslint-disable-next-line @typescript-eslint/no-deprecated
420421
subtitle={StringUtils.lineBreaksToSpaces(getReportName(report, reportAttributes))}
421422
/>
422423
<View style={[styles.pl5, styles.pr5]}>{headerButtons}</View>

src/pages/RoomMembersPage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ function RoomMembersPage({report, policy}: RoomMembersPageProps) {
200200
}
201201
};
202202

203-
/** Include the search bar when there are 8 or more active members in the selection list */
203+
/** Include the search bar when there are STANDARD_LIST_ITEM_LIMIT or more active members in the selection list */
204204
const shouldShowTextInput = useMemo(() => {
205205
// Get the active chat members by filtering out the pending members with delete action
206206
const activeParticipants = participants.filter((accountID) => {
@@ -403,6 +403,7 @@ function RoomMembersPage({report, policy}: RoomMembersPageProps) {
403403
>
404404
<HeaderWithBackButton
405405
title={selectionModeHeader ? translate('common.selectMultiple') : translate('workspace.common.members')}
406+
// eslint-disable-next-line @typescript-eslint/no-deprecated
406407
subtitle={StringUtils.lineBreaksToSpaces(getReportName(report))}
407408
onBackButtonPress={() => {
408409
if (isMobileSelectionModeEnabled) {

src/styles/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5888,10 +5888,10 @@ const dynamicStyles = (theme: ThemeColors) =>
58885888
return {height};
58895889
},
58905890

5891-
getUserSelectionListPopoverHeight: (itemCount: number, windowHeight: number, shouldUseNarrowLayout: boolean) => {
5891+
getUserSelectionListPopoverHeight: (itemCount: number, windowHeight: number, shouldUseNarrowLayout: boolean, isSearchable = true) => {
58925892
const BUTTON_HEIGHT = 40;
5893-
const SEARCHBAR_HEIGHT = 50;
5894-
const SEARCHBAR_MARGIN = 14;
5893+
const SEARCHBAR_HEIGHT = isSearchable ? 50 : 0;
5894+
const SEARCHBAR_MARGIN = isSearchable ? 14 : 0;
58955895
const PADDING = 44 - (shouldUseNarrowLayout ? 32 : 0);
58965896
const ESTIMATED_LIST_HEIGHT = itemCount * variables.optionRowHeightCompact + SEARCHBAR_HEIGHT + SEARCHBAR_MARGIN + BUTTON_HEIGHT + PADDING;
58975897

0 commit comments

Comments
 (0)