Skip to content

Commit 174f8ad

Browse files
authored
Merge pull request Expensify#68441 from sofi-a/68354-improve-ux-when-filters-are-selected-but-not-visible
Improve UX when Filters are selected but not visible
2 parents e8b7be5 + 9cc572b commit 174f8ad

3 files changed

Lines changed: 347 additions & 285 deletions

File tree

src/components/Search/SearchPageHeader/SearchFiltersBar.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, {useCallback, useMemo, useRef} from 'react';
2+
import type {ReactNode} from 'react';
23
import {View} from 'react-native';
34
// eslint-disable-next-line no-restricted-imports
45
import type {ScrollView as RNScrollView} from 'react-native';
@@ -19,6 +20,7 @@ import {useSearchContext} from '@components/Search/SearchContext';
1920
import type {SearchDateValues} from '@components/Search/SearchDatePresetFilterBase';
2021
import type {SearchDateFilterKeys, SearchGroupBy, SearchQueryJSON, SingularSearchStatus} from '@components/Search/types';
2122
import SearchFiltersSkeleton from '@components/Skeletons/SearchFiltersSkeleton';
23+
import useAdvancedSearchFilters from '@hooks/useAdvancedSearchFilters';
2224
import useLocalize from '@hooks/useLocalize';
2325
import useNetwork from '@hooks/useNetwork';
2426
import useOnyx from '@hooks/useOnyx';
@@ -45,11 +47,19 @@ import type {TranslationPaths} from '@src/languages/types';
4547
import ONYXKEYS from '@src/ONYXKEYS';
4648
import ROUTES from '@src/ROUTES';
4749
import type {SearchAdvancedFiltersForm} from '@src/types/form';
48-
import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm';
50+
import FILTER_KEYS, {DATE_FILTER_KEYS} from '@src/types/form/SearchAdvancedFiltersForm';
51+
import type {SearchAdvancedFiltersKey} from '@src/types/form/SearchAdvancedFiltersForm';
4952
import type {CurrencyList, Policy} from '@src/types/onyx';
5053
import {getEmptyObject} from '@src/types/utils/EmptyObject';
5154
import type {SearchHeaderOptionValue} from './SearchPageHeader';
5255

