diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 411ce7eb0c68c..1b4c78a04dfa0 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7149,6 +7149,8 @@ const CONST = { VIEW: { TABLE: 'table', BAR: 'bar', + LINE: 'line', + PIE: 'pie', }, SYNTAX_FILTER_KEYS: { TYPE: 'type', @@ -7219,6 +7221,7 @@ const CONST = { SORT_ORDER: 'sort-order', POLICY_ID: 'workspace', GROUP_BY: 'group-by', + VIEW: 'view', DATE: 'date', AMOUNT: 'amount', TOTAL: 'total', diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index d6c4f88b1871d..42a7aee121cef 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -243,6 +243,10 @@ function SearchAutocompleteList({ } }, [currentType]); + const viewAutocompleteList = useMemo(() => { + return Object.values(CONST.SEARCH.VIEW).map((value) => getUserFriendlyValue(value)); + }, []); + const statusAutocompleteList = useMemo(() => { let suggestedStatuses; switch (currentType) { @@ -495,6 +499,12 @@ function SearchAutocompleteList({ ); return filteredGroupBy.map((groupByValue) => ({filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.GROUP_BY, text: groupByValue})); } + case CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW: { + const filteredViews = viewAutocompleteList.filter( + (viewValue) => viewValue.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.has(viewValue.toLowerCase()), + ); + return filteredViews.map((viewValue) => ({filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.VIEW, text: viewValue})); + } case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: { const filteredStatuses = statusAutocompleteList .filter((status) => status.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.has(status)) @@ -639,6 +649,7 @@ function SearchAutocompleteList({ currentUserAccountID, currentUserEmail, groupByAutocompleteList, + viewAutocompleteList, statusAutocompleteList, feedAutoCompleteList, cardAutocompleteList, diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 0019a764dc943..0c938c94454c6 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -224,7 +224,8 @@ type SearchFilterKey = | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.COLUMNS - | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT; + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW; type UserFriendlyKey = ValueOf; type UserFriendlyValue = ValueOf; diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index 547b56418d032..e6e0aa7387ed6 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -127,6 +127,7 @@ function getAutocompleteQueryWithComma(prevQuery: string, newQuery: string) { const userFriendlyExpenseTypeList = Object.values(CONST.SEARCH.TRANSACTION_TYPE).map((value) => getUserFriendlyValue(value)); const userFriendlyGroupByList = Object.values(CONST.SEARCH.GROUP_BY).map((value) => getUserFriendlyValue(value)); +const userFriendlyViewList = Object.values(CONST.SEARCH.VIEW).map((value) => getUserFriendlyValue(value)); const userFriendlyStatusList = Object.values({ ...CONST.SEARCH.STATUS.EXPENSE, ...CONST.SEARCH.STATUS.INVOICE, @@ -160,6 +161,7 @@ function filterOutRangesWithCorrectValue( const withdrawalTypeList = Object.values(CONST.SEARCH.WITHDRAWAL_TYPE) as string[]; const statusList = userFriendlyStatusList; const groupByList = userFriendlyGroupByList; + const viewList = userFriendlyViewList; const booleanList = Object.values(CONST.SEARCH.BOOLEAN) as string[]; const actionList = Object.values(CONST.SEARCH.ACTION_FILTERS) as string[]; const datePresetList = Object.values(CONST.SEARCH.DATE_PRESETS) as string[]; @@ -208,6 +210,8 @@ function filterOutRangesWithCorrectValue( return false; } return groupByList.includes(range.value); + case CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW: + return viewList.includes(range.value); case CONST.SEARCH.SYNTAX_FILTER_KEYS.BILLABLE: case CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE: return booleanList.includes(range.value); diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index 6155382cf255d..cae2ed04fa592 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -1031,53 +1031,56 @@ function peg$parse(input, options) { if (s1 === peg$FAILED) { s1 = peg$parsegroupBy(); if (s1 === peg$FAILED) { - s1 = peg$parsereimbursable(); + s1 = peg$parseview(); if (s1 === peg$FAILED) { - s1 = peg$parsebillable(); + s1 = peg$parsereimbursable(); if (s1 === peg$FAILED) { - s1 = peg$parsepolicyID(); + s1 = peg$parsebillable(); if (s1 === peg$FAILED) { - s1 = peg$parseaction(); + s1 = peg$parsepolicyID(); if (s1 === peg$FAILED) { - s1 = peg$parsedate(); + s1 = peg$parseaction(); if (s1 === peg$FAILED) { - s1 = peg$parsesubmitted(); + s1 = peg$parsedate(); if (s1 === peg$FAILED) { - s1 = peg$parseapproved(); + s1 = peg$parsesubmitted(); if (s1 === peg$FAILED) { - s1 = peg$parsepaid(); + s1 = peg$parseapproved(); if (s1 === peg$FAILED) { - s1 = peg$parseexported(); + s1 = peg$parsepaid(); if (s1 === peg$FAILED) { - s1 = peg$parsewithdrawn(); + s1 = peg$parseexported(); if (s1 === peg$FAILED) { - s1 = peg$parseposted(); + s1 = peg$parsewithdrawn(); if (s1 === peg$FAILED) { - s1 = peg$parsehas(); + s1 = peg$parseposted(); if (s1 === peg$FAILED) { - s1 = peg$parseis(); + s1 = peg$parsehas(); if (s1 === peg$FAILED) { - s1 = peg$parsepurchaseCurrency(); + s1 = peg$parseis(); if (s1 === peg$FAILED) { - s1 = peg$parsepurchaseAmount(); + s1 = peg$parsepurchaseCurrency(); if (s1 === peg$FAILED) { - s1 = peg$parseamount(); + s1 = peg$parsepurchaseAmount(); if (s1 === peg$FAILED) { - s1 = peg$parsemerchant(); + s1 = peg$parseamount(); if (s1 === peg$FAILED) { - s1 = peg$parsedescription(); + s1 = peg$parsemerchant(); if (s1 === peg$FAILED) { - s1 = peg$parsereportID(); + s1 = peg$parsedescription(); if (s1 === peg$FAILED) { - s1 = peg$parsewithdrawalID(); + s1 = peg$parsereportID(); if (s1 === peg$FAILED) { - s1 = peg$parsetitle(); + s1 = peg$parsewithdrawalID(); if (s1 === peg$FAILED) { - s1 = peg$parsereportFieldDynamic(); + s1 = peg$parsetitle(); if (s1 === peg$FAILED) { - s1 = peg$parsecolumns(); + s1 = peg$parsereportFieldDynamic(); if (s1 === peg$FAILED) { - s1 = peg$parselimit(); + s1 = peg$parsecolumns(); + if (s1 === peg$FAILED) { + s1 = peg$parselimit(); + } } } } diff --git a/src/libs/SearchParser/autocompleteParser.peggy b/src/libs/SearchParser/autocompleteParser.peggy index a50efdbcdbdd1..2299252b119d2 100644 --- a/src/libs/SearchParser/autocompleteParser.peggy +++ b/src/libs/SearchParser/autocompleteParser.peggy @@ -96,6 +96,7 @@ autocompleteKey "key" / cardID / feed / groupBy + / view / reimbursable / billable / policyID diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 0392115d6aadf..2c1e00911ddd7 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -473,8 +473,9 @@ function buildSearchQueryString(queryJSON?: SearchQueryJSON) { const queryParts: string[] = []; const defaultQueryJSON = buildSearchQueryJSON(''); - // Check if view was explicitly set by the user (exists in rawFilterList) - const wasViewExplicitlySet = queryJSON?.rawFilterList?.some((filter) => filter.key === CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW); + // Check if view was explicitly set by the user (exists in rawFilterList or differs from default) + const wasViewExplicitlySet = + (queryJSON?.rawFilterList?.some((filter) => filter.key === CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW) ?? false) || (queryJSON?.view && queryJSON.view !== defaultQueryJSON?.view); for (const [, key] of Object.entries(CONST.SEARCH.SYNTAX_ROOT_KEYS)) { // Skip view if it wasn't explicitly set by the user diff --git a/src/types/form/SearchAdvancedFiltersForm.ts b/src/types/form/SearchAdvancedFiltersForm.ts index 09489573ea3dc..09ccb3f64b511 100644 --- a/src/types/form/SearchAdvancedFiltersForm.ts +++ b/src/types/form/SearchAdvancedFiltersForm.ts @@ -168,6 +168,7 @@ const FILTER_KEYS = { COLUMNS: 'columns', LIMIT: 'limit', + VIEW: 'view', } as const; const ALLOWED_TYPE_FILTERS = { @@ -535,6 +536,7 @@ type SearchAdvancedFiltersForm = Form< [FILTER_KEYS.GROUP_BY]: SearchGroupBy; [FILTER_KEYS.TYPE]: SearchDataTypes; [FILTER_KEYS.COLUMNS]: SearchCustomColumnIds[]; + [FILTER_KEYS.VIEW]: ValueOf; [FILTER_KEYS.STATUS]: string[] | string; diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 67aff2e48b3be..c3ccf1c4d2c80 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -9,6 +9,7 @@ import { buildFilterFormValuesFromQuery, buildQueryStringFromFilterFormValues, buildSearchQueryJSON, + buildSearchQueryString, buildUserReadableQueryString, getFilterDisplayValue, getQueryWithUpdatedValues, @@ -94,6 +95,30 @@ describe('SearchQueryUtils', () => { expect(result).toEqual(`${defaultQuery} groupBy:reports from:12345`); }); + test('returns query with updated view', () => { + const userQuery = 'from:johndoe@example.com view:bar'; + + const result = getQueryWithUpdatedValues(userQuery); + + expect(result).toEqual(`${defaultQuery} view:bar from:12345`); + }); + + test('returns query with view:line', () => { + const userQuery = 'type:expense view:line category:travel'; + + const result = getQueryWithUpdatedValues(userQuery); + + expect(result).toEqual(`${defaultQuery} view:line category:travel`); + }); + + test('returns query with view:pie', () => { + const userQuery = 'type:expense view:pie merchant:Amazon'; + + const result = getQueryWithUpdatedValues(userQuery); + + expect(result).toEqual(`${defaultQuery} view:pie merchant:Amazon`); + }); + test('deduplicates conflicting type filters keeping the last occurrence', () => { const userQuery = 'type:expense-report action:submit from:me type:expense'; @@ -925,4 +950,64 @@ describe('SearchQueryUtils', () => { } }); }); + + describe('buildSearchQueryString', () => { + test('includes view when explicitly set in rawFilterList', () => { + const queryJSON = buildSearchQueryJSON('type:expense view:line', 'type:expense view:line'); + + const result = buildSearchQueryString(queryJSON); + + expect(result).toContain('view:line'); + }); + + test('includes view when differs from default even without rawFilterList', () => { + const queryJSON = buildSearchQueryJSON('type:expense view:pie'); + + const result = buildSearchQueryString(queryJSON); + + expect(result).toContain('view:pie'); + }); + + test('includes view when set to bar', () => { + const queryJSON = buildSearchQueryJSON('type:expense view:bar'); + + const result = buildSearchQueryString(queryJSON); + + expect(result).toContain('view:bar'); + }); + + test('skips view when not explicitly set and matches default', () => { + const queryJSON = buildSearchQueryJSON('type:expense'); + + const result = buildSearchQueryString(queryJSON); + + expect(result).not.toContain('view:table'); + }); + + test('includes view when explicitly set to table in rawFilterList', () => { + const queryJSON = buildSearchQueryJSON('type:expense view:table', 'type:expense view:table'); + + const result = buildSearchQueryString(queryJSON); + + expect(result).toContain('view:table'); + }); + + test('preserves view along with other filters', () => { + const queryJSON = buildSearchQueryJSON('type:expense view:line category:travel'); + + const result = buildSearchQueryString(queryJSON); + + expect(result).toContain('view:line'); + expect(result).toContain('category:travel'); + }); + + test('handles view with rawFilterList containing other filters', () => { + const queryJSON = buildSearchQueryJSON('type:expense view:pie merchant:Amazon', 'type:expense view:pie merchant:Amazon'); + + const result = buildSearchQueryString(queryJSON); + + expect(result).toContain('view:pie'); + expect(result).toContain('merchant:Amazon'); + }); + }); }); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 10403c1b1c8b0..966060607bd09 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -29,6 +29,7 @@ import {setOptimisticDataForTransactionThreadPreview} from '@userActions/Search' import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import type {CardFeedForDisplay} from '@src/libs/CardFeedUtils'; +import {getUserFriendlyValue} from '@src/libs/SearchQueryUtils'; import * as SearchUIUtils from '@src/libs/SearchUIUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -5231,4 +5232,24 @@ describe('SearchUIUtils', () => { expect(result).toBeDefined(); }); }); + + describe('view autocomplete values', () => { + test('should include all view values (table, bar, line, pie)', () => { + const viewValues = Object.values(CONST.SEARCH.VIEW); + expect(viewValues).toContain('table'); + expect(viewValues).toContain('bar'); + expect(viewValues).toContain('line'); + expect(viewValues).toContain('pie'); + expect(viewValues).toHaveLength(4); + }); + + test('should correctly map view values to user-friendly values', () => { + const viewValues = Object.values(CONST.SEARCH.VIEW); + const userFriendlyValues = viewValues.map((value) => getUserFriendlyValue(value)); + + // All view values should be mapped (they may be the same or different) + expect(userFriendlyValues).toHaveLength(4); + expect(userFriendlyValues.every((value) => typeof value === 'string')).toBe(true); + }); + }); }); diff --git a/tests/unit/SearchAutocompleteUtilsTest.ts b/tests/unit/SearchAutocompleteUtilsTest.ts index 7b92ad879ee24..73ed309c63f71 100644 --- a/tests/unit/SearchAutocompleteUtilsTest.ts +++ b/tests/unit/SearchAutocompleteUtilsTest.ts @@ -280,5 +280,79 @@ describe('SearchAutocompleteUtils', () => { // Total amounts with more than 8 digits fail validation expect(result).toEqual([]); }); + + describe('view filter highlighting', () => { + it('highlights valid view values', () => { + const validViews = ['table', 'bar', 'line', 'pie']; + + for (const view of validViews) { + const input = `view:${view}`; + + const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList); + + expect(result).toEqual([{start: 5, type: 'mention-user', length: view.length}]); + } + }); + + it('does not highlight invalid view values', () => { + const input = 'view:invalid'; + + const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList); + + expect(result).toEqual([]); + }); + + it('highlights view in complex query with other filters', () => { + const input = 'type:expense view:line category:Travel'; + + const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList); + + expect(result).toEqual([ + {start: 5, type: 'mention-user', length: 7}, // type:expense + {start: 18, type: 'mention-user', length: 4}, // view:line + {start: 32, type: 'mention-user', length: 6}, // category:Travel + ]); + }); + + it('does not highlight empty view value', () => { + const input = 'view:'; + + const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList); + + expect(result).toEqual([]); + }); + + it('highlights view:table in query', () => { + const input = 'view:table'; + + const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList); + + expect(result).toEqual([{start: 5, type: 'mention-user', length: 5}]); + }); + + it('highlights view:bar in query', () => { + const input = 'view:bar'; + + const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList); + + expect(result).toEqual([{start: 5, type: 'mention-user', length: 3}]); + }); + + it('highlights view:line in query', () => { + const input = 'view:line'; + + const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList); + + expect(result).toEqual([{start: 5, type: 'mention-user', length: 4}]); + }); + + it('highlights view:pie in query', () => { + const input = 'view:pie'; + + const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList); + + expect(result).toEqual([{start: 5, type: 'mention-user', length: 3}]); + }); + }); }); }); diff --git a/tests/unit/SearchParserTest.ts b/tests/unit/SearchParserTest.ts index 31c8f96bf551d..8aa73a26fe5e0 100644 --- a/tests/unit/SearchParserTest.ts +++ b/tests/unit/SearchParserTest.ts @@ -919,6 +919,36 @@ const keywordTests = [ }, }, }, + { + query: 'type:expense view:line category:travel', + expected: { + type: 'expense', + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: 'date', + sortOrder: 'desc', + view: 'line', + filters: { + operator: 'eq', + left: 'category', + right: 'travel', + }, + }, + }, + { + query: 'type:expense view:pie category:travel', + expected: { + type: 'expense', + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: 'date', + sortOrder: 'desc', + view: 'pie', + filters: { + operator: 'eq', + left: 'category', + right: 'travel', + }, + }, + }, ]; const limitTests = [