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 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/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 @@ function buildDateFilterQuery(filterValues: Partial<SearchAdvancedFiltersForm>)
return dateFilter;
}

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

/**
* Given object with chosen search filters builds correct query string from them
*/
Expand All @@ -390,6 +397,11 @@ function buildQueryStringFromFilters(filterValues: Partial<SearchAdvancedFilters
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(sanitizeString).join(',')}`;
}

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 @@ function getFilterDisplayTitle(filters: Partial<SearchAdvancedFiltersForm>, fiel
return dateValue;
}

if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && filters[fieldName]) {
const categories = filters[fieldName] ?? [];
return categories.join(', ');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You did not sort the categories before showing which caused #48496

}

// 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 @@ function AdvancedSearchFilters() {
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