Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
19 changes: 18 additions & 1 deletion src/libs/SearchQueryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,9 @@ function buildQueryStringFromFilterFormValues(filterValues: Partial<SearchAdvanc
const keyInCorrectForm = (Object.keys(CONST.SEARCH.SYNTAX_FILTER_KEYS) as FilterKeys[]).find((key) => 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(',')}`;
}
}
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 33 additions & 14 deletions src/libs/SearchUIUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<QueryFilters>((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 {
Comment thread
KJ21-ENG marked this conversation as resolved.
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<SearchQueryJSON> | undefined): number[] {
if (!data || !queryJSON?.groupBy) {
return [];
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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] = {
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/Search/SearchQueryUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchAdvancedFiltersForm> = {
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<SearchAdvancedFiltersForm> = {
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<SearchAdvancedFiltersForm> = {};

Expand Down Expand Up @@ -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: {},
Expand Down
45 changes: 40 additions & 5 deletions tests/unit/Search/SearchUIUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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', () => {
Expand Down
Loading