Skip to content

Commit

Permalink
Merge pull request #46962 from software-mansion-labs/kicu/46035-from-…
Browse files Browse the repository at this point in the history
…to-filters

[Search] Add From & To advanced filters
  • Loading branch information
luacmartins authored Aug 14, 2024
2 parents ba89909 + 278ba21 commit 04d0b1b
Show file tree
Hide file tree
Showing 16 changed files with 410 additions and 51 deletions.
3 changes: 3 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ const ROUTES = {
SEARCH_ADVANCED_FILTERS_TAX_RATE: 'search/filters/taxRate',
SEARCH_ADVANCED_FILTERS_EXPENSE_TYPE: 'search/filters/expenseType',
SEARCH_ADVANCED_FILTERS_TAG: 'search/filters/tag',
SEARCH_ADVANCED_FILTERS_FROM: 'search/filters/from',
SEARCH_ADVANCED_FILTERS_TO: 'search/filters/to',

SEARCH_REPORT: {
route: 'search/view/:reportID',
getRoute: (reportID: string) => `search/view/${reportID}` as const,
Expand Down
2 changes: 2 additions & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ const SCREENS = {
ADVANCED_FILTERS_TAX_RATE_RHP: 'Search_Advanced_Filters_Tax_Rate_RHP',
ADVANCED_FILTERS_EXPENSE_TYPE_RHP: 'Search_Advanced_Filters_Expense_Type_RHP',
ADVANCED_FILTERS_TAG_RHP: 'Search_Advanced_Filters_Tag_RHP',
ADVANCED_FILTERS_FROM_RHP: 'Search_Advanced_Filters_From_RHP',
ADVANCED_FILTERS_TO_RHP: 'Search_Advanced_Filters_To_RHP',
TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP',
BOTTOM_TAB: 'Search_Bottom_Tab',
},
Expand Down
231 changes: 231 additions & 0 deletions src/components/Search/SearchFiltersParticipantsSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import {usePersonalDetails} from '@components/OnyxProvider';
import {useOptionsList} from '@components/OptionListContextProvider';
import SelectionList from '@components/SelectionList';
import InviteMemberListItem from '@components/SelectionList/InviteMemberListItem';
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useScreenWrapperTranstionStatus from '@hooks/useScreenWrapperTransitionStatus';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import type {Option} from '@libs/OptionsListUtils';
import type {OptionData} from '@libs/ReportUtils';
import Navigation from '@navigation/Navigation';
import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';

const defaultListOptions = {
userToInvite: null,
recentReports: [],
personalDetails: [],
currentUserOption: null,
headerMessage: '',
categoryOptions: [],
tagOptions: [],
taxRatesOptions: [],
};

function getSelectedOptionData(option: Option): OptionData {
return {...option, selected: true, reportID: option.reportID ?? '-1'};
}

type SearchFiltersParticipantsSelectorProps = {
initialAccountIDs: string[];
onFiltersUpdate: (accountIDs: string[]) => void;
};

function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: SearchFiltersParticipantsSelectorProps) {
const {translate} = useLocalize();
const personalDetails = usePersonalDetails();
const {didScreenTransitionEnd} = useScreenWrapperTranstionStatus();
const {options, areOptionsInitialized} = useOptionsList({
shouldInitialize: didScreenTransitionEnd,
});

const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false});
const [selectedOptions, setSelectedOptions] = useState<OptionData[]>([]);
const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
const cleanSearchTerm = useMemo(() => searchTerm.trim().toLowerCase(), [searchTerm]);

const defaultOptions = useMemo(() => {
if (!areOptionsInitialized) {
return defaultListOptions;
}

return OptionsListUtils.getFilteredOptions(
options.reports,
options.personalDetails,
undefined,
'',
selectedOptions,
CONST.EXPENSIFY_EMAILS,
false,
true,
false,
{},
[],
false,
{},
[],
true,
false,
false,
0,
undefined,
true,
);
}, [areOptionsInitialized, options.personalDetails, options.reports, selectedOptions]);