56+
type FilterItem = {
57+
label: string;
58+
PopoverComponent: (props: PopoverComponentProps) => ReactNode;
59+
value: string | string[] | null;
60+
filterKey: SearchAdvancedFiltersKey;
61+
};
62+
5363
type SearchFiltersBarProps = {
5464
queryJSON: SearchQueryJSON;
5565
headerButtonsOptions: Array<DropdownOption<SearchHeaderOptionValue>>;
@@ -377,11 +387,13 @@ function SearchFiltersBar({queryJSON, headerButtonsOptions, isMobileSelectionMod
377387
[filterFormValues.from, updateFilterForm],
378388
);
379389

390+
const {typeFiltersKeys} = useAdvancedSearchFilters();
391+
380392
/**
381393
* Builds the list of all filter chips to be displayed in the
382394
* filter bar
383395
*/
384-
const filters = useMemo(() => {
396+
const filters = useMemo<FilterItem[]>(() => {
385397
const fromValue = filterFormValues.from?.map((accountID) => personalDetails?.[accountID]?.displayName ?? accountID) ?? [];
386398

387399
const shouldDisplayGroupByFilter = groupBy?.value === CONST.SEARCH.GROUP_BY.FROM || groupBy?.value === CONST.SEARCH.GROUP_BY.CARD;
@@ -515,6 +527,25 @@ function SearchFiltersBar({queryJSON, headerButtonsOptions, isMobileSelectionMod
515527
hasMultipleOutputCurrency,
516528
]);
517529

530+
const hiddenSelectedFilters = useMemo(() => {
531+
const advancedSearchFiltersKeys = typeFiltersKeys.flat();
532+
const exposedFiltersKeys = filters.flatMap((filter) => {
533+
const dateFilterKey = DATE_FILTER_KEYS.find((key) => filter.filterKey.startsWith(key));
534+
if (dateFilterKey) {
535+
return dateFilterKey;
536+
}
537+
return filter.filterKey;
538+
});
539+
const hiddenFilters = advancedSearchFiltersKeys.filter((key) => !exposedFiltersKeys.includes(key as SearchAdvancedFiltersKey));
540+
return hiddenFilters.filter((key) => {
541+
const dateFilterKey = DATE_FILTER_KEYS.find((dateKey) => key === dateKey);
542+
if (dateFilterKey) {
543+
return filterFormValues[`${dateFilterKey}On`] ?? filterFormValues[`${dateFilterKey}After`] ?? filterFormValues[`${dateFilterKey}Before`];
544+
}
545+
return filterFormValues[key as SearchAdvancedFiltersKey];
546+
});
547+
}, [filterFormValues, filters, typeFiltersKeys]);
548+
518549
if (hasErrors) {
519550
return null;
520551
}
@@ -577,7 +608,7 @@ function SearchFiltersBar({queryJSON, headerButtonsOptions, isMobileSelectionMod
577608
link
578609
small
579610
shouldUseDefaultHover={false}
580-
text={translate('search.filtersHeader')}
611+
text={translate('search.filtersHeader') + (hiddenSelectedFilters.length > 0 ? ` (${hiddenSelectedFilters.length})` : '')}
581612
iconFill={theme.link}
582613
iconHoverFill={theme.linkHover}
583614
icon={Expensicons.Filter}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import {useMemo} from 'react';
2+
import type {OnyxCollection} from 'react-native-onyx';
3+
import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils';
4+
import {getAllTaxRates, getTagNamesFromTagsLists, isPolicyFeatureEnabled} from '@libs/PolicyUtils';
5+
import {getAllPolicyValues} from '@libs/SearchQueryUtils';
6+
import CONST from '@src/CONST';
7+
import ONYXKEYS from '@src/ONYXKEYS';
8+
import type {SearchAdvancedFiltersForm} from '@src/types/form';
9+
import type {Policy, PolicyCategories, PolicyTagLists} from '@src/types/onyx';
10+
import type {PolicyFeatureName} from '@src/types/onyx/Policy';
11+
import {getEmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject';
12+
import useLocalize from './useLocalize';
13+
import useOnyx from './useOnyx';
14+
import useWorkspaceList from './useWorkspaceList';
15+
16+
/**
17+
* typeFiltersKeys is stored as an object keyed by the different search types.
18+
* Each value is then an array of arrays where each inner array is a separate section in the UI.
19+
*/
20+
const typeFiltersKeys = {
21+
[CONST.SEARCH.DATA_TYPES.EXPENSE]: [
22+
[
23+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE,
24+
CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM,
25+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TO,
26+
CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD,
27+
CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS,
28+
CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID,
29+
CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY,
30+
],
31+
[
32+
CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE,
33+
CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT,
34+
CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE,
35+
CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT,
36+
CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY,
37+
CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY,
38+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG,
39+
CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION,
40+
CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID,
41+
CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED,
42+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE,
43+
CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE,
44+
CONST.SEARCH.SYNTAX_FILTER_KEYS.BILLABLE,
45+
],
46+
[
47+
CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID,
48+
CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED,
49+
CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED,
50+
CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID,
51+
CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED,
52+
CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_TYPE,
53+
CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWN,
54+
],
55+
],
56+
[CONST.SEARCH.DATA_TYPES.INVOICE]: [
57+
[
58+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE,
59+
CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM,
60+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TO,
61+
CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD,
62+
CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS,
63+
CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID,
64+
],
65+
[
66+
CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT,
67+
CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE,
68+
CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT,
69+
CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY,
70+
CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY,
71+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG,
72+
CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION,
73+
CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID,
74+
CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED,
75+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE,
76+
],
77+
[
78+
CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID,
79+
CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED,
80+
CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED,
81+
CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID,
82+
CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED,
83+
CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_TYPE,
84+
CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWN,
85+
],
86+
],
87+
[CONST.SEARCH.DATA_TYPES.TRIP]: [
88+
[
89+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE,
90+
CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM,
91+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TO,
92+
CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD,
93+
CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS,
94+
CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID,
95+
CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY,
96+
],
97+
[
98+
CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT,
99+
CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE,
100+
CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT,
101+
CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY,
102+
CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY,
103+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG,
104+
CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION,
105+
CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID,
106+
CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED,
107+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE,
108+
],
109+
[
110+
CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID,
111+
CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED,
112+
CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED,
113+
CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID,
114+
CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED,
115+
],
116+
],
117+
[CONST.SEARCH.DATA_TYPES.CHAT]: [
118+
[
119+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE,
120+
CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM,
121+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TO,
122+
CONST.SEARCH.SYNTAX_FILTER_KEYS.IN,
123+
CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD,
124+
CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS,
125+
CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID,
126+
CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE,
127+
],
128+
],
129+
[CONST.SEARCH.DATA_TYPES.TASK]: [
130+
[
131+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE,
132+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE,
133+
CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION,
134+
CONST.SEARCH.SYNTAX_FILTER_KEYS.IN,
135+
CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM,
136+
CONST.SEARCH.SYNTAX_FILTER_KEYS.ASSIGNEE,
137+
CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS,
138+
CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE,
139+
],
140+
],
141+
};
142+
143+
function shouldDisplayFilter(numberOfFilters: number, isFeatureEnabled: boolean, singlePolicyCondition = false): boolean {
144+
return (numberOfFilters !== 0 || singlePolicyCondition) && isFeatureEnabled;
145+
}
146+
147+
function isFeatureEnabledInPolicies(policies: OnyxCollection<Policy>, featureName: PolicyFeatureName) {
148+
if (isEmptyObject(policies)) {
149+
return false;
150+
}
151+
return Object.values(policies).some((policy) => isPolicyFeatureEnabled(policy, featureName));
152+
}
153+
154+
function useAdvancedSearchFilters() {
155+
const {localeCompare} = useLocalize();
156+
const [searchAdvancedFilters = getEmptyObject<SearchAdvancedFiltersForm>()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {canBeMissing: true});
157+
const policyID = searchAdvancedFilters.policyID;
158+
const groupBy = searchAdvancedFilters.groupBy;
159+
const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST, {canBeMissing: false});
160+
const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {canBeMissing: false});
161+
const allCards = useMemo(() => mergeCardListWithWorkspaceFeeds(workspaceCardFeeds ?? CONST.EMPTY_OBJECT, userCardList, true), [userCardList, workspaceCardFeeds]);
162+
const taxRates = getAllTaxRates();
163+
164+
const [policies = getEmptyObject<NonNullable<OnyxCollection<Policy>>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: false});
165+
const [allPolicyCategories = getEmptyObject<NonNullable<OnyxCollection<PolicyCategories>>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {
166+
canBeMissing: false,
167+
selector: (policyCategories) =>
168+
Object.fromEntries(
169+
Object.entries(policyCategories ?? {}).filter(([, categories]) => {
170+
const availableCategories = Object.values(categories ?? {}).filter((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
171+
return availableCategories.length > 0;
172+
}),
173+
),
174+
});
175+
const selectedPolicyCategories = getAllPolicyValues(policyID, ONYXKEYS.COLLECTION.POLICY_CATEGORIES, allPolicyCategories);
176+
const [allPolicyTagLists = getEmptyObject<NonNullable<OnyxCollection<PolicyTagLists>>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {canBeMissing: false});
177+
const selectedPolicyTagLists = getAllPolicyValues(policyID, ONYXKEYS.COLLECTION.POLICY_TAGS, allPolicyTagLists);
178+
const tagListsUnpacked = Object.values(allPolicyTagLists ?? {})
179+
.filter((item): item is NonNullable<PolicyTagLists> => !!item)
180+
.map(getTagNamesFromTagsLists)
181+
.flat();
182+
183+
const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false, selector: (session) => session?.email});
184+
185+
const {sections: workspaces} = useWorkspaceList({
186+
policies,
187+
currentUserLogin,
188+
shouldShowPendingDeletePolicy: false,
189+
selectedPolicyIDs: undefined,
190+
searchTerm: '',
191+
localeCompare,
192+
});
193+
194+
// When looking if a user has any categories to display, we want to ignore the policies that are of type PERSONAL
195+
const nonPersonalPolicyCategoryIds = Object.values(policies)
196+
.filter((policy): policy is NonNullable<Policy> => !!(policy && policy.type !== CONST.POLICY.TYPE.PERSONAL))
197+
.map((policy) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`);
198+
const nonPersonalPolicyCategoryCount = Object.keys(allPolicyCategories).filter((policyCategoryId) => nonPersonalPolicyCategoryIds.includes(policyCategoryId)).length;
199+
200+
const areCategoriesEnabled = isFeatureEnabledInPolicies(policies, CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED);
201+
const areTagsEnabled = isFeatureEnabledInPolicies(policies, CONST.POLICY.MORE_FEATURES.ARE_TAGS_ENABLED);
202+
const areCardsEnabled =
203+
isFeatureEnabledInPolicies(policies, CONST.POLICY.MORE_FEATURES.ARE_COMPANY_CARDS_ENABLED) ||
204+
isFeatureEnabledInPolicies(policies, CONST.POLICY.MORE_FEATURES.ARE_EXPENSIFY_CARDS_ENABLED);
205+
const areTaxEnabled = isFeatureEnabledInPolicies(policies, CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED);
206+
const shouldDisplayCategoryFilter = shouldDisplayFilter(nonPersonalPolicyCategoryCount, areCategoriesEnabled, !!selectedPolicyCategories);
207+
const shouldDisplayTagFilter = shouldDisplayFilter(tagListsUnpacked.length, areTagsEnabled, !!selectedPolicyTagLists);
208+
const shouldDisplayCardFilter = shouldDisplayFilter(Object.keys(allCards).length, areCardsEnabled);
209+
const shouldDisplayTaxFilter = shouldDisplayFilter(Object.keys(taxRates).length, areTaxEnabled);
210+
const shouldDisplayWorkspaceFilter = workspaces.some((section) => section.data.length !== 0);
211+
const shouldDisplayGroupByFilter = groupBy === CONST.SEARCH.GROUP_BY.FROM || groupBy === CONST.SEARCH.GROUP_BY.CARD;
212+
213+
let currentType = searchAdvancedFilters?.type ?? CONST.SEARCH.DATA_TYPES.EXPENSE;
214+
215+
if (!Object.keys(typeFiltersKeys).includes(currentType)) {
216+
currentType = CONST.SEARCH.DATA_TYPES.EXPENSE;
217+
}
218+
219+
return {
220+
currentType,
221+
typeFiltersKeys: typeFiltersKeys[currentType]
222+
.map((section) =>
223+
section
224+
.map((key) => {
225+
if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && !shouldDisplayCategoryFilter) {
226+
return;
227+
}
228+
if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG && !shouldDisplayTagFilter) {
229+
return;
230+
}
231+
if ((key === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID || CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED) && !shouldDisplayCardFilter) {
232+
return;
233+
}
234+
if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE && !shouldDisplayTaxFilter) {
235+
return;
236+
}
237+
if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID && !shouldDisplayWorkspaceFilter) {
238+
return;
239+
}
240+
if (key === CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY && !shouldDisplayGroupByFilter) {
241+
return;
242+
}
243+
return key;
244+
})
245+
.filter((filter): filter is NonNullable<typeof filter> => !!filter),
246+
)
247+
.filter((section) => !!section.length),
248+
};
249+
}
250+
251+
export default useAdvancedSearchFilters;

0 commit comments

Comments
 (0)