diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 78b741d7aec0..ee6db3ac4918 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8243,6 +8243,7 @@ const CONST = { RANGE_PREFIX: 'reportFieldRange-', }, TAG_EMPTY_VALUE: 'none', + TAG_UNTAGGED_VALUE: '(untagged)', CATEGORY_EMPTY_VALUE: 'none', CATEGORY_DEFAULT_VALUE: 'Uncategorized', MERCHANT_EMPTY_VALUE: 'none', diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 302bfe97ead2..09a3a54f1c95 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -955,6 +955,9 @@ function buildQueryStringFromFilterFormValues(filterValues: Partial CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); if (keyInCorrectForm) { + if (!isNegated && filterKey === FILTER_KEYS.TAG && filterValueArray.length === 1 && filterValueArray.at(0) === CONST.SEARCH.TAG_EMPTY_VALUE) { + return `-${CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS}:${CONST.SEARCH.HAS_VALUES.TAG}`; + } return `${prefix}${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}:${filterValueArray.map(sanitizeSearchValue).join(',')}`; } } @@ -1098,7 +1101,21 @@ function buildFilterFormValuesFromQuery( filtersForm[key as typeof filterKey] = filterValues.filter((expenseType) => VALID_EXPENSE_TYPES.has(expenseType as ExpenseTypeValue)) as ExpenseTypeValues; } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS) { - filtersForm[key as typeof filterKey] = filterValues.filter((hasType) => VALID_HAS_TYPES.has(hasType as HasFilterValue)) as HasFilterValues; + const validHasFilters = filterList.filter((item) => VALID_HAS_TYPES.has(item.value as HasFilterValue)); + const positiveHasFilters = validHasFilters.filter((item) => item.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO).map((item) => item.value.toString()) as HasFilterValues; + const negatedHasFilters = validHasFilters.filter((item) => item.operator === CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO).map((item) => item.value.toString()) as HasFilterValues; + const hasNegatedTagFilter = negatedHasFilters.includes(CONST.SEARCH.HAS_VALUES.TAG); + const remainingNegatedHasFilters = negatedHasFilters.filter((hasType) => hasType !== CONST.SEARCH.HAS_VALUES.TAG); + + if (hasNegatedTagFilter) { + filtersForm[FILTER_KEYS.TAG] = [CONST.SEARCH.TAG_EMPTY_VALUE]; + } + if (positiveHasFilters.length > 0) { + filtersForm[FILTER_KEYS.HAS] = positiveHasFilters; + } + if (remainingNegatedHasFilters.length > 0) { + filtersForm[FILTER_KEYS.HAS_NOT] = remainingNegatedHasFilters; + } } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.IS) { filtersForm[key as typeof filterKey] = filterValues.filter((isType) => VALID_IS_TYPES.has(isType as IsFilterValue)) as IsFilterValues; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 513374624ea5..1d2cd198f182 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2909,6 +2909,36 @@ function buildSpecificGroupQuery(queryJSON: SearchQueryJSON, filterKey: SearchFi return buildSearchQueryJSON(buildSearchQueryString(newQueryJSON)); } +function buildEmptyTagGroupQuery(queryJSON: SearchQueryJSON): SearchQueryJSON | undefined { + const newFlatFilters = queryJSON.flatFilters.reduce((filters, filter) => { + if (filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG) { + return filters; + } + + if (filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS) { + const remainingHasFilters = filter.filters.filter((item) => item.value !== CONST.SEARCH.HAS_VALUES.TAG); + if (remainingHasFilters.length > 0) { + filters.push({...filter, filters: remainingHasFilters}); + } + return filters; + } + + filters.push(filter); + return filters; + }, []); + newFlatFilters.push({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO, value: CONST.SEARCH.HAS_VALUES.TAG}]}); + const newQueryJSON: SearchQueryJSON = {...queryJSON, groupBy: undefined, flatFilters: newFlatFilters}; + return buildSearchQueryJSON(buildSearchQueryString(newQueryJSON)); +} + +function buildTagGroupQuery(queryJSON: SearchQueryJSON, tag: string): SearchQueryJSON | undefined { + if (!tag || tag === CONST.SEARCH.TAG_EMPTY_VALUE || tag === CONST.SEARCH.TAG_UNTAGGED_VALUE) { + return buildEmptyTagGroupQuery(queryJSON); + } + + return buildSpecificGroupQuery(queryJSON, CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, tag); +} + function getActiveGroupSearchHashes(data: OnyxTypes.SearchResults['data'] | undefined, queryJSON: Readonly | undefined): number[] { if (!data || !queryJSON?.groupBy) { return []; @@ -2970,11 +3000,7 @@ function getActiveGroupSearchHashes(data: OnyxTypes.SearchResults['data'] | unde case CONST.SEARCH.GROUP_BY.TAG: { const tagGroup = group as SearchTagGroup; if (tagGroup.tag !== undefined) { - transactionsQueryJSON = buildSpecificGroupQuery( - queryJSON, - CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, - tagGroup.tag === '' || tagGroup.tag === '(untagged)' ? CONST.SEARCH.TAG_EMPTY_VALUE : tagGroup.tag, - ); + transactionsQueryJSON = buildTagGroupQuery(queryJSON, tagGroup.tag); } break; } @@ -3274,18 +3300,11 @@ function getTagSections(data: OnyxTypes.SearchResults['data'], queryJSON: Search if (isGroupEntry(key)) { const tagGroup = data[key] as SearchTagGroup; - const transactionsQueryJSON = - queryJSON && tagGroup.tag !== undefined - ? buildSpecificGroupQuery( - queryJSON, - CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, - tagGroup.tag === '' || tagGroup.tag === '(untagged)' ? CONST.SEARCH.TAG_EMPTY_VALUE : tagGroup.tag, - ) - : undefined; + const transactionsQueryJSON = queryJSON && tagGroup.tag !== undefined ? buildTagGroupQuery(queryJSON, tagGroup.tag) : undefined; // Format the tag name - use translated "No tag" for empty values so it sorts alphabetically const rawTag = tagGroup.tag; - const isEmptyTag = !rawTag || rawTag === CONST.SEARCH.TAG_EMPTY_VALUE || rawTag === '(untagged)'; + const isEmptyTag = !rawTag || rawTag === CONST.SEARCH.TAG_EMPTY_VALUE || rawTag === CONST.SEARCH.TAG_UNTAGGED_VALUE; const formattedTag = isEmptyTag ? translate('search.noTag') : getCommaSeparatedTagNameWithSanitizedColons(rawTag); tagSections[key] = { diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 958973f3395e..552e9643296b 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -254,6 +254,31 @@ describe('SearchQueryUtils', () => { expect(result).toEqual('type:expense category:equipment,consulting,none,Uncategorized'); }); + test('serializes No Tag filter as missing tag query', () => { + const filterValues: Partial = { + type: 'expense', + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + tag: [CONST.SEARCH.TAG_EMPTY_VALUE], + }; + + const result = buildQueryStringFromFilterFormValues(filterValues); + + expect(result).toEqual('type:expense -has:tag'); + expect(result).not.toContain('tag:none'); + }); + + test('serializes real tag values as tag filters', () => { + const filterValues: Partial = { + type: 'expense', + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + tag: ['Engineering'], + }; + + const result = buildQueryStringFromFilterFormValues(filterValues); + + expect(result).toEqual('type:expense tag:Engineering'); + }); + test('empty filter values', () => { const filterValues: Partial = {}; @@ -1197,6 +1222,39 @@ describe('SearchQueryUtils', () => { expect(result['reportFieldRange-start-date']).toBeUndefined(); }); + test('hydrates missing tag query as No Tag filter', () => { + const queryJSON = buildSearchQueryJSON('type:expense -has:tag'); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {}); + + expect(result).toEqual({ + type: 'expense', + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + tag: [CONST.SEARCH.TAG_EMPTY_VALUE], + }); + }); + + test('hydrates missing tag query while preserving other has filters', () => { + const queryJSON = buildSearchQueryJSON('type:expense has:receipt -has:tag'); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {}); + + expect(result).toEqual({ + type: 'expense', + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + has: [CONST.SEARCH.HAS_VALUES.RECEIPT], + tag: [CONST.SEARCH.TAG_EMPTY_VALUE], + }); + }); + describe('view parameter', () => { const emptyParams = { policyCategories: {}, diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index cd3fb1c77daa..f89d8c0e8213 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -23,7 +23,7 @@ import type { TransactionWithdrawalIDGroupListItemType, TransactionYearGroupListItemType, } from '@components/Search/SearchList/ListItem/types'; -import type {SelectedTransactionInfo} from '@components/Search/types'; +import type {SearchQueryJSON, SelectedTransactionInfo} from '@components/Search/types'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@navigation/Navigation'; import type * as ReportUserActions from '@userActions/Report'; @@ -35,7 +35,7 @@ import IntlStore from '@src/languages/IntlStore'; import type {CardFeedForDisplay} from '@src/libs/CardFeedUtils'; import {getCardDescriptionForSearchTable} from '@src/libs/CardUtils'; import DateUtils from '@src/libs/DateUtils'; -import {getDateRangeForPreset, getUserFriendlyValue} from '@src/libs/SearchQueryUtils'; +import {buildSearchQueryJSON, getDateRangeForPreset, getUserFriendlyValue} from '@src/libs/SearchQueryUtils'; import * as SearchUIUtils from '@src/libs/SearchUIUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -4676,11 +4676,11 @@ describe('SearchUIUtils', () => { expect(result.some((item) => item.tag === CONST.SEARCH.TAG_EMPTY_VALUE)).toBe(true); }); - it('should handle "(untagged)" value from backend', () => { + it('should handle backend untagged value', () => { const dataWithUntagged: OnyxTypes.SearchResults['data'] = { personalDetailsList: {}, [`${CONST.SEARCH.GROUP_PREFIX}untagged` as const]: { - tag: '(untagged)', + tag: CONST.SEARCH.TAG_UNTAGGED_VALUE, count: 3, currency: 'USD', total: 100, @@ -4702,7 +4702,42 @@ describe('SearchUIUtils', () => { }) as [TransactionTagGroupListItemType[], number, boolean]; expect(result).toHaveLength(1); - expect(result.at(0)?.tag).toBe('(untagged)'); + expect(result.at(0)?.tag).toBe(CONST.SEARCH.TAG_UNTAGGED_VALUE); + }); + + it('should build missing tag query for empty tag group drill-down', () => { + const queryJSON = buildSearchQueryJSON('type:expense has:receipt groupBy:tag'); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + const dataWithEmptyTag: OnyxTypes.SearchResults['data'] = { + personalDetailsList: {}, + [`${CONST.SEARCH.GROUP_PREFIX}empty` as const]: { + tag: '', + count: 2, + currency: 'USD', + total: 50, + }, + }; + + const [result] = SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: dataWithEmptyTag, + currentAccountID: 2074551, + currentUserEmail: '', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + groupBy: CONST.SEARCH.GROUP_BY.TAG, + allReportMetadata: {}, + conciergeReportID: undefined, + queryJSON: queryJSON as SearchQueryJSON, + convertToDisplayString, + }) as [TransactionTagGroupListItemType[], number, boolean]; + + expect(result.at(0)?.transactionsQueryJSON?.inputQuery).toBe('type:expense sortBy:groupTag sortOrder:asc has:receipt -has:tag'); }); it('should return isTransactionTagGroupListItemType true for tag group items', () => {