From f0a2deb59c118a72344627b77f0f7e2761c11046 Mon Sep 17 00:00:00 2001 From: Samran Date: Wed, 14 May 2025 23:58:32 +0500 Subject: [PATCH 1/8] Add Uncategorized expenses in No category filter --- src/CONST.ts | 3 ++- src/components/Search/SearchMultipleSelectionPicker.tsx | 4 ++-- src/libs/SearchQueryUtils.ts | 8 +++++--- src/pages/Search/AdvancedSearchFilters.tsx | 8 ++++---- .../SearchFiltersCategoryPage.tsx | 4 ++-- .../SearchAdvancedFiltersPage/SearchFiltersTagPage.tsx | 4 ++-- 6 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index c42fb547ab71..b70cf99a1472 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -6599,7 +6599,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..abaea0ab015f 100644 --- a/src/components/Search/SearchMultipleSelectionPicker.tsx +++ b/src/components/Search/SearchMultipleSelectionPicker.tsx @@ -33,10 +33,10 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit const sortOptionsWithEmptyValue = (a: SearchMultipleSelectionPickerItem, b: SearchMultipleSelectionPickerItem) => { // Always show `No category` and `No tag` as the first option - if (a.value === CONST.SEARCH.EMPTY_VALUE) { + if (a.value === CONST.SEARCH.CATEGORY_EMPTY_VALUE || a.value === CONST.SEARCH.TAG_EMPTY_VALUE) { return -1; } - if (b.value === CONST.SEARCH.EMPTY_VALUE) { + if (b.value === CONST.SEARCH.CATEGORY_EMPTY_VALUE || b.value === CONST.SEARCH.TAG_EMPTY_VALUE) { return 1; } return localeCompare(a.name, b.name); diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index ba1c985eaef0..05297db1744e 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -498,7 +498,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) { @@ -508,8 +508,10 @@ 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)); + uniqueCategories.add(CONST.SEARCH.CATEGORY_EMPTY_VALUE); + const EMPTY_VALUE_CATEGORIES = CONST.SEARCH.CATEGORY_EMPTY_VALUE.split(','); + const hasEmptyCategoryInFilter = filterValues.some((val) => EMPTY_VALUE_CATEGORIES.includes(val)) && uniqueCategories.has(CONST.SEARCH.CATEGORY_EMPTY_VALUE); + filtersForm[filterKey] = filterValues.filter((name) => uniqueCategories.has(name)).concat(hasEmptyCategoryInFilter ? [CONST.SEARCH.CATEGORY_EMPTY_VALUE] : []); } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD) { filtersForm[filterKey] = filterValues diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index 64a9c769fbe9..afdc6c79314f 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -331,10 +331,10 @@ function getFilterParticipantDisplayTitle(accountIDs: string[], personalDetails: const sortOptionsWithEmptyValue = (a: string, b: string) => { // Always show `No category` and `No tag` as the first option - if (a === CONST.SEARCH.EMPTY_VALUE) { + if (a === CONST.SEARCH.CATEGORY_EMPTY_VALUE || a === CONST.SEARCH.TAG_EMPTY_VALUE) { return -1; } - if (b === CONST.SEARCH.EMPTY_VALUE) { + if (b === CONST.SEARCH.CATEGORY_EMPTY_VALUE || b === CONST.SEARCH.TAG_EMPTY_VALUE) { return 1; } return localeCompare(a, b); @@ -390,7 +390,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 +398,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..517f1e858a28 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx @@ -18,7 +18,7 @@ function SearchFiltersCategoryPage() { const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); 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}; @@ -28,7 +28,7 @@ function SearchFiltersCategoryPage() { 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))); diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTagPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTagPage.tsx index b65a82ad7f8c..77e8bc80b0a0 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTagPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTagPage.tsx @@ -20,7 +20,7 @@ function SearchFiltersTagPage() { const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); 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}; @@ -30,7 +30,7 @@ function SearchFiltersTagPage() { 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[]; From b81c7258830cf35546aea839665ed2f2cb01d2b1 Mon Sep 17 00:00:00 2001 From: Samran Date: Thu, 15 May 2025 01:41:00 +0500 Subject: [PATCH 2/8] fix lint error changed files --- .../SearchFiltersCategoryPage.tsx | 9 +++++---- .../SearchAdvancedFiltersPage/SearchFiltersTagPage.tsx | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx index 517f1e858a28..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,15 +16,16 @@ 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.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(() => { @@ -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.TAG_EMPTY_VALUE) { return {name: translate('search.noTag'), value: tag}; @@ -26,7 +26,7 @@ function SearchFiltersTagPage() { 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(() => { From 470b9d0d29e051e3288904c8a558d878bc163e91 Mon Sep 17 00:00:00 2001 From: Samran Date: Fri, 23 May 2025 22:06:15 +0500 Subject: [PATCH 3/8] Follow design doc for constant naming pattern --- src/libs/SearchQueryUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 05297db1744e..b1caa5bcb44f 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -509,8 +509,8 @@ function buildFilterFormValuesFromQuery( .flat(); const uniqueCategories = new Set(categories); uniqueCategories.add(CONST.SEARCH.CATEGORY_EMPTY_VALUE); - const EMPTY_VALUE_CATEGORIES = CONST.SEARCH.CATEGORY_EMPTY_VALUE.split(','); - const hasEmptyCategoryInFilter = filterValues.some((val) => EMPTY_VALUE_CATEGORIES.includes(val)) && uniqueCategories.has(CONST.SEARCH.CATEGORY_EMPTY_VALUE); + const emptyCategories = CONST.SEARCH.CATEGORY_EMPTY_VALUE.split(','); + const hasEmptyCategoryInFilter = filterValues.some((category) => emptyCategories.includes(category)) && uniqueCategories.has(CONST.SEARCH.CATEGORY_EMPTY_VALUE); filtersForm[filterKey] = filterValues.filter((name) => uniqueCategories.has(name)).concat(hasEmptyCategoryInFilter ? [CONST.SEARCH.CATEGORY_EMPTY_VALUE] : []); } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD) { From 3cddb6421155f823c97b5bd22ac124b4124c229e Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Tue, 27 May 2025 19:36:31 +0500 Subject: [PATCH 4/8] Remove redundant code and add comment for explanation --- src/libs/SearchQueryUtils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index b1caa5bcb44f..ab04f774376d 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -510,7 +510,9 @@ function buildFilterFormValuesFromQuery( const uniqueCategories = new Set(categories); uniqueCategories.add(CONST.SEARCH.CATEGORY_EMPTY_VALUE); const emptyCategories = CONST.SEARCH.CATEGORY_EMPTY_VALUE.split(','); - const hasEmptyCategoryInFilter = filterValues.some((category) => emptyCategories.includes(category)) && uniqueCategories.has(CONST.SEARCH.CATEGORY_EMPTY_VALUE); + const hasEmptyCategoryInFilter = filterValues.some((category) => emptyCategories.includes(category)); + // We split CATEGORY_EMPTY_VALUE into individual values to detect if any are present in filterValues. + // If empty category is found, append the full CATEGORY_EMPTY_VALUE to filtersForm. filtersForm[filterKey] = filterValues.filter((name) => uniqueCategories.has(name)).concat(hasEmptyCategoryInFilter ? [CONST.SEARCH.CATEGORY_EMPTY_VALUE] : []); } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD) { From 94f958b97e8ea077d9aa2890ce080d95146afa4a Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Thu, 29 May 2025 00:40:40 +0500 Subject: [PATCH 5/8] check we have both emptyCategories in filterValues --- src/libs/SearchQueryUtils.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index ab04f774376d..03bfde088922 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -508,12 +508,11 @@ function buildFilterFormValuesFromQuery( .map((item) => Object.values(item ?? {}).map((category) => category.name)) .flat(); const uniqueCategories = new Set(categories); - uniqueCategories.add(CONST.SEARCH.CATEGORY_EMPTY_VALUE); const emptyCategories = CONST.SEARCH.CATEGORY_EMPTY_VALUE.split(','); - const hasEmptyCategoryInFilter = filterValues.some((category) => emptyCategories.includes(category)); - // We split CATEGORY_EMPTY_VALUE into individual values to detect if any are present in filterValues. - // If empty category is found, append the full CATEGORY_EMPTY_VALUE to filtersForm. - filtersForm[filterKey] = filterValues.filter((name) => uniqueCategories.has(name)).concat(hasEmptyCategoryInFilter ? [CONST.SEARCH.CATEGORY_EMPTY_VALUE] : []); + 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 From bf73b1e67bfe3ebe4b96530936a817963fcdcdfe Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Thu, 29 May 2025 19:16:48 +0500 Subject: [PATCH 6/8] Add unit test for buildFilterFormValuesFromQuery and test we get CATEGORY_EMPTY_VALUE in filterForm --- tests/unit/Search/SearchQueryUtilsTest.ts | 76 ++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index d8d0598edbb2..1fb4137d5240 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ // we need "dirty" object key names in these tests +import {SearchQueryJSON} from '@components/Search/types'; import CONST from '@src/CONST'; -import {buildQueryStringFromFilterFormValues, getQueryWithUpdatedValues, shouldHighlight} from '@src/libs/SearchQueryUtils'; +import {buildFilterFormValuesFromQuery, buildQueryStringFromFilterFormValues, getQueryWithUpdatedValues, shouldHighlight} from '@src/libs/SearchQueryUtils'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; const personalDetailsFakeData = { @@ -121,6 +122,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 = {}; @@ -160,6 +173,67 @@ describe('SearchQueryUtils', () => { }); }); + describe('buildFilterFormValuesFromQuery', () => { + test('category filter includes empty values', () => { + const queryJSON: SearchQueryJSON = { + filters: { + left: 'category', + operator: 'eq', + right: ['none', 'Uncategorized', 'Maintenance'], + }, + flatFilters: [ + { + filters: [ + {operator: 'eq', value: 'none'}, + {operator: 'eq', value: 'Uncategorized'}, + {operator: 'eq', value: 'Maintenance'}, + ], + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, + }, + ], + hash: 123456789, + inputQuery: 'sortBy:date sortOrder:desc type:expense status:all category:none,Uncategorized,Maintenance', + recentSearchHash: 987654321, + sortBy: 'date', + sortOrder: 'desc', + status: 'all', + type: 'expense', + }; + + const policyCategories = { + ['policyCategories_testPolicy']: { + 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 = {}; + + 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); From 753b3776911a245256bf6d5626660dba3a219aab Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Thu, 29 May 2025 20:00:43 +0500 Subject: [PATCH 7/8] fix lint error --- tests/unit/Search/SearchQueryUtilsTest.ts | 39 +++++++---------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 1fb4137d5240..76c4659b4493 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/naming-convention */ // we need "dirty" object key names in these tests -import {SearchQueryJSON} from '@components/Search/types'; +import {generatePolicyID} from '@libs/actions/Policy/Policy'; import CONST from '@src/CONST'; -import {buildFilterFormValuesFromQuery, 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 = { @@ -175,33 +176,12 @@ describe('SearchQueryUtils', () => { describe('buildFilterFormValuesFromQuery', () => { test('category filter includes empty values', () => { - const queryJSON: SearchQueryJSON = { - filters: { - left: 'category', - operator: 'eq', - right: ['none', 'Uncategorized', 'Maintenance'], - }, - flatFilters: [ - { - filters: [ - {operator: 'eq', value: 'none'}, - {operator: 'eq', value: 'Uncategorized'}, - {operator: 'eq', value: 'Maintenance'}, - ], - key: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, - }, - ], - hash: 123456789, - inputQuery: 'sortBy:date sortOrder:desc type:expense status:all category:none,Uncategorized,Maintenance', - recentSearchHash: 987654321, - sortBy: 'date', - sortOrder: 'desc', - status: 'all', - type: 'expense', - }; + const policyID = generatePolicyID(); + const queryString = 'sortBy:date sortOrder:desc type:expense status:all category:none,Uncategorized,Maintenance'; + const queryJSON = buildSearchQueryJSON(queryString); const policyCategories = { - ['policyCategories_testPolicy']: { + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]: { Maintenance: { enabled: true, name: 'Maintenance', @@ -216,7 +196,6 @@ describe('SearchQueryUtils', () => { }, }, }; - const policyTags = {}; const currencyList = {}; const personalDetails = {}; @@ -224,6 +203,10 @@ describe('SearchQueryUtils', () => { 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({ From 7d01d68e8008a7bb62dd0450794d19cd9af86ca7 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Mon, 2 Jun 2025 19:16:25 +0500 Subject: [PATCH 8/8] refactor: centralize sortOptionsWithEmptyValue util for reuse --- .../Search/SearchMultipleSelectionPicker.tsx | 18 +++--------------- src/libs/SearchQueryUtils.ts | 14 ++++++++++++++ src/pages/Search/AdvancedSearchFilters.tsx | 13 +------------ 3 files changed, 18 insertions(+), 27 deletions(-) diff --git a/src/components/Search/SearchMultipleSelectionPicker.tsx b/src/components/Search/SearchMultipleSelectionPicker.tsx index abaea0ab015f..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.CATEGORY_EMPTY_VALUE || a.value === CONST.SEARCH.TAG_EMPTY_VALUE) { - return -1; - } - if (b.value === CONST.SEARCH.CATEGORY_EMPTY_VALUE || b.value === CONST.SEARCH.TAG_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 4af90d8a03e7..dd61b368d48f 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -742,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. */ @@ -874,5 +887,6 @@ export { getQueryWithoutFilters, getUserFriendlyKey, isDefaultExpensesQuery, + sortOptionsWithEmptyValue, shouldHighlight, }; diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index afdc6c79314f..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.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); -}; - 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