Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
49ed3df
fix: Reports - Workspace quick filter has no search field when there …
TaduJR Dec 2, 2025
1cfe11a
fix: deprecated getReportName usage in ReportParticipantsPage and Roo…
TaduJR Dec 2, 2025
72cb18f
fix: remove unused variables in ReportParticipantsPage page
TaduJR Dec 2, 2025
a0ad053
Merge branch 'Expensify:main' into feat-Add-an-exposed-filter-for-wor…
TaduJR Dec 3, 2025
778309d
Merge branch 'Expensify:main' into feat-Add-an-exposed-filter-for-wor…
TaduJR Dec 3, 2025
e3bf98f
Merge branch 'main' of https://github.com/TaduJR/App into feat-Add-an…
TaduJR Dec 4, 2025
92dec2d
refactor: workspace filter to extend MultiSelectPopup instead of sepa…
TaduJR Dec 4, 2025
2a76119
fix: workspace filter button overlap by constraining row height
TaduJR Dec 4, 2025
ac00728
Merge branch 'main' of https://github.com/TaduJR/App into feat-Add-an…
TaduJR Dec 6, 2025
00b653a
Merge branch 'main' into feat-Add-an-exposed-filter-for-workspace-whe…
TaduJR Dec 6, 2025
0cf09fd
Merge branch 'main' into feat-Add-an-exposed-filter-for-workspace-whe…
TaduJR Dec 6, 2025
44ebf78
Merge branch 'main' of https://github.com/TaduJR/App into feat-Add-an…
TaduJR Dec 9, 2025
4db7b6b
fix: prevent search input from hiding on filter and add no results me…
TaduJR Dec 10, 2025
c062c4f
fix: refactor MultiSelectPopup memoization for React Compiler compliance
TaduJR Dec 10, 2025
ea5939a
fix: separate filteredItems memo for React Compiler compliance
TaduJR Dec 10, 2025
1336839
fix: simplify headerMessage to derived value for React Compiler compl…
TaduJR Dec 10, 2025
1ebae27
Merge branch 'main' of https://github.com/TaduJR/App into feat-Add-an…
TaduJR Dec 10, 2025
203bdc0
Merge branch 'Expensify:main' into feat-Add-an-exposed-filter-for-wor…
TaduJR Dec 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3899,7 +3899,7 @@ const CONST = {
// Character Limits
FORM_CHARACTER_LIMIT: 50,
STANDARD_LENGTH_LIMIT: 100,
STANDARD_LIST_ITEM_LIMIT: 8,
STANDARD_LIST_ITEM_LIMIT: 12,
LEGAL_NAMES_CHARACTER_LIMIT: 150,
LOGIN_CHARACTER_LIMIT: 254,
CATEGORY_NAME_LIMIT: 256,
Expand Down
33 changes: 29 additions & 4 deletions src/components/Search/FilterDropdowns/MultiSelectPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import SelectionList from '@components/SelectionList';
import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem';
import type {ListItem} from '@components/SelectionList/ListItem/types';
import Text from '@components/Text';
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import type {Icon} from '@src/types/onyx/OnyxCommon';

type MultiSelectItem<T> = {
text: string;
value: T;
icons?: Icon[];
};

type MultiSelectPopupProps<T> = {
Expand All @@ -30,23 +33,34 @@ type MultiSelectPopupProps<T> = {

/** Function to call when changes are applied */
onChange: (item: Array<MultiSelectItem<T>>) => void;

/** Whether the search input should be displayed. */
isSearchable?: boolean;

/** Search input placeholder. Defaults to 'common.search' when not provided. */
searchPlaceholder?: string;
};

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

const listData: ListItem[] = useMemo(() => {
return items.map((item) => ({
const filteredItems = isSearchable ? items.filter((item) => item.text.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) : items;
return filteredItems.map((item) => ({
text: item.text,
keyForList: item.value,
isSelected: !!selectedItems.find((i) => i.value === item.value),
icons: item.icons,
}));
}, [items, selectedItems]);
}, [items, selectedItems, isSearchable, debouncedSearchTerm]);

const headerMessage = isSearchable && listData.length === 0 ? translate('common.noResultsFound') : undefined;

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

const textInputOptions = useMemo(
() => ({
value: searchTerm,
label: isSearchable ? (searchPlaceholder ?? translate('common.search')) : undefined,
onChangeText: setSearchTerm,
Comment thread
TaduJR marked this conversation as resolved.
headerMessage,
}),
[searchTerm, isSearchable, searchPlaceholder, translate, setSearchTerm, headerMessage],
);

return (
<View style={[!isSmallScreenWidth && styles.pv4, styles.gap2]}>
{isSmallScreenWidth && <Text style={[styles.textLabel, styles.textSupporting, styles.ph5, styles.pv1]}>{label}</Text>}

<View style={[styles.getSelectionListPopoverHeight(items.length, windowHeight, false)]}>
<View style={[styles.getSelectionListPopoverHeight(listData.length || 1, windowHeight, isSearchable ?? false)]}>
<SelectionList
shouldSingleExecuteRowSelect
data={listData}
ListItem={MultiSelectListItem}
Comment thread
TaduJR marked this conversation as resolved.
onSelectRow={updateSelectedItems}
textInputOptions={textInputOptions}
/>
</View>

Expand Down
33 changes: 22 additions & 11 deletions src/components/Search/FilterDropdowns/UserSelectPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,16 @@ type UserSelectPopupProps = {

/** Function to call when changes are applied */
onChange: (value: string[]) => void;

/**
* Whether the search input should be displayed.
* When undefined, defaults to showing search when user count >= CONST.STANDARD_LIST_ITEM_LIMIT (12 users).
* Set to true to always show search, or false to never show search regardless of user count.
*/
isSearchable?: boolean;
};

function UserSelectPopup({value, closeOverlay, onChange}: UserSelectPopupProps) {
function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSelectPopupProps) {
const selectionListRef = useRef<SelectionListHandle | null>(null);
const styles = useThemeStyles();
const {translate} = useLocalize();
Expand Down Expand Up @@ -171,21 +178,25 @@ function UserSelectPopup({value, closeOverlay, onChange}: UserSelectPopupProps)
}, [closeOverlay, onChange]);

const isLoadingNewOptions = !!isSearchingForReports;
const dataLength = listData.length;
const totalOptionsCount = optionsList.personalDetails.length + optionsList.recentReports.length;
const shouldShowSearchInput = isSearchable ?? totalOptionsCount >= CONST.STANDARD_LIST_ITEM_LIMIT;

const textInputOptions = useMemo(
() => ({
value: searchTerm,
label: translate('selectionList.searchForSomeone'),
onChangeText: setSearchTerm,
headerMessage,
disableAutoFocus: !shouldFocusInputOnScreenFocus,
}),
[searchTerm, translate, headerMessage, shouldFocusInputOnScreenFocus],
() =>
shouldShowSearchInput
? {
value: searchTerm,
label: translate('selectionList.searchForSomeone'),
onChangeText: setSearchTerm,
headerMessage,
disableAutoFocus: !shouldFocusInputOnScreenFocus,
}
: undefined,
[searchTerm, translate, headerMessage, shouldFocusInputOnScreenFocus, shouldShowSearchInput],
);

return (
<View style={[styles.getUserSelectionListPopoverHeight(dataLength || 1, windowHeight, shouldUseNarrowLayout)]}>
<View style={[styles.getUserSelectionListPopoverHeight(listData.length || 1, windowHeight, shouldUseNarrowLayout, shouldShowSearchInput)]}>
<SelectionList
data={listData}
ref={selectionListRef}
Expand Down
34 changes: 27 additions & 7 deletions src/components/Search/SearchPageHeader/SearchFiltersBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import type {SearchAdvancedFiltersForm} from '@src/types/form';
import FILTER_KEYS, {AMOUNT_FILTER_KEYS, DATE_FILTER_KEYS} from '@src/types/form/SearchAdvancedFiltersForm';
import type {SearchAdvancedFiltersKey} from '@src/types/form/SearchAdvancedFiltersForm';
import type {CurrencyList, Policy} from '@src/types/onyx';
import type {Icon} from '@src/types/onyx/OnyxCommon';
import {getEmptyObject} from '@src/types/utils/EmptyObject';
import type {SearchHeaderOptionValue} from './SearchPageHeader';

Expand Down Expand Up @@ -117,7 +118,7 @@ function SearchFiltersBar({
const taxRates = getAllTaxRates(allPolicies);

// Get workspace data for the filter
const {sections: workspaces} = useWorkspaceList({
const {sections: workspaces, shouldShowSearchInput: shouldShowWorkspaceSearchInput} = useWorkspaceList({
policies: allPolicies,
currentUserLogin: email,
shouldShowPendingDeletePolicy: false,
Expand All @@ -131,10 +132,11 @@ function SearchFiltersBar({
const workspaceOptions = useMemo<Array<MultiSelectItem<string>>>(() => {
return workspaces
.flatMap((section) => section.data)
.filter((workspace): workspace is typeof workspace & {policyID: string} => !!workspace.policyID)
.filter((workspace): workspace is typeof workspace & {policyID: string; icons: Icon[]} => !!workspace.policyID && !!workspace.icons)
.map((workspace) => ({
text: workspace.text,
value: workspace.policyID,
icons: workspace.icons,
}));
}, [workspaces]);

Expand Down Expand Up @@ -424,6 +426,7 @@ function SearchFiltersBar({
items: Array<MultiSelectItem<T>>,
value: Array<MultiSelectItem<T>>,
onChangeCallback: (selectedItems: Array<MultiSelectItem<T>>) => void,
isSearchable?: boolean,
) => {
return ({closeOverlay}: PopoverComponentProps) => {
return (
Expand All @@ -433,6 +436,7 @@ function SearchFiltersBar({
value={value}
closeOverlay={closeOverlay}
onChange={onChangeCallback}
isSearchable={isSearchable}
/>
);
};
Expand Down Expand Up @@ -508,12 +512,28 @@ function SearchFiltersBar({
[filterFormValues.from, updateFilterForm],
);

const workspaceComponent = useMemo(() => {
const updateWorkspaceFilterForm = (items: Array<MultiSelectItem<string>>) => {
const handleWorkspaceChange = useCallback(
(items: Array<MultiSelectItem<string>>) => {
updateFilterForm({policyID: items.map((item) => item.value)});
};
return createMultiSelectComponent('workspace.common.workspace', workspaceOptions, selectedWorkspaceOptions, updateWorkspaceFilterForm);
}, [createMultiSelectComponent, workspaceOptions, selectedWorkspaceOptions, updateFilterForm]);
},
[updateFilterForm],
);

const workspaceComponent = useCallback(
({closeOverlay}: PopoverComponentProps) => {
return (
<MultiSelectPopup
label={translate('workspace.common.workspace')}
items={workspaceOptions}
value={selectedWorkspaceOptions}
closeOverlay={closeOverlay}
onChange={handleWorkspaceChange}
isSearchable={shouldShowWorkspaceSearchInput}
/>
);
},
[workspaceOptions, selectedWorkspaceOptions, handleWorkspaceChange, shouldShowWorkspaceSearchInput, translate],
);

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

Expand Down
41 changes: 36 additions & 5 deletions src/components/SelectionList/ListItem/MultiSelectListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import React, {useCallback} from 'react';
import React, {useCallback, useMemo} from 'react';
import {View} from 'react-native';
import Avatar from '@components/Avatar';
import Checkbox from '@components/Checkbox';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import RadioListItem from './RadioListItem';
import type {ListItem, MultiSelectListItemProps} from './types';

/**
* MultiSelectListItem mirrors the behavior of a default RadioListItem, but adds support
* for the new style of multi selection lists.
* MultiSelectListItem extends RadioListItem with multi-selection support.
* Renders an avatar when icons are provided.
*/
function MultiSelectListItem<TItem extends ListItem>({
item,
Expand All @@ -25,6 +28,7 @@ function MultiSelectListItem<TItem extends ListItem>({
titleStyles,
}: MultiSelectListItemProps<TItem>) {
const styles = useThemeStyles();
const icon = item.icons?.at(0);

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

const {itemWithAvatar, computedWrapperStyle} = useMemo(() => {
if (!icon) {
return {
itemWithAvatar: item,
computedWrapperStyle: [wrapperStyle, styles.optionRowCompact],
};
}

const avatarElement = (
<View style={[styles.mentionSuggestionsAvatarContainer, styles.mr3]}>
<Avatar
source={icon.source}
size={CONST.AVATAR_SIZE.SMALLER}
name={icon.name}
avatarID={icon.id}
type={icon.type ?? CONST.ICON_TYPE_AVATAR}
fallbackIcon={icon.fallbackIcon}
/>
</View>
);

return {
itemWithAvatar: {...item, leftElement: avatarElement},
computedWrapperStyle: [wrapperStyle, styles.pv0, styles.mnh13],
};
}, [icon, item, wrapperStyle, styles.mentionSuggestionsAvatarContainer, styles.mr3, styles.optionRowCompact, styles.pv0, styles.mnh13]);

return (
<RadioListItem
item={item}
item={itemWithAvatar}
keyForList={item.keyForList}
isFocused={isFocused}
showTooltip={showTooltip}
Expand All @@ -53,7 +84,7 @@ function MultiSelectListItem<TItem extends ListItem>({
alternateTextNumberOfLines={alternateTextNumberOfLines}
onFocus={onFocus}
shouldSyncFocus={shouldSyncFocus}
wrapperStyle={[wrapperStyle, styles.optionRowCompact]}
wrapperStyle={computedWrapperStyle}
titleStyles={titleStyles}
/>
);
Expand Down
3 changes: 2 additions & 1 deletion src/pages/ReportParticipantsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
return !pendingMember || isOffline || pendingMember.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
});

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

useEffect(() => {
Expand Down Expand Up @@ -417,6 +417,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID, backTo));
}
}}
// eslint-disable-next-line @typescript-eslint/no-deprecated
subtitle={StringUtils.lineBreaksToSpaces(getReportName(report, reportAttributes))}
/>
<View style={[styles.pl5, styles.pr5]}>{headerButtons}</View>
Expand Down
3 changes: 2 additions & 1 deletion src/pages/RoomMembersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ function RoomMembersPage({report, policy}: RoomMembersPageProps) {
}
};

/** Include the search bar when there are 8 or more active members in the selection list */
/** Include the search bar when there are STANDARD_LIST_ITEM_LIMIT or more active members in the selection list */
const shouldShowTextInput = useMemo(() => {
// Get the active chat members by filtering out the pending members with delete action
const activeParticipants = participants.filter((accountID) => {
Expand Down Expand Up @@ -403,6 +403,7 @@ function RoomMembersPage({report, policy}: RoomMembersPageProps) {
>
<HeaderWithBackButton
title={selectionModeHeader ? translate('common.selectMultiple') : translate('workspace.common.members')}
// eslint-disable-next-line @typescript-eslint/no-deprecated
subtitle={StringUtils.lineBreaksToSpaces(getReportName(report))}
onBackButtonPress={() => {
if (isMobileSelectionModeEnabled) {
Expand Down
6 changes: 3 additions & 3 deletions src/styles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5888,10 +5888,10 @@ const dynamicStyles = (theme: ThemeColors) =>
return {height};
},

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

Expand Down
Loading
Loading