Skip to content

Commit

Permalink
Merge pull request #46197 from software-mansion-labs/289Adam289/46038…
Browse files Browse the repository at this point in the history
…-advanced-filters-category

advanced filters category
  • Loading branch information
luacmartins authored Aug 1, 2024
2 parents bbaa9da + ee320a8 commit d69b203
Show file tree
Hide file tree
Showing 9 changed files with 271 additions and 0 deletions.
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 @@ -1017,6 +1017,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 @@ -387,6 +387,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 @@ -402,6 +409,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(', ');
}

// 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

0 comments on commit d69b203

Please sign in to comment.