const chatOptions = useMemo(() => {
return OptionsListUtils.filterOptions(defaultOptions, cleanSearchTerm, {
selectedOptions,
excludeLogins: CONST.EXPENSIFY_EMAILS,
maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW,
});
}, [defaultOptions, cleanSearchTerm, selectedOptions]);

const {sections, headerMessage} = useMemo(() => {
const newSections: OptionsListUtils.CategorySection[] = [];
if (!areOptionsInitialized) {
return {sections: [], headerMessage: undefined};
}

const formattedResults = OptionsListUtils.formatSectionsFromSearchTerm(
cleanSearchTerm,
selectedOptions,
chatOptions.recentReports,
chatOptions.personalDetails,
personalDetails,
true,
);

const isCurrentUserSelected = selectedOptions.find((option) => option.accountID === chatOptions.currentUserOption?.accountID);

newSections.push(formattedResults.section);

if (chatOptions.currentUserOption && !isCurrentUserSelected) {
newSections.push({
title: '',
data: [chatOptions.currentUserOption],
shouldShow: true,
});
}

newSections.push({
title: '',
data: chatOptions.recentReports,
shouldShow: chatOptions.recentReports.length > 0,
});

newSections.push({
title: '',
data: chatOptions.personalDetails,
shouldShow: chatOptions.personalDetails.length > 0,
});

const noResultsFound = chatOptions.personalDetails.length === 0 && chatOptions.recentReports.length === 0 && !chatOptions.currentUserOption;
const message = noResultsFound ? translate('common.noResultsFound') : undefined;

return {
sections: newSections,
headerMessage: message,
};
}, [areOptionsInitialized, cleanSearchTerm, selectedOptions, chatOptions.recentReports, chatOptions.personalDetails, chatOptions.currentUserOption, personalDetails, translate]);

// This effect handles setting initial selectedOptions based on accountIDs saved in onyx form
useEffect(() => {
if (!initialAccountIDs || initialAccountIDs.length === 0 || !personalDetails) {
return;
}

const preSelectedOptions = initialAccountIDs
.map((accountID) => {
const participant = personalDetails[accountID];
if (!participant) {
return;
}

return getSelectedOptionData(participant);
})
.filter((option): option is NonNullable<OptionData> => {
return !!option;
});

setSelectedOptions(preSelectedOptions);
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- this should react only to changes in form data
}, [initialAccountIDs, personalDetails]);

useEffect(() => {
Report.searchInServer(debouncedSearchTerm.trim());
}, [debouncedSearchTerm]);

const handleParticipantSelection = useCallback(
(option: Option) => {
const foundOptionIndex = selectedOptions.findIndex((selectedOption: Option) => {
if (selectedOption.accountID && selectedOption.accountID === option?.accountID) {
return true;
}

if (selectedOption.reportID && selectedOption.reportID === option?.reportID) {
return true;
}

return false;
});

if (foundOptionIndex < 0) {
setSelectedOptions([...selectedOptions, getSelectedOptionData(option)]);
} else {
const newSelectedOptions = [...selectedOptions.slice(0, foundOptionIndex), ...selectedOptions.slice(foundOptionIndex + 1)];
setSelectedOptions(newSelectedOptions);
}
},
[selectedOptions],
);

const footerContent = (
<Button
success
text={translate('common.save')}
pressOnEnter
onPress={() => {
const selectedAccountIDs = selectedOptions.map((option) => (option.accountID ? option.accountID.toString() : undefined)).filter(Boolean) as string[];
onFiltersUpdate(selectedAccountIDs);

Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS);
}}
large
/>
);

const isLoadingNewOptions = !!isSearchingForReports;
const showLoadingPlaceholder = !didScreenTransitionEnd || !areOptionsInitialized || !initialAccountIDs || !personalDetails;

