Skip to content

Commit 00eaba3

Browse files
authored
Merge pull request Expensify#88428 from bernhardoj/feat/84877-update-filters-chip-to-open-filters-popup-prepare
Extract the popup content to make it reusable
2 parents 3e964b5 + 36a700b commit 00eaba3

61 files changed

Lines changed: 2082 additions & 1892 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/CONST/index.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9013,17 +9013,10 @@ const CONST = {
90139013
FILTER_POPUP_APPLY_TEXT_INPUT: 'Search-FilterPopupApplyTextInput',
90149014
FILTER_POPUP_RESET_AMOUNT: 'Search-FilterPopupResetAmount',
90159015
FILTER_POPUP_APPLY_AMOUNT: 'Search-FilterPopupApplyAmount',
9016-
FILTER_POPUP_SAVE_AMOUNT: 'Search-FilterPopupSaveAmount',
90179016
FILTER_POPUP_RESET_DATE: 'Search-FilterPopupResetDate',
90189017
FILTER_POPUP_APPLY_DATE: 'Search-FilterPopupApplyDate',
9019-
FILTER_POPUP_RESET_USER: 'Search-FilterPopupResetUser',
9020-
FILTER_POPUP_APPLY_USER: 'Search-FilterPopupApplyUser',
9021-
FILTER_POPUP_RESET_REPORT: 'Search-FilterPopupResetReport',
9022-
FILTER_POPUP_APPLY_REPORT: 'Search-FilterPopupApplyReport',
90239018
FILTER_POPUP_RESET_REPORT_FIELD: 'Search-FilterPopupResetReportField',
90249019
FILTER_POPUP_APPLY_REPORT_FIELD: 'Search-FilterPopupApplyReportField',
9025-
FILTER_POPUP_RESET_CARD: 'Search-FilterPopupResetCard',
9026-
FILTER_POPUP_APPLY_CARD: 'Search-FilterPopupApplyCard',
90279020
TRANSACTION_LIST_ITEM_CHECKBOX: 'Search-TransactionListItemCheckbox',
90289021
EXPANDED_TRANSACTION_ROW: 'Search-ExpandedTransactionRow',
90299022
EXPANDED_TRANSACTION_ROW_CHECKBOX: 'Search-ExpandedTransactionRowCheckbox',

src/components/DatePicker/CalendarPicker/index.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,16 @@ type CalendarPickerProps = {
4343
};
4444

4545
function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) {
46-
let initialCurrentDateView = typeof value === 'string' ? parseISO(value) : new Date(value);
46+
let initialCurrentDateView: Date;
47+
if (typeof value === 'string') {
48+
if (!value) {
49+
initialCurrentDateView = new Date();
50+
} else {
51+
initialCurrentDateView = parseISO(value);
52+
}
53+
} else {
54+
initialCurrentDateView = new Date(value);
55+
}
4756

4857
if (maxDate < initialCurrentDateView) {
4958
initialCurrentDateView = maxDate;
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import React, {useEffect} from 'react';
2+
import {View} from 'react-native';
3+
import ActivityIndicator from '@components/ActivityIndicator';
4+
import {usePersonalDetails} from '@components/OnyxListItemProvider';
5+
import CardListItem from '@components/SelectionList/ListItem/CardListItem';
6+
import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections';
7+
import type {Section} from '@components/SelectionList/SelectionListWithSections/types';
8+
import {useCompanyCardFeedIcons} from '@hooks/useCompanyCardIcons';
9+
import useDebouncedState from '@hooks/useDebouncedState';
10+
import useLocalize from '@hooks/useLocalize';
11+
import useNetwork from '@hooks/useNetwork';
12+
import useOnyx from '@hooks/useOnyx';
13+
import useTheme from '@hooks/useTheme';
14+
import useThemeIllustrations from '@hooks/useThemeIllustrations';
15+
import useThemeStyles from '@hooks/useThemeStyles';
16+
import {openSearchCardFiltersPage} from '@libs/actions/Search';
17+
import {buildCardsData} from '@libs/CardFeedUtils';
18+
import type {CardFilterItem} from '@libs/CardFeedUtils';
19+
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
20+
import variables from '@styles/variables';
21+
import CONST from '@src/CONST';
22+
import ONYXKEYS from '@src/ONYXKEYS';
23+
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
24+
import ListFilterView from './ListFilterViewWrapper';
25+
26+
type CardSelectorProps = {
27+
value: string[] | undefined;
28+
onChange: (cards: string[]) => void;
29+
};
30+
31+
function CardSelector({value = [], onChange}: CardSelectorProps) {
32+
const theme = useTheme();
33+
const styles = useThemeStyles();
34+
const {translate} = useLocalize();
35+
const {isOffline} = useNetwork();
36+
const illustrations = useThemeIllustrations();
37+
const companyCardFeedIcons = useCompanyCardFeedIcons();
38+
39+
const [areCardsLoaded] = useOnyx(ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED);
40+
const [userCardList, userCardListMetadata] = useOnyx(ONYXKEYS.CARD_LIST);
41+
const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES);
42+
const [workspaceCardFeeds, workspaceCardFeedsMetadata] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST);
43+
const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
44+
const [searchAdvancedFiltersForm, searchAdvancedFiltersFormMetadata] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM);
45+
const personalDetails = usePersonalDetails();
46+
47+
useEffect(() => {
48+
if (isOffline) {
49+
return;
50+
}
51+
openSearchCardFiltersPage();
52+
}, [isOffline]);
53+
54+
const individualCardsSectionData = buildCardsData(
55+
workspaceCardFeeds ?? {},
56+
userCardList ?? {},
57+
personalDetails ?? {},
58+
value,
59+
illustrations,
60+
companyCardFeedIcons,
61+
false,
62+
customCardNames,
63+
);
64+
65+
const closedCardsSectionData = buildCardsData(workspaceCardFeeds ?? {}, userCardList ?? {}, personalDetails ?? {}, value, illustrations, companyCardFeedIcons, true, customCardNames);
66+
67+
const shouldShowSearchInput = individualCardsSectionData.selected.length + individualCardsSectionData.unselected.length >= CONST.STANDARD_LIST_ITEM_LIMIT;
68+
69+
const searchFunction = (item: CardFilterItem) =>
70+
!!item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()) ||
71+
!!item.lastFourPAN?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()) ||
72+
!!item.cardName?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()) ||
73+
(item.isVirtual && translate('workspace.expensifyCard.virtual').toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()));
74+
75+
let sections: Array<Section<CardFilterItem>> = [];
76+
let itemCount = 0;
77+
let sectionHeaderCount = 0;
78+
79+
if (searchAdvancedFiltersForm) {
80+
const selectedData = [...individualCardsSectionData.selected, ...closedCardsSectionData.selected].filter(searchFunction);
81+
const unselectedIndividualCardsData = individualCardsSectionData.unselected.filter(searchFunction);
82+
const unselectedClosedCardsData = closedCardsSectionData.unselected.filter(searchFunction);
83+
84+
itemCount = selectedData.length + unselectedIndividualCardsData.length + unselectedClosedCardsData.length;
85+
sectionHeaderCount = unselectedClosedCardsData.length > 0 ? 1 : 0;
86+
87+
sections = [
88+
{
89+
title: undefined,
90+
data: selectedData,
91+
sectionIndex: 0,
92+
},
93+
{
94+
title: undefined,
95+
data: unselectedIndividualCardsData,
96+
sectionIndex: 1,
97+
},
98+
{
99+
title: translate('search.filters.card.closedCards'),
100+
data: unselectedClosedCardsData,
101+
sectionIndex: 2,
102+
},
103+
];
104+
}
105+
106+
const updateNewCards = (item: CardFilterItem) => {
107+
if (!item.keyForList) {
108+
return;
109+
}
110+
111+
if (item.isSelected) {
112+
const newCardsObject = value.filter((card) => card !== item.keyForList);
113+
onChange(newCardsObject);
114+
} else {
115+
const newCardsObject = [...value, item.keyForList];
116+
onChange(newCardsObject);
117+
}
118+
};
119+
120+
const textInputOptions = {
121+
value: searchTerm,
122+
label: translate('common.search'),
123+
onChangeText: setSearchTerm,
124+
headerMessage: debouncedSearchTerm.trim() && sections.every((section) => !section.data.length) ? translate('common.noResultsFound') : '',
125+
};
126+
127+
const isLoadingOnyxData = isLoadingOnyxValue(userCardListMetadata, workspaceCardFeedsMetadata, searchAdvancedFiltersFormMetadata);
128+
const shouldShowLoadingState = isLoadingOnyxData || (!areCardsLoaded && !isOffline);
129+
const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'SearchFiltersCardPage', isLoadingFromOnyx: isLoadingOnyxData};
130+
131+
return (
132+
<ListFilterView
133+
itemCount={itemCount}
134+
itemHeight={variables.optionRowHeight}
135+
isSearchable={shouldShowSearchInput}
136+
extraHeight={28 * sectionHeaderCount}
137+
>
138+
{shouldShowLoadingState ? (
139+
<View style={[styles.flex1, styles.flexColumn, styles.justifyContentCenter, styles.alignItemsCenter]}>
140+
<ActivityIndicator
141+
color={theme.spinner}
142+
size={CONST.ACTIVITY_INDICATOR_SIZE.LARGE}
143+
style={[styles.pl3]}
144+
reasonAttributes={reasonAttributes}
145+
/>
146+
</View>
147+
) : (
148+
<SelectionListWithSections<CardFilterItem>
149+
sections={sections}
150+
ListItem={CardListItem}
151+
onSelectRow={updateNewCards}
152+
shouldPreventDefaultFocusOnSelectRow={false}
153+
shouldShowTextInput={shouldShowSearchInput}
154+
textInputOptions={textInputOptions}
155+
shouldStopPropagation
156+
canSelectMultiple
157+
/>
158+
)}
159+
</ListFilterView>
160+
);
161+
}
162+
163+
export default CardSelector;

