Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

advanced filters category #46197

Merged
Show file tree
Hide file tree
Changes from 14 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/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const ROUTES = {

SEARCH_ADVANCED_FILTERS_STATUS: 'search/filters/status',

SEARCH_ADVANCED_FILTERS_CATEGORY: 'search/filters/category',

SEARCH_REPORT: {
route: 'search/view/:reportID',
getRoute: (reportID: string) => `search/view/${reportID}` as const,
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const SCREENS = {
ADVANCED_FILTERS_DATE_RHP: 'Search_Advanced_Filters_Date_RHP',
ADVANCED_FILTERS_TYPE_RHP: 'Search_Advanced_Filters_Type_RHP',
ADVANCED_FILTERS_STATUS_RHP: 'Search_Advanced_Filters_Status_RHP',
ADVANCED_FILTERS_CATEGORY_RHP: 'Search_Advanced_Filters_Category_RHP',
TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP',
BOTTOM_TAB: 'Search_Bottom_Tab',
},
Expand Down
86 changes: 86 additions & 0 deletions src/components/SelectionList/SelectableListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React, {useCallback} from 'react';
import {View} from 'react-native';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import SelectCircle from '@components/SelectCircle';
import TextWithTooltip from '@components/TextWithTooltip';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import BaseListItem from './BaseListItem';
import type {InviteMemberListItemProps, ListItem} from './types';

function SelectableListItem<TItem extends ListItem>({
item,
isFocused,
showTooltip,
isDisabled,
canSelectMultiple,
onSelectRow,
onCheckboxPress,
onDismissError,
onFocus,
shouldSyncFocus,
}: InviteMemberListItemProps<TItem>) {
const styles = useThemeStyles();
const handleCheckboxPress = useCallback(() => {
if (onCheckboxPress) {
onCheckboxPress(item);
} else {
onSelectRow(item);
}
}, [item, onCheckboxPress, onSelectRow]);

return (
<BaseListItem
item={item}
wrapperStyle={[styles.flex1, styles.justifyContentBetween, styles.sidebarLinkInner, isFocused && styles.sidebarLinkActive]}
isFocused={isFocused}
isDisabled={isDisabled}
showTooltip={showTooltip}
canSelectMultiple={canSelectMultiple}
onSelectRow={onSelectRow}
onDismissError={onDismissError}
errors={item.errors}
pendingAction={item.pendingAction}
keyForList={item.keyForList}
onFocus={onFocus}
shouldSyncFocus={shouldSyncFocus}
>
<>
<View style={[styles.flex1, styles.flexColumn, styles.justifyContentCenter, styles.alignItemsStretch, styles.optionRow]}>
<View style={[styles.flexRow, styles.alignItemsCenter]}>
<TextWithTooltip
shouldShowTooltip={showTooltip}
text={item.text ?? ''}
style={[
styles.optionDisplayName,
isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText,
item.isBold !== false && styles.sidebarLinkTextBold,
styles.pre,
item.alternateText ? styles.mb1 : null,
]}
/>
</View>
</View>
{!!item.rightElement && item.rightElement}
{canSelectMultiple && !item.isDisabled && (
<PressableWithFeedback
onPress={handleCheckboxPress}
disabled={isDisabled}
role={CONST.ROLE.BUTTON}
accessibilityLabel={item.text ?? ''}
style={[styles.ml2, styles.optionSelectCircle]}
>
<SelectCircle
isChecked={item.isSelected ?? false}
selectCircleStyles={styles.ml0}
/>
</PressableWithFeedback>
)}
</>
</BaseListItem>
);
}

SelectableListItem.displayName = 'SelectableListItem';

export default SelectableListItem;
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,7 @@ const SearchAdvancedFiltersModalStackNavigator = createModalStackNavigator<Searc
[SCREENS.SEARCH.ADVANCED_FILTERS_DATE_RHP]: () => require<ReactComponentModule>('../../../../pages/Search/SearchFiltersDatePage').default,
[SCREENS.SEARCH.ADVANCED_FILTERS_TYPE_RHP]: () => require<ReactComponentModule>('../../../../pages/Search/SearchFiltersTypePage').default,
[SCREENS.SEARCH.ADVANCED_FILTERS_STATUS_RHP]: () => require<ReactComponentModule>('../../../../pages/Search/SearchFiltersStatusPage').default,
[SCREENS.SEARCH.ADVANCED_FILTERS_CATEGORY_RHP]: () => require<ReactComponentModule>('../../../../pages/Search/SearchFiltersCategoryPage').default,
});

