Skip to content
3 changes: 2 additions & 1 deletion src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6665,7 +6665,8 @@ const CONST = {
BILLABLE: 'billable',
POLICY_ID: 'policyID',
},
EMPTY_VALUE: 'none',
TAG_EMPTY_VALUE: 'none',
CATEGORY_EMPTY_VALUE: 'none,Uncategorized',
SEARCH_ROUTER_ITEM_TYPE: {
CONTEXTUAL_SUGGESTION: 'contextualSuggestion',
AUTOCOMPLETE_SUGGESTION: 'autocompleteSuggestion',
Expand Down
18 changes: 3 additions & 15 deletions src/components/Search/SearchMultipleSelectionPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ 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 Navigation from '@libs/Navigation/Navigation';
import type {OptionData} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import {sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils';
import ROUTES from '@src/ROUTES';

type SearchMultipleSelectionPickerItem = {
Expand All @@ -31,25 +30,14 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit
const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
const [selectedItems, setSelectedItems] = useState<SearchMultipleSelectionPickerItem[]>(initiallySelectedItems ?? []);

const sortOptionsWithEmptyValue = (a: SearchMultipleSelectionPickerItem, b: SearchMultipleSelectionPickerItem) => {
// Always show `No category` and `No tag` as the first option
if (a.value === CONST.SEARCH.EMPTY_VALUE) {
return -1;
}
if (b.value === CONST.SEARCH.EMPTY_VALUE) {
return 1;
}
return localeCompare(a.name, b.name);
};

useEffect(() => {
setSelectedItems(initiallySelectedItems ?? []);
}, [initiallySelectedItems]);

const {sections, noResultsFound} = useMemo(() => {
const selectedItemsSection = selectedItems
.filter((item) => item?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()))
.sort((a, b) => sortOptionsWithEmptyValue(a, b))
.sort((a, b) => sortOptionsWithEmptyValue(a.value as string, b.value as string))
.map((item) => ({
text: item.name,
keyForList: item.name,
Expand All @@ -58,7 +46,7 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit
}));
const remainingItemsSection = items
.filter((item) => selectedItems.some((selectedItem) => selectedItem.value === item.value) === false && item?.name?.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()))
.sort((a, b) => sortOptionsWithEmptyValue(a, b))
.sort((a, b) => sortOptionsWithEmptyValue(a.value as string, b.value as string))
.map((item) => ({
text: item.name,
keyForList: item.name,
Expand Down
23 changes: 20 additions & 3 deletions src/libs/SearchQueryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ function buildFilterFormValuesFromQuery(
.map((tagList) => getTagNamesFromTagsLists(tagList ?? {}))
.flat();
const uniqueTags = new Set(tags);
uniqueTags.add(CONST.SEARCH.EMPTY_VALUE);
uniqueTags.add(CONST.SEARCH.TAG_EMPTY_VALUE);
filtersForm[filterKey] = filterValues.filter((name) => uniqueTags.has(name));
}
if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY) {
Expand All @@ -509,8 +509,11 @@ function buildFilterFormValuesFromQuery(
.map((item) => Object.values(item ?? {}).map((category) => category.name))
.flat();
const uniqueCategories = new Set(categories);
uniqueCategories.add(CONST.SEARCH.EMPTY_VALUE);
filtersForm[filterKey] = filterValues.filter((name) => uniqueCategories.has(name));
Comment thread
samranahm marked this conversation as resolved.
const emptyCategories = CONST.SEARCH.CATEGORY_EMPTY_VALUE.split(',');
const hasEmptyCategoriesInFilter = emptyCategories.every((category) => filterValues.includes(category));
// We split CATEGORY_EMPTY_VALUE into individual values to detect both are present in filterValues.
// If empty categories are found, append the CATEGORY_EMPTY_VALUE to filtersForm.
filtersForm[filterKey] = filterValues.filter((name) => uniqueCategories.has(name)).concat(hasEmptyCategoriesInFilter ? [CONST.SEARCH.CATEGORY_EMPTY_VALUE] : []);
}
if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD) {
filtersForm[filterKey] = filterValues
Expand Down Expand Up @@ -739,6 +742,19 @@ function isDefaultExpensesQuery(queryJSON: SearchQueryJSON) {
return queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE && queryJSON.status === CONST.SEARCH.STATUS.EXPENSE.ALL && !queryJSON.filters && !queryJSON.groupBy && !queryJSON.policyID;
}

/**
* Always show `No category` and `No tag` as the first option
*/
const sortOptionsWithEmptyValue = (a: string, b: string) => {
if (a === CONST.SEARCH.CATEGORY_EMPTY_VALUE || a === CONST.SEARCH.TAG_EMPTY_VALUE) {
return -1;
}
if (b === CONST.SEARCH.CATEGORY_EMPTY_VALUE || b === CONST.SEARCH.TAG_EMPTY_VALUE) {
return 1;
}
return localeCompare(a, b);
};

/**
* Given a search query, this function will standardize the query by replacing display values with their corresponding IDs.
*/
Expand Down Expand Up @@ -871,5 +887,6 @@ export {
getQueryWithoutFilters,
getUserFriendlyKey,
isDefaultExpensesQuery,
sortOptionsWithEmptyValue,
shouldHighlight,
};
17 changes: 3 additions & 14 deletions src/pages/Search/AdvancedSearchFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import Navigation from '@libs/Navigation/Navigation';
import {createDisplayName} from '@libs/PersonalDetailsUtils';
import {getAllTaxRates, getCleanedTagName, getTagNamesFromTagsLists, isPolicyFeatureEnabled} from '@libs/PolicyUtils';
import {getReportName} from '@libs/ReportUtils';
import {buildCannedSearchQuery, buildQueryStringFromFilterFormValues, buildSearchQueryJSON, isCannedSearchQuery} from '@libs/SearchQueryUtils';
import {buildCannedSearchQuery, buildQueryStringFromFilterFormValues, buildSearchQueryJSON, isCannedSearchQuery, sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils';
import {getExpenseTypeTranslationKey} from '@libs/SearchUIUtils';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
Expand Down Expand Up @@ -329,17 +329,6 @@ function getFilterParticipantDisplayTitle(accountIDs: string[], personalDetails:
.join(', ');
}

const sortOptionsWithEmptyValue = (a: string, b: string) => {
// Always show `No category` and `No tag` as the first option
if (a === CONST.SEARCH.EMPTY_VALUE) {
return -1;
}
if (b === CONST.SEARCH.EMPTY_VALUE) {
return 1;
}
return localeCompare(a, b);
};

function getFilterDisplayTitle(filters: Partial<SearchAdvancedFiltersForm>, filterKey: SearchFilterKey, translate: LocaleContextProps['translate']) {
if (DATE_FILTER_KEYS.includes(filterKey as SearchDateFilterKeys)) {
// the value of date filter is a combination of dateBefore + dateAfter values
Expand Down Expand Up @@ -390,15 +379,15 @@ function getFilterDisplayTitle(filters: Partial<SearchAdvancedFiltersForm>, filt
const filterArray = filters[nonDateFilterKey] ?? [];
return filterArray
.sort(sortOptionsWithEmptyValue)
.map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noCategory') : value))
.map((value) => (value === CONST.SEARCH.CATEGORY_EMPTY_VALUE ? translate('search.noCategory') : value))
.join(', ');
}

if (nonDateFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG && filters[nonDateFilterKey]) {
const filterArray = filters[nonDateFilterKey] ?? [];
return filterArray
.sort(sortOptionsWithEmptyValue)
.map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noTag') : getCleanedTagName(value)))
.map((value) => (value === CONST.SEARCH.TAG_EMPTY_VALUE ? translate('search.noTag') : getCleanedTagName(value)))
.join(', ');
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import SearchMultipleSelectionPicker from '@components/Search/SearchMultipleSele
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import * as SearchActions from '@userActions/Search';
import {updateAdvancedFilters} from '@userActions/Search';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
Expand All @@ -16,19 +16,20 @@ function SearchFiltersCategoryPage() {
const styles = useThemeStyles();
const {translate} = useLocalize();

const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM);
const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {canBeMissing: true});
const selectedCategoriesItems = searchAdvancedFiltersForm?.category?.map((category) => {
if (category === CONST.SEARCH.EMPTY_VALUE) {
if (category === CONST.SEARCH.CATEGORY_EMPTY_VALUE) {
return {name: translate('search.noCategory'), value: category};
}
return {name: category, value: category};
});
// eslint-disable-next-line rulesdir/no-default-id-values
const policyID = searchAdvancedFiltersForm?.policyID ?? '-1';
const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {canBeMissing: true});
const singlePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`];

const categoryItems = useMemo(() => {
const items = [{name: translate('search.noCategory'), value: CONST.SEARCH.EMPTY_VALUE as string}];
const items = [{name: translate('search.noCategory'), value: CONST.SEARCH.CATEGORY_EMPTY_VALUE as string}];
if (!singlePolicyCategories) {
const uniqueCategoryNames = new Set<string>();
Object.values(allPolicyCategories ?? {}).map((policyCategories) => Object.values(policyCategories ?? {}).forEach((category) => uniqueCategoryNames.add(category.name)));
Expand All @@ -39,7 +40,7 @@ function SearchFiltersCategoryPage() {
return items;
}, [allPolicyCategories, singlePolicyCategories, translate]);

const onSaveSelection = useCallback((values: string[]) => SearchActions.updateAdvancedFilters({category: values}), []);
const onSaveSelection = useCallback((values: string[]) => updateAdvancedFilters({category: values}), []);

return (
<ScreenWrapper
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,19 @@ function SearchFiltersTagPage() {
const styles = useThemeStyles();
const {translate} = useLocalize();

const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM);
const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {canBeMissing: true});
const selectedTagsItems = searchAdvancedFiltersForm?.tag?.map((tag) => {
if (tag === CONST.SEARCH.EMPTY_VALUE) {
if (tag === CONST.SEARCH.TAG_EMPTY_VALUE) {
return {name: translate('search.noTag'), value: tag};
}
return {name: getCleanedTagName(tag), value: tag};
});
const policyID = searchAdvancedFiltersForm?.policyID;
const [allPolicyTagLists = {}] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
const [allPolicyTagLists = {}] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {canBeMissing: true});
const singlePolicyTagLists = allPolicyTagLists[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`];

const tagItems = useMemo(() => {
const items = [{name: translate('search.noTag'), value: CONST.SEARCH.EMPTY_VALUE as string}];
const items = [{name: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}];
if (!singlePolicyTagLists) {
const uniqueTagNames = new Set<string>();
const tagListsUnpacked = Object.values(allPolicyTagLists ?? {}).filter((item) => !!item) as PolicyTagLists[];
Expand Down
59 changes: 58 additions & 1 deletion tests/unit/Search/SearchQueryUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/naming-convention */
// we need "dirty" object key names in these tests
import {generatePolicyID} from '@libs/actions/Policy/Policy';
import CONST from '@src/CONST';
import {buildQueryStringFromFilterFormValues, getQueryWithUpdatedValues, shouldHighlight} from '@src/libs/SearchQueryUtils';
import {buildFilterFormValuesFromQuery, buildQueryStringFromFilterFormValues, buildSearchQueryJSON, getQueryWithUpdatedValues, shouldHighlight} from '@src/libs/SearchQueryUtils';
import ONYXKEYS from '@src/ONYXKEYS';
import type {SearchAdvancedFiltersForm} from '@src/types/form';

const personalDetailsFakeData = {
Expand Down Expand Up @@ -129,6 +131,18 @@ describe('SearchQueryUtils', () => {
expect(result).toEqual('sortBy:date sortOrder:desc type:expense status:all category:services,consulting currency:USD,EUR');
});

test('has empty category values', () => {
const filterValues: Partial<SearchAdvancedFiltersForm> = {
type: 'expense',
status: 'all',
category: ['equipment', 'consulting', 'none,Uncategorized'],
};

const result = buildQueryStringFromFilterFormValues(filterValues);

expect(result).toEqual('sortBy:date sortOrder:desc type:expense status:all category:equipment,consulting,none,Uncategorized');
});

test('empty filter values', () => {
const filterValues: Partial<SearchAdvancedFiltersForm> = {};

Expand Down Expand Up @@ -168,6 +182,49 @@ describe('SearchQueryUtils', () => {
});
});

describe('buildFilterFormValuesFromQuery', () => {
test('category filter includes empty values', () => {
const policyID = generatePolicyID();
const queryString = 'sortBy:date sortOrder:desc type:expense status:all category:none,Uncategorized,Maintenance';
const queryJSON = buildSearchQueryJSON(queryString);

const policyCategories = {
[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]: {
Maintenance: {
enabled: true,
name: 'Maintenance',
},
Travel: {
enabled: true,
name: 'Travel',
},
Meals: {
enabled: true,
name: 'Meals',
},
},
};
const policyTags = {};
const currencyList = {};
const personalDetails = {};
const cardList = {};
const reports = {};
const taxRates = {};

if (!queryJSON) {
throw new Error('Failed to parse query string');
}

const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates);

expect(result).toEqual({
type: 'expense',
status: 'all',
category: ['Maintenance', 'none,Uncategorized'],
});
});
});

describe('shouldHighlight', () => {
it('returns false if either input is empty', () => {
expect(shouldHighlight('', 'test')).toBe(false);
Expand Down