src/components/Search/FilterDropdowns/CategorySelectPopup.tsx renamed to src/components/Search/FilterComponents/CategorySelector.tsx

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,27 @@
11
import React from 'react';
22
import type {OnyxCollection} from 'react-native-onyx';
3-
import MultiSelectFilterPopup from '@components/Search/SearchPageHeader/MultiSelectFilterPopup';
43
import useLocalize from '@hooks/useLocalize';
54
import useOnyx from '@hooks/useOnyx';
65
import {getDecodedCategoryName} from '@libs/CategoryUtils';
76
import CONST from '@src/CONST';
87
import ONYXKEYS from '@src/ONYXKEYS';
9-
import type {SearchAdvancedFiltersForm} from '@src/types/form';
8+
import {filterPolicyIDSelector} from '@src/selectors/Search';
109
import type {PolicyCategories, PolicyCategory} from '@src/types/onyx';
1110
import {getEmptyObject} from '@src/types/utils/EmptyObject';
12-
import type {MultiSelectItem} from './MultiSelectPopup';
11+
import getEmptyArray from '@src/types/utils/getEmptyArray';
12+
import MultiSelect from './MultiSelect';
1313

14-
type CategorySelectPopupProps = {
15-
closeOverlay: () => void;
16-
updateFilterForm: (values: Partial<SearchAdvancedFiltersForm>) => void;
14+
type CategorySelectorProps = {
15+
value: string[] | undefined;
16+
onChange: (categories: string[]) => void;
1717
};
1818

19-
function CategorySelectPopup({closeOverlay, updateFilterForm}: CategorySelectPopupProps) {
19+
function CategorySelector({value = [], onChange}: CategorySelectorProps) {
2020
const {translate} = useLocalize();
21-
const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM);
21+
const [policyIDs = getEmptyArray<string>()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: filterPolicyIDSelector});
2222
const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID);
23-
const policyIDs = searchAdvancedFiltersForm?.policyID ?? [];
2423

