Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1985,6 +1985,8 @@ const CONST = {
GLOBAL_CREATE: '\uE100',
},

MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD: 8,

INVISIBLE_CODEPOINTS: ['fe0f', '200d', '2066'],

UNICODE: {
Expand Down
24 changes: 21 additions & 3 deletions src/components/CountryPicker/CountrySelectorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,28 @@ type CountrySelectorModalProps = {
function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onClose, label, onBackdropPress}: CountrySelectorModalProps) {
const {translate} = useLocalize();
const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState('');
const initialCountry = currentCountry;

const orderedCountryISOs = useMemo(() => {
const countryKeys = Object.keys(CONST.ALL_COUNTRIES);
if (!initialCountry || countryKeys.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) {
return countryKeys;
}
const selected: string[] = [];
const remaining: string[] = [];
for (const countryISO of countryKeys) {
if (countryISO === initialCountry) {
selected.push(countryISO);
} else {
remaining.push(countryISO);
}
}
return [...selected, ...remaining];
}, [initialCountry]);

const countries = useMemo(
() =>
Object.keys(CONST.ALL_COUNTRIES).map((countryISO) => {
orderedCountryISOs.map((countryISO) => {
const countryName = translate(`allCountries.${countryISO}` as TranslationPaths);
return {
value: countryISO,
Expand All @@ -49,10 +67,10 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC
searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`),
};
}),
[translate, currentCountry],
[translate, currentCountry, orderedCountryISOs],
);

const searchResults = searchOptions(debouncedSearchValue, countries);
const searchResults = useMemo(() => searchOptions(debouncedSearchValue, countries), [countries, debouncedSearchValue]);
const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '';

const styles = useThemeStyles();
Expand Down
41 changes: 32 additions & 9 deletions src/components/PushRowWithModal/PushRowModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,39 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio

const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState('');

const orderedOptionKeys = useMemo(() => {
const keys = Object.keys(optionsList);
if (!selectedOption || keys.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) {
return keys;
}

const selected: string[] = [];
const remaining: string[] = [];

for (const key of keys) {
if (key === selectedOption) {
selected.push(key);
} else {
remaining.push(key);
}
}

return [...selected, ...remaining];
}, [optionsList, selectedOption]);

const options = useMemo(
() =>
Object.entries(optionsList).map(([key, value]) => ({
value: key,
text: value,
keyForList: key,
isSelected: key === selectedOption,
searchValue: StringUtils.sanitizeString(value),
})),
[optionsList, selectedOption],
orderedOptionKeys.map((key) => {
const value = optionsList[key];
return {
value: key,
text: value,
keyForList: key,
isSelected: key === selectedOption,
searchValue: StringUtils.sanitizeString(value),
};
}),
[optionsList, selectedOption, orderedOptionKeys],
);

const handleSelectRow = (option: ListItemType) => {
Expand All @@ -67,7 +90,7 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio
setSearchValue('');
};

const searchResults = searchOptions(debouncedSearchValue, options);
const searchResults = useMemo(() => searchOptions(debouncedSearchValue, options), [debouncedSearchValue, options]);

const textInputOptions = useMemo(
() => ({
Expand Down
40 changes: 36 additions & 4 deletions src/components/Search/FilterDropdowns/MultiSelectPopup.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useCallback, useMemo, useState} from 'react';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import Button from '@components/Button';
import SelectionList from '@components/SelectionList';
Expand All @@ -10,6 +10,7 @@ import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import CONST from '@src/CONST';
import type {Icon} from '@src/types/onyx/OnyxCommon';

type MultiSelectItem<T> = {
Expand Down Expand Up @@ -39,26 +40,57 @@ type MultiSelectPopupProps<T> = {

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

/** Whether to move initially selected items to the top on open (no reordering while toggling). */
shouldMoveSelectedItemsToTopOnOpen?: boolean;
};

function MultiSelectPopup<T extends string>({label, value, items, closeOverlay, onChange, isSearchable, searchPlaceholder}: MultiSelectPopupProps<T>) {
function MultiSelectPopup<T extends string>({
label,
value,
items,
closeOverlay,
onChange,
isSearchable,
searchPlaceholder,
shouldMoveSelectedItemsToTopOnOpen = false,
}: 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 initialSelectedValuesRef = useRef<Set<string>>(new Set(value.map((item) => item.value)));

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

if (!shouldMoveSelectedItemsToTopOnOpen || mappedItems.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) {
return mappedItems;
}

const initialSelectedValues = initialSelectedValuesRef.current;
const initialItems: ListItem[] = [];
const remainingItems: ListItem[] = [];

for (const item of mappedItems) {
if (initialSelectedValues.has(item.keyForList)) {
initialItems.push(item);
} else {
remainingItems.push(item);
}
}

return [...initialItems, ...remainingItems];
}, [items, selectedItems, isSearchable, debouncedSearchTerm, shouldMoveSelectedItemsToTopOnOpen]);

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

Expand Down
48 changes: 23 additions & 25 deletions src/components/Search/FilterDropdowns/UserSelectPopup.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {accountIDSelector} from '@selectors/Session';
import isEmpty from 'lodash/isEmpty';
import React, {memo, useCallback, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
Expand Down Expand Up @@ -59,7 +58,6 @@ function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSele
const personalDetails = usePersonalDetails();
const {windowHeight} = useWindowDimensions();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const [accountID] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true, selector: accountIDSelector});
const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false});
const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST, {canBeMissing: true});
Expand Down Expand Up @@ -92,6 +90,10 @@ function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSele
return new Set(selectedOptions.map((option) => option.accountID).filter(Boolean));
}, [selectedOptions]);

const initialSelectedAccountIDs = useMemo(() => {
return new Set(initialSelectedOptions.map((option) => option.accountID).filter(Boolean));
}, [initialSelectedOptions]);

const optionsList = useMemo(() => {
return memoizedGetValidOptions(
{
Expand Down Expand Up @@ -119,42 +121,39 @@ function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSele
});
}, [optionsList, cleanSearchTerm, countryCode, loginList]);

const listData = useMemo(() => {
const personalDetailList = filteredOptions.personalDetails.map((participant) => ({
const listData = useMemo<Array<Option & {keyForList: string; isSelected: boolean}>>(() => {
const initialOptions: Array<Option & {keyForList: string; isSelected: boolean}> = [];
const remainingOptions: Array<Option & {keyForList: string; isSelected: boolean}> = [];

const personalDetailOptions = filteredOptions.personalDetails.map((participant) => ({
...participant,
isSelected: selectedAccountIDs.has(participant.accountID),
keyForList: String(participant.accountID),
}));

const recentReportsList = filteredOptions.recentReports.map((report) => ({
const recentReportOptions = filteredOptions.recentReports.map((report) => ({
...report,
isSelected: selectedAccountIDs.has(report.accountID),
keyForList: String(report.reportID),
}));

const combined = [...personalDetailList, ...recentReportsList];
const totalOptions = personalDetailOptions.length + recentReportOptions.length;
const reordered = [...personalDetailOptions, ...recentReportOptions];

combined.sort((a, b) => {
// selected items first
if (a.isSelected && !b.isSelected) {
return -1;
}
if (!a.isSelected && b.isSelected) {
return 1;
}
if (totalOptions <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) {
return reordered;
}

// Put the current user at the top of the list
if (a.accountID === accountID) {
return -1;
}
if (b.accountID === accountID) {
return 1;
for (const option of reordered) {
if (option.accountID && initialSelectedAccountIDs.has(option.accountID)) {
initialOptions.push(option);
} else {
remainingOptions.push(option);
}
return 0;
});
}

return combined;
}, [filteredOptions, accountID, selectedAccountIDs]);
return [...initialOptions, ...remainingOptions];
}, [filteredOptions, initialSelectedAccountIDs, selectedAccountIDs]);

const headerMessage = useMemo(() => {
const noResultsFound = isEmpty(listData);
Expand All @@ -166,7 +165,6 @@ function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSele
const isSelected = selectedOptions.some((selected) => optionsMatch(selected, option));

setSelectedOptions((prev) => (isSelected ? prev.filter((selected) => !optionsMatch(selected, option)) : [...prev, getSelectedOptionData(option)]));
selectionListRef?.current?.scrollToIndex(0);
},
[selectedOptions],
);
Expand Down
Loading
Loading