diff --git a/src/CONST.ts b/src/CONST.ts index 683af4157ecd..780e0d7fb18d 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -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', diff --git a/src/components/Search/SearchMultipleSelectionPicker.tsx b/src/components/Search/SearchMultipleSelectionPicker.tsx index 8e9695907cbd..12cb775ca6be 100644 --- a/src/components/Search/SearchMultipleSelectionPicker.tsx +++ b/src/components/Search/SearchMultipleSelectionPicker.tsx @@ -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 = { @@ -31,17 +30,6 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [selectedItems, setSelectedItems] = useState(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]); @@ -49,7 +37,7 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit 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, @@ -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, diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 38e50ea382b8..dd61b368d48f 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -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) { @@ -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)); + 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 @@ -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. */ @@ -871,5 +887,6 @@ export { getQueryWithoutFilters, getUserFriendlyKey, isDefaultExpensesQuery, + sortOptionsWithEmptyValue, shouldHighlight, }; diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index 64a9c769fbe9..59daf76cba08 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -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'; @@ -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, 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 @@ -390,7 +379,7 @@ function getFilterDisplayTitle(filters: Partial, 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(', '); } @@ -398,7 +387,7 @@ function getFilterDisplayTitle(filters: Partial, filt 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(', '); } diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx index 92a0d5a8478d..a2e81591d0a1 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx @@ -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'; @@ -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(); Object.values(allPolicyCategories ?? {}).map((policyCategories) => Object.values(policyCategories ?? {}).forEach((category) => uniqueCategoryNames.add(category.name))); @@ -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 ( { - 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(); const tagListsUnpacked = Object.values(allPolicyTagLists ?? {}).filter((item) => !!item) as PolicyTagLists[]; diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 17daff395780..cb40f1ca6ccc 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -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 = { @@ -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 = { + 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 = {}; @@ -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);