const RestrictedActionModalStackNavigator = createModalStackNavigator<SearchReportParamList>({
Expand Down
1 change: 1 addition & 0 deletions src/libs/Navigation/linkingConfig/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,7 @@ const config: LinkingOptions<RootStackParamList>['config'] = {
[SCREENS.SEARCH.ADVANCED_FILTERS_DATE_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_DATE,
[SCREENS.SEARCH.ADVANCED_FILTERS_TYPE_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_TYPE,
[SCREENS.SEARCH.ADVANCED_FILTERS_STATUS_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_STATUS,
[SCREENS.SEARCH.ADVANCED_FILTERS_CATEGORY_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_CATEGORY,
},
},
[SCREENS.RIGHT_MODAL.RESTRICTED_ACTION]: {
Expand Down
12 changes: 12 additions & 0 deletions src/libs/SearchUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,13 @@
return dateFilter;
}

function sanitizeCategoryName(category: string) {
if (category.includes(' ')) {
return `"${category}"`;
}
return category;
}

/**
* Given object with chosen search filters builds correct query string from them
*/
Expand All @@ -390,6 +397,11 @@
return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS}:${filterValue as string}`;
}

if (filterKey === INPUT_IDS.CATEGORY && filterValues[filterKey]) {
const categories = filterValues[filterKey];
return `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY}:${categories.map((category) => sanitizeCategoryName(category)).join(',')}`;

Check failure on line 402 in src/libs/SearchUtils.ts

View workflow job for this annotation

GitHub Actions / typecheck

'categories' is possibly 'undefined'.
}

return undefined;
})
.filter(Boolean)
Expand Down
10 changes: 10 additions & 0 deletions src/pages/Search/AdvancedSearchFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
return dateValue;
}

if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && filters[fieldName]) {
const categories = filters[fieldName];
return categories.map((category) => Str.recapitalize(category)).join(', ');

Check failure on line 41 in src/pages/Search/AdvancedSearchFilters.tsx

View workflow job for this annotation

GitHub Actions / typecheck

'categories' is possibly 'undefined'.
}

// Todo Once all Advanced filters are implemented this line can be cleaned up. See: https://github.com/Expensify/App/issues/45026
// @ts-expect-error this property access is temporarily an error, because not every SYNTAX_FILTER_KEYS is handled by form.
// When all filters are updated here: src/types/form/SearchAdvancedFiltersForm.ts this line comment + type cast can be removed.
Expand Down Expand Up @@ -68,6 +73,11 @@
description: 'common.date' as const,
route: ROUTES.SEARCH_ADVANCED_FILTERS_DATE,
},
{
title: getFilterDisplayTitle(searchAdvancedFilters, CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, translate),
description: 'common.category' as const,
route: ROUTES.SEARCH_ADVANCED_FILTERS_CATEGORY,
},
],
[searchAdvancedFilters, translate],
);
Expand Down
154 changes: 154 additions & 0 deletions src/pages/Search/SearchFiltersCategoryPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import React, {useCallback, useMemo, useState} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import Button from '@components/Button';
import type {FormOnyxValues} from '@components/Form/types';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
import SelectableListItem from '@components/SelectionList/SelectableListItem';
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import localeCompare from '@libs/LocaleCompare';
import type {CategorySection} from '@libs/OptionsListUtils';
import type {OptionData} from '@libs/ReportUtils';
import Navigation from '@navigation/Navigation';
import * as SearchActions from '@userActions/Search';
import ONYXKEYS from '@src/ONYXKEYS';

function SearchFiltersCategoryPage() {
const styles = useThemeStyles();
const {translate} = useLocalize();

const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
const [noResultsFound, setNoResultsFound] = useState(false);

const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM);
const currentCategories = searchAdvancedFiltersForm?.category;
const [newCategories, setNewCategories] = useState<string[]>(currentCategories ?? []);
const policyID = searchAdvancedFiltersForm?.policyID ?? '-1';

const [allPolicyIdCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
const singlePolicyCategories = allPolicyIdCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`];

const categoryNames = useMemo(() => {
let categories: string[] = [];
if (!singlePolicyCategories) {
categories = Object.values(allPolicyIdCategories ?? {})
.map((policyCategories) => Object.values(policyCategories ?? {}).map((category) => category.name))
.flat();
} else {
categories = Object.values(singlePolicyCategories ?? {}).map((value) => value.name);
}

return [...new Set(categories)];
}, [allPolicyIdCategories, singlePolicyCategories]);

const sections = useMemo(() => {
const newSections: CategorySection[] = [];
const chosenCategories = newCategories
.filter((category) => category.toLowerCase().includes(debouncedSearchTerm.toLowerCase()))
.sort((a, b) => localeCompare(a, b))
.map((name) => ({
text: name,
keyForList: name,
isSelected: newCategories?.includes(name) ?? false,
}));
const remainingCategories = categoryNames
.filter((category) => newCategories.includes(category) === false)
.filter((category) => category.toLowerCase().includes(debouncedSearchTerm.toLowerCase()))
.sort((a, b) => localeCompare(a, b))
.map((name) => ({
text: name,
keyForList: name,
isSelected: newCategories?.includes(name) ?? false,
}));
if (chosenCategories.length === 0 && remainingCategories.length === 0) {
setNoResultsFound(true);
} else {
setNoResultsFound(false);
}
newSections.push({
title: undefined,
data: chosenCategories,
shouldShow: chosenCategories.length > 0,
});
newSections.push({
title: translate('common.category'),
data: remainingCategories,
shouldShow: remainingCategories.length > 0,
});
return newSections;
}, [categoryNames, newCategories, translate, debouncedSearchTerm]);

const updateCategory = useCallback((values: Partial<FormOnyxValues<typeof ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM>>) => {
SearchActions.updateAdvancedFilters(values);
}, []);

const handleConfirmSelection = useCallback(() => {
updateCategory({
category: newCategories.sort((a, b) => localeCompare(a, b)),
});
Navigation.goBack();
}, [newCategories, updateCategory]);

const updateNewCategories = useCallback(
(item: Partial<OptionData>) => {
if (!item.text) {
return;
}
if (item.isSelected) {
setNewCategories(newCategories?.filter((category) => category !== item.text));
} else {
setNewCategories([...(newCategories ?? []), item.text]);
}
},
[newCategories],
);

const footerContent = useMemo(
() => (
<Button
success
text={translate('common.save')}
pressOnEnter
onPress={handleConfirmSelection}
large
/>
),
[translate, handleConfirmSelection],
);
return (
<ScreenWrapper
testID={SearchFiltersCategoryPage.displayName}
shouldShowOfflineIndicatorInWideScreen
offlineIndicatorStyle={styles.mtAuto}
>
<FullPageNotFoundView shouldShow={false}>
<HeaderWithBackButton title={translate('common.category')} />
<View style={[styles.flex1]}>
<SelectionList
sections={sections}
textInputValue={searchTerm}
onChangeText={setSearchTerm}
textInputLabel={translate('common.search')}
onSelectRow={updateNewCategories}
headerMessage={noResultsFound ? translate('common.noResultsFound') : undefined}
footerContent={footerContent}
shouldStopPropagation
showLoadingPlaceholder={!noResultsFound}
shouldShowTooltips
canSelectMultiple
ListItem={SelectableListItem}
/>
</View>
</FullPageNotFoundView>
</ScreenWrapper>
);
}

SearchFiltersCategoryPage.displayName = 'SearchFiltersCategoryPage';

export default SearchFiltersCategoryPage;
4 changes: 4 additions & 0 deletions src/types/form/SearchAdvancedFiltersForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const INPUT_IDS = {
STATUS: 'status',
DATE_AFTER: 'dateAfter',
DATE_BEFORE: 'dateBefore',
CATEGORY: 'category',
POLICY_ID: 'policyID',
} as const;

type InputID = ValueOf<typeof INPUT_IDS>;
Expand All @@ -17,6 +19,8 @@ type SearchAdvancedFiltersForm = Form<
[INPUT_IDS.DATE_AFTER]: string;
[INPUT_IDS.DATE_BEFORE]: string;
[INPUT_IDS.STATUS]: string;
[INPUT_IDS.CATEGORY]: string[];
[INPUT_IDS.POLICY_ID]: string;
}
>;

Expand Down
Loading