25-
const selectedCategoriesItems = searchAdvancedFiltersForm?.category?.map((category) => {
24+
const selectedCategoriesItems = value.map((category) => {
2625
if (category === CONST.SEARCH.CATEGORY_EMPTY_VALUE) {
2726
return {text: translate('search.noCategory'), value: category};
2827
}
@@ -48,13 +47,13 @@ function CategorySelectPopup({closeOverlay, updateFilterForm}: CategorySelectPop
4847
[availableNonPersonalPolicyCategoriesSelector],
4948
);
5049
const selectedPoliciesCategories: PolicyCategory[] = Object.keys(allPolicyCategories ?? {})
51-
.filter((key) => policyIDs?.map((policyID) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`)?.includes(key))
50+
.filter((key) => policyIDs.map((policyID) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`)?.includes(key))
5251
.map((key) => Object.values(allPolicyCategories?.[key] ?? {}))
5352
.flat();
5453

5554
const categoryItems = [{text: translate('search.noCategory'), value: CONST.SEARCH.CATEGORY_EMPTY_VALUE as string}];
5655
const uniqueCategoryNames = new Set<string>();
57-
if (policyIDs?.length === 0) {
56+
if (policyIDs.length === 0) {
5857
const categories = Object.values(allPolicyCategories ?? {}).flatMap((policyCategories) => Object.values(policyCategories ?? {}));
5958
for (const category of categories) {
6059
uniqueCategoryNames.add(category.name);
@@ -73,20 +72,15 @@ function CategorySelectPopup({closeOverlay, updateFilterForm}: CategorySelectPop
7372
}),
7473
);
7574

76-
const updateCategoryFilterForm = (items: Array<MultiSelectItem<string>>) => {
77-
updateFilterForm({category: items.map((item) => item.value)});
78-
};
79-
8075
return (
81-
<MultiSelectFilterPopup
82-
closeOverlay={closeOverlay}
83-
translationKey="common.category"
76+
<MultiSelect
77+
value={selectedCategoriesItems}
8478
items={categoryItems}
85-
value={selectedCategoriesItems ?? []}
8679
isSearchable={categoryItems.length >= CONST.STANDARD_LIST_ITEM_LIMIT}
87-
onChangeCallback={updateCategoryFilterForm}
80+
searchPlaceholder={translate('common.category')}
81+
onChange={(categories) => onChange(categories.map((category) => category.value))}
8882
/>
8983
);
9084
}
9185

92-
export default CategorySelectPopup;
86+
export default CategorySelector;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React from 'react';
2+
import {useCurrencyListActions, useCurrencyListState} from '@components/CurrencyListContextProvider';
3+
import {getCurrencyOptions} from '@libs/SearchUIUtils';
4+
import MultiSelect from './MultiSelect';
5+
6+
type CurrencySelectorProps = {
7+
value: string[] | undefined;
8+
onChange: (item: string[]) => void;
9+
};
10+
11+
function CurrencySelector({value = [], onChange}: CurrencySelectorProps) {
12+
const {currencyList} = useCurrencyListState();
13+
const {getCurrencySymbol} = useCurrencyListActions();
14+
const currencyOptions = getCurrencyOptions(currencyList, getCurrencySymbol);
15+
const currencyValues = currencyOptions.filter((option) => value.includes(option.value));
16+
17+
return (
18+
<MultiSelect
19+
value={currencyValues}
20+
items={currencyOptions}
21+
isSearchable
22+
onChange={(currencies) => onChange(currencies.map((currency) => currency.value))}
23+
/>
24+
);
25+
}
26+
27+
export default CurrencySelector;

src/components/Search/FilterComponents/DateFilterBase.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
22
import {View} from 'react-native';
3+
import type {StyleProp, ViewStyle} from 'react-native';
34
import Button from '@components/Button';
45
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
56
import FormHelpMessage from '@components/FormHelpMessage';
@@ -20,6 +21,7 @@ type DateFilterBaseHandle = {
2021
getDateValues: () => SearchDateValues;
2122
/** Handles back navigation by closing the active date modifier before leaving the screen */
2223
goBack: () => void;
24+
save: () => void;
2325
};
2426

2527
type DateFilterBaseProps = {
@@ -45,12 +47,14 @@ type DateFilterBaseProps = {
4547
onSelectDateModifier?: (dateModifier: SearchDateModifier | null) => void;
4648
/** Callback when the date modifier screen is opened or closed (on/after/before) */
4749
onDateModifierChange?: (isOpen: boolean) => void;
50+
shouldShowActionButtons?: boolean;
4851
/** If true, the Reset/Save buttons are only shown when a date modifier is selected. */
4952
shouldShowButtonsOnlyWithDateModifier?: boolean;
5053
/** Whether to render the built-in HeaderWithBackButton. Defaults to true. */
5154
shouldShowHeader?: boolean;
5255
/** The ref handle */
5356
ref?: React.Ref<DateFilterBaseHandle>;
57+
style?: StyleProp<ViewStyle>;
5458
};
5559

5660
// Component uses ref as a prop, which is supported in modern React.
@@ -64,11 +68,13 @@ function DateFilterBase({
6468
onReset,
6569
onDateValuesChange,
6670
onDateModifierChange,
71+
shouldShowActionButtons: shouldShowActionButtonsProp = true,
6772
shouldShowButtonsOnlyWithDateModifier = false,
6873
shouldShowHeader = true,
6974
ref,
7075
selectedDateModifier: selectedDateModifierProp,
7176
onSelectDateModifier,
77+
style,
7278
}: DateFilterBaseProps) {
7379
const styles = useThemeStyles();
7480
const {translate} = useLocalize();
@@ -142,15 +148,6 @@ function DateFilterBase({
142148
onBackButtonPress?.();
143149
}, [onBackButtonPress, onDateModifierChange, selectedDateModifier, setSelectedDateModifier]);
144150

145-
useImperativeHandle(
146-
ref,
147-
() => ({
148-
getDateValues: () => searchDatePresetFilterBaseRef.current?.getDateValues() ?? getEmptyDateValues(),
149-
goBack,
150-
}),
151-
[goBack],
152-
);
153-
154151
const computedTitle = getDateModifierTitle(selectedDateModifier, title ?? '', translate);
155152

156153
const reset = useCallback(() => {
@@ -196,11 +193,21 @@ function DateFilterBase({
196193
onSubmit(searchDatePresetFilterBaseRef.current.getDateValues());
197194
}, [onDateModifierChange, onSubmit, selectedDateModifier, setSelectedDateModifier]);
198195

199-
const shouldShowActionButtons = !shouldShowButtonsOnlyWithDateModifier || !!selectedDateModifier;
196+
useImperativeHandle(
197+
ref,
198+
() => ({
199+
getDateValues: () => searchDatePresetFilterBaseRef.current?.getDateValues() ?? getEmptyDateValues(),
200+
goBack,
201+
save,
202+
}),
203+
[goBack, save],
204+
);
205+
206+
const shouldShowActionButtons = shouldShowActionButtonsProp && (!shouldShowButtonsOnlyWithDateModifier || !!selectedDateModifier);
200207
const shouldShowRangeSummary = selectedDateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE && !!rangeDisplayText;
201208

202209
return (
203-
<View style={styles.flex1}>
210+
<View style={style}>
204211
{shouldShowHeader && (
205212
<HeaderWithBackButton
206213
title={computedTitle}

0 commit comments

Comments
 (0)