diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index e504a7b08b6d6..9fc9e0665d8c5 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -38,6 +38,7 @@ import {getEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {SearchQueryItem, SearchQueryListItemProps} from './SearchList/ListItem/SearchQueryListItem'; import SearchQueryListItem, {isSearchQueryItem} from './SearchList/ListItem/SearchQueryListItem'; +import type {SubstitutionMap} from './SearchRouter/getQueryWithSubstitutions'; import {getSubstitutionMapKey} from './SearchRouter/getQueryWithSubstitutions'; import type {UserFriendlyKey} from './types'; @@ -82,6 +83,9 @@ type SearchAutocompleteListProps = { /** All cards */ allCards: CardList | undefined; + /** Map of display values to actual IDs for filters (e.g. workspace name -> policy ID). Used to exclude by ID when multiple options share the same name. */ + autocompleteSubstitutions?: SubstitutionMap; + /** Reference to the outer element */ ref?: ForwardedRef; }; @@ -143,6 +147,7 @@ function SearchAutocompleteList({ reports, allFeeds, allCards = CONST.EMPTY_OBJECT, + autocompleteSubstitutions, ref, }: SearchAutocompleteListProps) { const styles = useThemeStyles(); @@ -290,6 +295,7 @@ function SearchAutocompleteList({ personalDetails, feedKeysWithCards, translate, + autocompleteSubstitutions, }); const autocompleteQueryWithoutFilters = getQueryWithoutFilters(autocompleteQueryValue); diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx index ab754329e7cec..f61320ab8ceae 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx @@ -14,6 +14,7 @@ import SearchInputSelectionWrapper from '@components/Search/SearchInputSelection import type {SearchQueryItem} from '@components/Search/SearchList/ListItem/SearchQueryListItem'; import {isSearchQueryItem} from '@components/Search/SearchList/ListItem/SearchQueryListItem'; import {buildSubstitutionsMap} from '@components/Search/SearchRouter/buildSubstitutionsMap'; +import getAutocompleteSelectionSubstitutionKey from '@components/Search/SearchRouter/getAutocompleteSelectionSubstitutionKey'; import type {SubstitutionMap} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; import {getQueryWithSubstitutions} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; import {getUpdatedSubstitutionsMap} from '@components/Search/SearchRouter/getUpdatedSubstitutionsMap'; @@ -229,8 +230,9 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo onSearchQueryChange(newSearchQuery); setSelection({start: newSearchQuery.length, end: newSearchQuery.length}); - if (item.mapKey && item.autocompleteID) { - const substitutions = {...autocompleteSubstitutions, [item.mapKey]: item.autocompleteID}; + if (item.mapKey && item.autocompleteID && fieldKey) { + const substitutionKey = getAutocompleteSelectionSubstitutionKey(newSearchQuery, fieldKey, item.mapKey, item.searchQuery); + const substitutions = {...autocompleteSubstitutions, [substitutionKey]: item.autocompleteID}; setAutocompleteSubstitutions(substitutions); } } else if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH) { @@ -301,6 +303,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo allCards={personalAndWorkspaceCards} allFeeds={allFeeds} textInputRef={textInputRef} + autocompleteSubstitutions={autocompleteSubstitutions} /> )} @@ -373,6 +376,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo allCards={personalAndWorkspaceCards} allFeeds={allFeeds} textInputRef={textInputRef} + autocompleteSubstitutions={autocompleteSubstitutions} /> )} diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index a3d8f2e2d08ff..1e4c41fc8f740 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -44,6 +44,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type Report from '@src/types/onyx/Report'; +import getAutocompleteSelectionSubstitutionKey from './getAutocompleteSelectionSubstitutionKey'; import type {SubstitutionMap} from './getQueryWithSubstitutions'; import {getQueryWithSubstitutions} from './getQueryWithSubstitutions'; import {getUpdatedSubstitutionsMap} from './getUpdatedSubstitutionsMap'; @@ -278,8 +279,9 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla onSearchQueryChange(newSearchQuery, true); setSelection({start: newSearchQuery.length, end: newSearchQuery.length}); - if (item.mapKey && item.autocompleteID) { - const substitutions = {...autocompleteSubstitutions, [item.mapKey]: item.autocompleteID}; + if (item.mapKey && item.autocompleteID && fieldKey) { + const substitutionKey = getAutocompleteSelectionSubstitutionKey(newSearchQuery, fieldKey, item.mapKey, item.searchQuery); + const substitutions = {...autocompleteSubstitutions, [substitutionKey]: item.autocompleteID}; setAutocompleteSubstitutions(substitutions); } setFocusAndScrollToRight(); @@ -359,6 +361,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla allFeeds={allFeeds} allCards={personalAndWorkspaceCards} textInputRef={textInputRef} + autocompleteSubstitutions={autocompleteSubstitutions} /> ); diff --git a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts index 47a72df7c347a..995c6049e892c 100644 --- a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts +++ b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts @@ -1,13 +1,12 @@ import type {OnyxCollection} from 'react-native-onyx'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; -import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types'; +import type {SearchAutocompleteQueryRange} from '@components/Search/types'; import {parse} from '@libs/SearchParser/autocompleteParser'; import {getFilterDisplayValue} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import type {CardFeeds, CardList, PersonalDetailsList, Policy, Report, ReportAttributesDerivedValue} from '@src/types/onyx'; import type {SubstitutionMap} from './getQueryWithSubstitutions'; - -const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`; +import {getSubstitutionMapKey, getSubstitutionMapKeyWithIndex} from './getQueryWithSubstitutions'; /** * Given a plaintext query and specific entities data, @@ -44,6 +43,8 @@ function buildSubstitutionsMap( return {}; } + const substitutionKeyOccurrences = new Map(); + const substitutionsMap = searchAutocompleteQueryRanges.reduce((map, range) => { const {key: filterKey, value: filterValue} = range; @@ -56,7 +57,10 @@ function buildSubstitutionsMap( const taxRateNames = taxRates.length > 0 ? taxRates : [taxRateID]; const uniqueTaxRateNames = [...new Set(taxRateNames)]; for (const taxRateName of uniqueTaxRateNames) { - const substitutionKey = getSubstitutionsKey(filterKey, taxRateName); + const substitutionBaseKey = getSubstitutionMapKey(filterKey, taxRateName); + const occurrenceIndex = substitutionKeyOccurrences.get(substitutionBaseKey) ?? 0; + substitutionKeyOccurrences.set(substitutionBaseKey, occurrenceIndex + 1); + const substitutionKey = getSubstitutionMapKeyWithIndex(filterKey, taxRateName, occurrenceIndex); // eslint-disable-next-line no-param-reassign map[substitutionKey] = taxRateID; @@ -89,7 +93,10 @@ function buildSubstitutionsMap( // If displayValue === filterValue, then it means there is nothing to substitute, so we don't add any key to map if (displayValue !== filterValue) { - const substitutionKey = getSubstitutionsKey(filterKey, displayValue); + const substitutionBaseKey = getSubstitutionMapKey(filterKey, displayValue); + const occurrenceIndex = substitutionKeyOccurrences.get(substitutionBaseKey) ?? 0; + substitutionKeyOccurrences.set(substitutionBaseKey, occurrenceIndex + 1); + const substitutionKey = getSubstitutionMapKeyWithIndex(filterKey, displayValue, occurrenceIndex); // eslint-disable-next-line no-param-reassign map[substitutionKey] = filterValue; } diff --git a/src/components/Search/SearchRouter/getAutocompleteSelectionSubstitutionKey.ts b/src/components/Search/SearchRouter/getAutocompleteSelectionSubstitutionKey.ts new file mode 100644 index 0000000000000..c4d9af386ba59 --- /dev/null +++ b/src/components/Search/SearchRouter/getAutocompleteSelectionSubstitutionKey.ts @@ -0,0 +1,13 @@ +import {parse as parseSearchQuery} from '@libs/SearchParser/autocompleteParser'; + +function getAutocompleteSelectionSubstitutionKey(newSearchQuery: string, fieldKey: string, fallbackMapKey: string, fallbackSearchQuery: string): string { + const parsed = parseSearchQuery(newSearchQuery) as {ranges: Array<{key: string; value: string}>}; + const sameKeyRanges = parsed.ranges?.filter((range) => range.key === fieldKey) ?? []; + const lastRange = sameKeyRanges.at(-1); + const rangeValue = lastRange?.value ?? fallbackSearchQuery; + const index = sameKeyRanges.filter((range) => range.value === rangeValue).length - 1; + const substitutionBaseKey = `${fieldKey}:${rangeValue}`; + return index <= 0 ? fallbackMapKey : `${substitutionBaseKey}:${index}`; +} + +export default getAutocompleteSelectionSubstitutionKey; diff --git a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts index 84895efb33a5b..aefebb345297c 100644 --- a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts +++ b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts @@ -6,10 +6,29 @@ type SubstitutionMap = Record; const getSubstitutionMapKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`; +/** + * Key for the Nth occurrence of the same filter+value (e.g. multiple workspaces with the same name). + * Index 0 uses the base key for backward compatibility; index > 0 uses baseKey:index. + */ +const getSubstitutionMapKeyWithIndex = (filterKey: SearchFilterKey, value: string, index: number) => + index === 0 ? getSubstitutionMapKey(filterKey, value) : `${getSubstitutionMapKey(filterKey, value)}:${index}`; + +/** + * Resolve substitution for a (key, value, occurrenceIndex) triple. + * Falls back to base key when indexed keys are unavailable (e.g. rebuilt maps that only have base keys). + */ +const getSubstitutionForOccurrence = (substitutions: SubstitutionMap, filterKey: SearchFilterKey, value: string, index: number) => { + const baseKey = getSubstitutionMapKey(filterKey, value); + const indexedKey = getSubstitutionMapKeyWithIndex(filterKey, value, index); + return substitutions[indexedKey] ?? substitutions[baseKey]; +}; + /** * Given a plaintext query and a SubstitutionMap object, this function will return a transformed query where: * - any autocomplete mention in the original query will be substituted with an id taken from `substitutions` object * - anything that does not match will stay as is + * - when the same filter+value appears multiple times (e.g. workspace:"A's Workspace" three times), each occurrence + * is looked up with an index so multiple different IDs can be stored (baseKey for first, baseKey:1, baseKey:2, ...) * * Ex: * query: `A from:@johndoe A` @@ -27,12 +46,25 @@ function getQueryWithSubstitutions(changedQuery: string, substitutions: Substitu return changedQuery; } + // Count occurrence index per (key, value) so we can look up indexed keys for duplicates (e.g. same workspace name) + const keyValueCount = new Map(); + const rangeIndices = searchAutocompleteQueryRanges.map((range) => { + const baseKey = getSubstitutionMapKey(range.key, range.value); + const index = keyValueCount.get(baseKey) ?? 0; + keyValueCount.set(baseKey, index + 1); + return index; + }); + let resultQuery = changedQuery; let lengthDiff = 0; - for (const range of searchAutocompleteQueryRanges) { - const itemKey = getSubstitutionMapKey(range.key, range.value); - let substitutionEntry = substitutions[itemKey]; + for (let i = 0; i < searchAutocompleteQueryRanges.length; i++) { + const range = searchAutocompleteQueryRanges.at(i); + const index = rangeIndices.at(i); + if (range === undefined || index === undefined) { + continue; + } + let substitutionEntry = getSubstitutionForOccurrence(substitutions, range.key, range.value, index); if (substitutionEntry) { const substitutionStart = range.start + lengthDiff; @@ -48,5 +80,5 @@ function getQueryWithSubstitutions(changedQuery: string, substitutions: Substitu return resultQuery; } -export {getQueryWithSubstitutions, getSubstitutionMapKey}; +export {getQueryWithSubstitutions, getSubstitutionMapKey, getSubstitutionMapKeyWithIndex}; export type {SubstitutionMap}; diff --git a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts index 328eb3ff89814..fc7671b95bc7d 100644 --- a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts +++ b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts @@ -1,12 +1,13 @@ -import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types'; +import type {SearchAutocompleteQueryRange} from '@components/Search/types'; import {parse} from '@libs/SearchParser/autocompleteParser'; import type {SubstitutionMap} from './getQueryWithSubstitutions'; - -const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`; +import {getSubstitutionMapKeyWithIndex} from './getQueryWithSubstitutions'; /** * Given a plaintext query and a SubstitutionMap object, - * this function will remove any substitution keys that do not appear in the query and return an updated object + * this function will remove any substitution keys that do not appear in the query and return an updated object. + * When the same filter+value appears multiple times (e.g. workspace:"A's Workspace" three times), each occurrence + * is assigned an index and we preserve keys baseKey (index 0), baseKey:1, baseKey:2, ... so multiple IDs are kept. * * Ex: * query: `Test from:John1` @@ -24,17 +25,21 @@ function getUpdatedSubstitutionsMap(query: string, substitutions: SubstitutionMa return {}; } - const autocompleteQueryKeys = searchAutocompleteQueryRanges.map((range) => getSubstitutionsKey(range.key, range.value)); + // Assign occurrence index per (key, value) so we preserve indexed keys for duplicates (e.g. same workspace name) + const keyValueCount = new Map(); + const updatedSubstitutionMap: SubstitutionMap = {}; - // Build a new substitutions map consisting of only the keys from old map, that appear in query - const updatedSubstitutionMap = autocompleteQueryKeys.reduce((map, key) => { - if (substitutions[key]) { - // eslint-disable-next-line no-param-reassign - map[key] = substitutions[key]; - } + for (const range of searchAutocompleteQueryRanges) { + const baseKey = `${range.key}:${range.value}`; + const index = keyValueCount.get(baseKey) ?? 0; + keyValueCount.set(baseKey, index + 1); - return map; - }, {} as SubstitutionMap); + const fullKey = getSubstitutionMapKeyWithIndex(range.key, range.value, index); + const value = substitutions[fullKey] ?? substitutions[baseKey]; + if (value) { + updatedSubstitutionMap[fullKey] = value; + } + } return updatedSubstitutionMap; } diff --git a/src/hooks/useAutocompleteSuggestions.ts b/src/hooks/useAutocompleteSuggestions.ts index 8eabc5be71abb..4bc343fbee72b 100644 --- a/src/hooks/useAutocompleteSuggestions.ts +++ b/src/hooks/useAutocompleteSuggestions.ts @@ -1,6 +1,8 @@ import passthroughPolicyTagListSelector from '@selectors/PolicyTagList'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import type {SubstitutionMap} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; +import {getSubstitutionMapKey, getSubstitutionMapKeyWithIndex} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; import type {SearchFilterKey, UserFriendlyKey} from '@components/Search/types'; import {getCardFeedsForDisplay} from '@libs/CardFeedUtils'; import {getCardDescription, isCard, isCardHiddenFromSearch} from '@libs/CardUtils'; @@ -52,6 +54,8 @@ type UseAutocompleteSuggestionsParams = { personalDetails: OnyxEntry; feedKeysWithCards?: FeedKeysWithAssignedCards; translate: LocaleContextProps['translate']; + /** Map of display values to IDs for filters (e.g. workspace name → policy ID); used to exclude by ID when names duplicate */ + autocompleteSubstitutions?: SubstitutionMap; }; // Static autocomplete lists derived from CONST values, computed once at module load @@ -98,6 +102,7 @@ function useAutocompleteSuggestions({ personalDetails, feedKeysWithCards, translate, + autocompleteSubstitutions, }: UseAutocompleteSuggestionsParams): AutocompleteItemData[] { const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES); @@ -411,8 +416,30 @@ function useAutocompleteSuggestions({ } workspaceList.push({id: singlePolicy.id, name: singlePolicy.name ?? ''}); } + const policyIdRanges = ranges.filter((range) => range.key === autocompleteKey); + const keyValueCount = new Map(); + const alreadySelectedPolicyIds = + autocompleteSubstitutions && + new Set( + policyIdRanges + .map((range) => { + const baseKey = getSubstitutionMapKey(range.key, range.value); + const index = keyValueCount.get(baseKey) ?? 0; + keyValueCount.set(baseKey, index + 1); + const fullKey = getSubstitutionMapKeyWithIndex(range.key, range.value, index); + return autocompleteSubstitutions[fullKey] ?? (index === 0 ? autocompleteSubstitutions[baseKey] : undefined); + }) + .filter(Boolean), + ); + const filteredPolicies = workspaceList - .filter((workspace) => workspace.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.has(workspace.name.toLowerCase())) + .filter((workspace) => { + const matchesSearch = workspace.name.toLowerCase().includes(autocompleteValue.toLowerCase()); + if (alreadySelectedPolicyIds?.size) { + return matchesSearch && !alreadySelectedPolicyIds.has(workspace.id); + } + return matchesSearch && !alreadyAutocompletedKeys.has(workspace.name.toLowerCase()); + }) .sort() .slice(0, 10); diff --git a/tests/unit/Search/buildSubstitutionsMapTest.ts b/tests/unit/Search/buildSubstitutionsMapTest.ts index f3241fd17484f..486299bf48488 100644 --- a/tests/unit/Search/buildSubstitutionsMapTest.ts +++ b/tests/unit/Search/buildSubstitutionsMapTest.ts @@ -76,6 +76,17 @@ const cardFeedsMock: OnyxCollection = { }, }; +const policiesMock = { + [`${ONYXKEYS.COLLECTION.POLICY}policyA`]: { + id: 'policyA', + name: 'Test Workspace', + }, + [`${ONYXKEYS.COLLECTION.POLICY}policyB`]: { + id: 'policyB', + name: 'Test Workspace', + }, +} as OnyxCollection; + describe('buildSubstitutionsMap should return correct substitutions map', () => { test('when there were no substitutions', () => { const userQuery = 'foo bar'; @@ -118,4 +129,15 @@ describe('buildSubstitutionsMap should return correct substitutions map', () => 'from:me': '12345', }); }); + + test('when query has duplicate workspaces with same display name, build indexed substitution keys', () => { + const userQuery = 'policyID:policyA,policyB'; + + const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock, cardListMock, cardFeedsMock, policiesMock, 12345, translateLocal, {}); + + expect(result).toStrictEqual({ + 'policyID:Test Workspace': 'policyA', + 'policyID:Test Workspace:1': 'policyB', + }); + }); }); diff --git a/tests/unit/Search/getAutocompleteSelectionSubstitutionKeyTest.ts b/tests/unit/Search/getAutocompleteSelectionSubstitutionKeyTest.ts new file mode 100644 index 0000000000000..8c3a20afe2380 --- /dev/null +++ b/tests/unit/Search/getAutocompleteSelectionSubstitutionKeyTest.ts @@ -0,0 +1,18 @@ +import getAutocompleteSelectionSubstitutionKey from '@src/components/Search/SearchRouter/getAutocompleteSelectionSubstitutionKey'; + +describe('getAutocompleteSelectionSubstitutionKey', () => { + it('returns fallback map key for first occurrence of a value', () => { + const key = getAutocompleteSelectionSubstitutionKey('workspace:"Alpha" ', 'policyID', 'policyID:Alpha', 'Alpha'); + expect(key).toBe('policyID:Alpha'); + }); + + it('returns indexed key for duplicate occurrences of same value', () => { + const key = getAutocompleteSelectionSubstitutionKey('workspace:"Alpha","Alpha" ', 'policyID', 'policyID:Alpha', 'Alpha'); + expect(key).toBe('policyID:Alpha:1'); + }); + + it('counts index per key+value and does not index first occurrence of different value', () => { + const key = getAutocompleteSelectionSubstitutionKey('workspace:"Alpha","Beta" ', 'policyID', 'policyID:Beta', 'Beta'); + expect(key).toBe('policyID:Beta'); + }); +}); diff --git a/tests/unit/Search/getQueryWithSubstitutionsTest.ts b/tests/unit/Search/getQueryWithSubstitutionsTest.ts index 8ca2eec312564..4b85f0cc3e846 100644 --- a/tests/unit/Search/getQueryWithSubstitutionsTest.ts +++ b/tests/unit/Search/getQueryWithSubstitutionsTest.ts @@ -89,4 +89,28 @@ describe('getQueryWithSubstitutions should compute and return correct new query' expect(result).toBe('foo in:wave2,zxcv123 from:zzzz'); }); + + test('when query has duplicate workspace names with indexed substitution keys', () => { + const userTypedQuery = 'workspace:"Test Workspace","Test Workspace","Test Workspace"'; + const substitutionsMock = { + 'policyID:Test Workspace': 'policyA', + 'policyID:Test Workspace:1': 'policyB', + 'policyID:Test Workspace:2': 'policyC', + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('workspace:policyA,policyB,policyC'); + }); + + test('when duplicate values have only base substitution key, all duplicates still substitute', () => { + const userTypedQuery = 'workspace:"Test Workspace","Test Workspace"'; + const substitutionsMock = { + 'policyID:Test Workspace': 'policyA', + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('workspace:policyA,policyA'); + }); }); diff --git a/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts index f54bbf46aacdd..99355bdad92d7 100644 --- a/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts +++ b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts @@ -53,4 +53,34 @@ describe('getUpdatedSubstitutionsMap should return updated and cleaned substitut 'to:Steven': '@steven', }); }); + + test('when query has duplicate workspace names with indexed substitution keys', () => { + const userTypedQuery = 'workspace:"Test Workspace","Test Workspace"'; + const substitutionsMock = { + 'policyID:Test Workspace': 'policyA', + 'policyID:Test Workspace:1': 'policyB', + 'policyID:Test Workspace:2': 'policyC', + }; + + const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); + + expect(result).toStrictEqual({ + 'policyID:Test Workspace': 'policyA', + 'policyID:Test Workspace:1': 'policyB', + }); + }); + + test('when query has duplicate workspace names but map has only base key, keep substitution for all occurrences', () => { + const userTypedQuery = 'workspace:"Test Workspace","Test Workspace"'; + const substitutionsMock = { + 'policyID:Test Workspace': 'policyA', + }; + + const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); + + expect(result).toStrictEqual({ + 'policyID:Test Workspace': 'policyA', + 'policyID:Test Workspace:1': 'policyA', + }); + }); }); diff --git a/tests/unit/hooks/useAutocompleteSuggestions.test.ts b/tests/unit/hooks/useAutocompleteSuggestions.test.ts index dba4e4bb4cf62..a364e9662e499 100644 --- a/tests/unit/hooks/useAutocompleteSuggestions.test.ts +++ b/tests/unit/hooks/useAutocompleteSuggestions.test.ts @@ -1,6 +1,8 @@ import {renderHook} from '@testing-library/react-native'; +import type {OnyxCollection} from 'react-native-onyx'; import useAutocompleteSuggestions from '@hooks/useAutocompleteSuggestions'; import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; const onyxData: Record = {}; @@ -324,4 +326,36 @@ describe('useAutocompleteSuggestions', () => { expect(result.current).toEqual([]); }); + + it('excludes already selected workspaces by policy ID when names are duplicated', () => { + parseForAutocomplete.mockReturnValue({ + autocomplete: {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, value: 'test workspace'}, + ranges: [ + {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, value: 'Test Workspace', start: 0, length: 24}, + {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, value: 'Test Workspace', start: 25, length: 24}, + ], + }); + + const policiesWithSameName = { + policyOne: {id: 'policyA', name: 'Test Workspace'}, + policyTwo: {id: 'policyB', name: 'Test Workspace'}, + policyThree: {id: 'policyC', name: 'Test Workspace'}, + } as unknown as NonNullable>; + + const {result} = renderHook(() => + useAutocompleteSuggestions({ + ...defaultParams, + autocompleteQueryValue: 'workspace:"Test Workspace","Test Workspace"', + policies: policiesWithSameName, + autocompleteSubstitutions: Object.fromEntries([ + ['policyID:Test Workspace', 'policyA'], + ['policyID:Test Workspace:1', 'policyB'], + ]), + }), + ); + + expect(result.current).toHaveLength(1); + expect(result.current.at(0)?.autocompleteID).toBe('policyC'); + expect(result.current.at(0)?.text).toBe('Test Workspace'); + }); });