return (
<SelectionList
canSelectMultiple
sections={sections}
ListItem={InviteMemberListItem}
textInputLabel={translate('selectionList.nameEmailOrPhoneNumber')}
headerMessage={headerMessage}
textInputValue={searchTerm}
footerContent={footerContent}
showScrollIndicator
shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
onChangeText={(value) => {
setSearchTerm(value);
}}
onSelectRow={handleParticipantSelection}
isLoadingNewOptions={isLoadingNewOptions}
showLoadingPlaceholder={showLoadingPlaceholder}
/>
);
}

SearchFiltersParticipantsSelector.displayName = 'SearchFiltersParticipantsSelector';

export default SearchFiltersParticipantsSelector;
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,8 @@ const SearchAdvancedFiltersModalStackNavigator = createModalStackNavigator<Searc
[SCREENS.SEARCH.ADVANCED_FILTERS_TAX_RATE_RHP]: () => require<ReactComponentModule>('../../../../pages/Search/SearchFiltersTaxRatePage').default,
[SCREENS.SEARCH.ADVANCED_FILTERS_EXPENSE_TYPE_RHP]: () => require<ReactComponentModule>('../../../../pages/Search/SearchFiltersExpenseTypePage').default,
[SCREENS.SEARCH.ADVANCED_FILTERS_TAG_RHP]: () => require<ReactComponentModule>('../../../../pages/Search/SearchFiltersTagPage').default,
[SCREENS.SEARCH.ADVANCED_FILTERS_FROM_RHP]: () => require<ReactComponentModule>('@pages/Search/SearchAdvancedFiltersPage/SearchFiltersFromPage').default,
[SCREENS.SEARCH.ADVANCED_FILTERS_TO_RHP]: () => require<ReactComponentModule>('@pages/Search/SearchAdvancedFiltersPage/SearchFiltersToPage').default,
});

const RestrictedActionModalStackNavigator = createModalStackNavigator<SearchReportParamList>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial<Record<CentralPaneName, string[]>> =
SCREENS.SEARCH.ADVANCED_FILTERS_TAX_RATE_RHP,
SCREENS.SEARCH.ADVANCED_FILTERS_EXPENSE_TYPE_RHP,
SCREENS.SEARCH.ADVANCED_FILTERS_TAG_RHP,
SCREENS.SEARCH.ADVANCED_FILTERS_FROM_RHP,
SCREENS.SEARCH.ADVANCED_FILTERS_TO_RHP,
],
[SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: [
SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD,
Expand Down
2 changes: 2 additions & 0 deletions src/libs/Navigation/linkingConfig/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,6 +1035,8 @@ const config: LinkingOptions<RootStackParamList>['config'] = {
[SCREENS.SEARCH.ADVANCED_FILTERS_TAX_RATE_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_TAX_RATE,
[SCREENS.SEARCH.ADVANCED_FILTERS_EXPENSE_TYPE_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_EXPENSE_TYPE,
[SCREENS.SEARCH.ADVANCED_FILTERS_TAG_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_TAG,
[SCREENS.SEARCH.ADVANCED_FILTERS_FROM_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_FROM,
[SCREENS.SEARCH.ADVANCED_FILTERS_TO_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_TO,
},
},
[SCREENS.RIGHT_MODAL.RESTRICTED_ACTION]: {
Expand Down
4 changes: 3 additions & 1 deletion src/libs/SearchUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,9 @@ function buildQueryStringFromFilters(filterValues: Partial<SearchAdvancedFilters
filterKey === FILTER_KEYS.TAX_RATE ||
filterKey === FILTER_KEYS.EXPENSE_TYPE ||
filterKey === FILTER_KEYS.TAG ||
filterKey === FILTER_KEYS.CURRENCY) &&
filterKey === FILTER_KEYS.CURRENCY ||
filterKey === FILTER_KEYS.FROM ||
filterKey === FILTER_KEYS.TO) &&
Array.isArray(filterValue) &&
filterValue.length > 0
) {
Expand Down
53 changes: 40 additions & 13 deletions src/pages/Search/AdvancedSearchFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,47 @@ import {useOnyx} from 'react-native-onyx';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import type {LocaleContextProps} from '@components/LocaleContextProvider';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import {usePersonalDetails} from '@components/OnyxProvider';
import ScrollView from '@components/ScrollView';
import type {AdvancedFiltersKeys} from '@components/Search/types';
import useLocalize from '@hooks/useLocalize';
import useSingleExecution from '@hooks/useSingleExecution';
import useThemeStyles from '@hooks/useThemeStyles';
import useWaitForNavigation from '@hooks/useWaitForNavigation';
import Navigation from '@libs/Navigation/Navigation';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import {getAllTaxRates} from '@libs/PolicyUtils';
import * as SearchUtils from '@libs/SearchUtils';
import * as SearchActions from '@userActions/Search';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {SearchAdvancedFiltersForm} from '@src/types/form';
import type {CardList} from '@src/types/onyx';
import type {CardList, PersonalDetailsList} from '@src/types/onyx';

function getFilterCardDisplayTitle(filters: Partial<SearchAdvancedFiltersForm>, cards: CardList) {
const filterValue = filters[CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID];
return filterValue
? Object.values(cards)
.filter((card) => filterValue.includes(card.cardID.toString()))
.map((card) => card.bank)
.join(', ')
: undefined;
}

function getFilterParticipantDisplayTitle(accountIDs: string[], personalDetails: PersonalDetailsList) {
const selectedPersonalDetails = accountIDs.map((id) => personalDetails[id]);

return selectedPersonalDetails
.map((personalDetail) => {
if (!personalDetail) {
return '';
}

return PersonalDetailsUtils.createDisplayName(personalDetail.login ?? '', personalDetail);
})
.join(', ');
}

function getFilterDisplayTitle(filters: Partial<SearchAdvancedFiltersForm>, fieldName: AdvancedFiltersKeys, translate: LocaleContextProps['translate']) {
if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) {
Expand Down Expand Up @@ -58,16 +84,6 @@ function getFilterDisplayTitle(filters: Partial<SearchAdvancedFiltersForm>, fiel
return filterValue ? Str.recapitalize(filterValue) : undefined;
}

function getFilterCardDisplayTitle(filters: Partial<SearchAdvancedFiltersForm>, cards: CardList) {
const filterValue = filters[CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID];
return filterValue
? Object.values(cards)
.filter((card) => filterValue.includes(card.cardID.toString()))
.map((card) => card.bank)
.join(', ')
: undefined;
}

function getFilterTaxRateDisplayTitle(filters: Partial<SearchAdvancedFiltersForm>, taxRates: Record<string, string[]>) {
const selectedTaxRateKeys = filters[CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE];
if (!selectedTaxRateKeys) {
Expand Down Expand Up @@ -101,9 +117,10 @@ function AdvancedSearchFilters() {
const {singleExecution} = useSingleExecution();
const waitForNavigate = useWaitForNavigation();

const [searchAdvancedFilters = {}] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM);
const [searchAdvancedFilters = {} as SearchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM);
const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
const taxRates = getAllTaxRates();
const personalDetails = usePersonalDetails();

const advancedFilters = useMemo(
() => [
Expand Down Expand Up @@ -163,8 +180,18 @@ function AdvancedSearchFilters() {
description: 'common.tag' as const,
route: ROUTES.SEARCH_ADVANCED_FILTERS_TAG,
},
{
title: getFilterParticipantDisplayTitle(searchAdvancedFilters.from ?? [], personalDetails),
description: 'common.from' as const,
route: ROUTES.SEARCH_ADVANCED_FILTERS_FROM,
},
{
title: getFilterParticipantDisplayTitle(searchAdvancedFilters.to ?? [], personalDetails),
description: 'common.to' as const,
route: ROUTES.SEARCH_ADVANCED_FILTERS_TO,
},
],
[searchAdvancedFilters, translate, cardList, taxRates],
[searchAdvancedFilters, translate, cardList, taxRates, personalDetails],
);

const onFormSubmit = () => {
Expand Down
Loading

0 comments on commit 04d0b1b

Please sign in to comment.