From 688aca374275de2c5f95b452cf995b5fe653fdd0 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Wed, 18 Feb 2026 16:57:24 +0500 Subject: [PATCH 1/6] 82304: Search - The workspace with the same name is not displayed in the autocomplete --- .../Search/SearchAutocompleteList.tsx | 34 +++++++++++++++++-- .../SearchPageHeaderInput.tsx | 19 ++++++++--- .../Search/SearchRouter/SearchRouter.tsx | 18 +++++++--- .../SearchRouter/getQueryWithSubstitutions.ts | 28 ++++++++++++--- .../getUpdatedSubstitutionsMap.ts | 29 +++++++++------- 5 files changed, 102 insertions(+), 26 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 082c288f1d598..45cb540db9e05 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -51,7 +51,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {CardFeeds, CardList, PersonalDetailsList, Policy, Report} from '@src/types/onyx'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; -import {getSubstitutionMapKey} from './SearchRouter/getQueryWithSubstitutions'; +import type {SubstitutionMap} from './SearchRouter/getQueryWithSubstitutions'; +import {getSubstitutionMapKey, getSubstitutionMapKeyWithIndex} from './SearchRouter/getQueryWithSubstitutions'; import type {SearchFilterKey, UserFriendlyKey} from './types'; type AutocompleteItemData = { @@ -102,6 +103,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; }; @@ -164,6 +168,7 @@ function SearchAutocompleteList({ reports, allFeeds, allCards = CONST.EMPTY_OBJECT, + autocompleteSubstitutions, ref, }: SearchAutocompleteListProps) { const styles = useThemeStyles(); @@ -604,8 +609,33 @@ function SearchAutocompleteList({ })); } case CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID: { + // Exclude by policy ID when substitutions are available so that workspaces with the same name + // (e.g. 3+ workspaces named "A's Workspace") each appear in autocomplete until selected; we use + // index-based keys so multiple same-name selections are tracked separately. + 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/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx index 17a7faf32be4f..0915a7fb85373 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx @@ -13,10 +13,10 @@ import SearchAutocompleteList from '@components/Search/SearchAutocompleteList'; import SearchInputSelectionWrapper from '@components/Search/SearchInputSelectionWrapper'; import {buildSubstitutionsMap} from '@components/Search/SearchRouter/buildSubstitutionsMap'; import type {SubstitutionMap} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; -import {getQueryWithSubstitutions} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; +import {getQueryWithSubstitutions, getSubstitutionMapKeyWithIndex} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; import {getUpdatedSubstitutionsMap} from '@components/Search/SearchRouter/getUpdatedSubstitutionsMap'; import {useSearchRouterActions} from '@components/Search/SearchRouter/SearchRouterContext'; -import type {SearchQueryJSON, SearchQueryString} from '@components/Search/types'; +import type {SearchFilterKey, SearchQueryJSON, SearchQueryString} from '@components/Search/types'; import type {SelectionListWithSectionsHandle} from '@components/SelectionList/SelectionListWithSections/types'; import type {SearchQueryItem} from '@components/SelectionListWithSections/Search/SearchQueryListItem'; import {isSearchQueryItem} from '@components/SelectionListWithSections/Search/SearchQueryListItem'; @@ -33,6 +33,7 @@ import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; +import {parse as parseSearchQuery} from '@libs/SearchParser/autocompleteParser'; import {getAutocompleteQueryWithComma, getTrimmedUserSearchQueryPreservingComma} from '@libs/SearchAutocompleteUtils'; import {buildUserReadableQueryString, getQueryWithUpdatedValues, sanitizeSearchValue} from '@libs/SearchQueryUtils'; import StringUtils from '@libs/StringUtils'; @@ -282,8 +283,16 @@ 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) { + // When multiple options share the same name (e.g. workspaces), use index-based key so we don't overwrite + const parsed = parseSearchQuery(newSearchQuery) as {ranges: Array<{key: string; value: string}>}; + const sameKeyRanges = parsed.ranges?.filter((r) => r.key === fieldKey) ?? []; + const index = sameKeyRanges.length - 1; + const lastRange = sameKeyRanges.at(-1); + const rangeValue = lastRange?.value ?? item.searchQuery; + const substitutionKey = + index <= 0 ? item.mapKey : getSubstitutionMapKeyWithIndex(fieldKey as SearchFilterKey, rangeValue, index); + const substitutions = {...autocompleteSubstitutions, [substitutionKey]: item.autocompleteID}; setAutocompleteSubstitutions(substitutions); } @@ -404,6 +413,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo allCards={personalAndWorkspaceCards} allFeeds={allFeeds} textInputRef={textInputRef} + autocompleteSubstitutions={autocompleteSubstitutions} /> )} @@ -476,6 +486,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 75a3294cde103..8a669ffbf895d 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -12,7 +12,7 @@ import type {GetAdditionalSectionsCallback} from '@components/Search/SearchAutoc import SearchAutocompleteList from '@components/Search/SearchAutocompleteList'; import {useSearchContext} from '@components/Search/SearchContext'; import SearchInputSelectionWrapper from '@components/Search/SearchInputSelectionWrapper'; -import type {SearchQueryString} from '@components/Search/types'; +import type {SearchFilterKey, SearchQueryString} from '@components/Search/types'; import type {SelectionListWithSectionsHandle} from '@components/SelectionList/SelectionListWithSections/types'; import type {SearchQueryItem} from '@components/SelectionListWithSections/Search/SearchQueryListItem'; import {isSearchQueryItem} from '@components/SelectionListWithSections/Search/SearchQueryListItem'; @@ -35,6 +35,7 @@ import Parser from '@libs/Parser'; import {getReportAction} from '@libs/ReportActionsUtils'; import {getReportOrDraftReport} from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; +import {parse as parseSearchQuery} from '@libs/SearchParser/autocompleteParser'; import {getAutocompleteQueryWithComma, getTrimmedUserSearchQueryPreservingComma} from '@libs/SearchAutocompleteUtils'; import {getQueryWithUpdatedValues, sanitizeSearchValue} from '@libs/SearchQueryUtils'; import StringUtils from '@libs/StringUtils'; @@ -48,7 +49,7 @@ import ROUTES from '@src/ROUTES'; import type Report from '@src/types/onyx/Report'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {SubstitutionMap} from './getQueryWithSubstitutions'; -import {getQueryWithSubstitutions} from './getQueryWithSubstitutions'; +import {getQueryWithSubstitutions, getSubstitutionMapKeyWithIndex} from './getQueryWithSubstitutions'; import {getUpdatedSubstitutionsMap} from './getUpdatedSubstitutionsMap'; import {getContextualReportData, getContextualSearchAutocompleteKey, getContextualSearchQuery} from './SearchRouterUtils'; @@ -354,8 +355,16 @@ 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) { + // When multiple options share the same name (e.g. workspaces), use index-based key so we don't overwrite + const parsed = parseSearchQuery(newSearchQuery) as {ranges: Array<{key: string; value: string}>}; + const sameKeyRanges = parsed.ranges?.filter((r) => r.key === fieldKey) ?? []; + const index = sameKeyRanges.length - 1; + const lastRange = sameKeyRanges.at(-1); + const rangeValue = lastRange?.value ?? item.searchQuery; + const substitutionKey = + index <= 0 ? item.mapKey : getSubstitutionMapKeyWithIndex(fieldKey as SearchFilterKey, rangeValue, index); + const substitutions = {...autocompleteSubstitutions, [substitutionKey]: item.autocompleteID}; setAutocompleteSubstitutions(substitutions); } setFocusAndScrollToRight(); @@ -475,6 +484,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla allFeeds={allFeeds} allCards={personalAndWorkspaceCards} textInputRef={textInputRef} + autocompleteSubstitutions={autocompleteSubstitutions} /> )} {!shouldShowList && ( diff --git a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts index 84895efb33a5b..c59944b2c0f7f 100644 --- a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts +++ b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts @@ -6,10 +6,19 @@ 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}`; + /** * 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 +36,23 @@ 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[i]; + const index = rangeIndices[i]; + const itemKey = getSubstitutionMapKeyWithIndex(range.key, range.value, index); + let substitutionEntry = substitutions[itemKey] ?? (index === 0 ? substitutions[getSubstitutionMapKey(range.key, range.value)] : undefined); if (substitutionEntry) { const substitutionStart = range.start + lengthDiff; @@ -48,5 +68,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..a61b3556bc089 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 {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 as SearchFilterKey, range.value, index); + const value = substitutions[fullKey] ?? (index === 0 ? substitutions[baseKey] : undefined); + if (value) { + updatedSubstitutionMap[fullKey] = value; + } + } return updatedSubstitutionMap; } From de67dc71056448b9333c8d7e64179f960eec8dd5 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Wed, 1 Apr 2026 04:31:36 +0500 Subject: [PATCH 2/6] Fixed Eslint issues --- .../Search/SearchRouter/getQueryWithSubstitutions.ts | 7 +++++-- .../Search/SearchRouter/getUpdatedSubstitutionsMap.ts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts index c59944b2c0f7f..90e46b0299d3e 100644 --- a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts +++ b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts @@ -49,8 +49,11 @@ function getQueryWithSubstitutions(changedQuery: string, substitutions: Substitu let lengthDiff = 0; for (let i = 0; i < searchAutocompleteQueryRanges.length; i++) { - const range = searchAutocompleteQueryRanges[i]; - const index = rangeIndices[i]; + const range = searchAutocompleteQueryRanges.at(i); + const index = rangeIndices.at(i); + if (range === undefined || index === undefined) { + continue; + } const itemKey = getSubstitutionMapKeyWithIndex(range.key, range.value, index); let substitutionEntry = substitutions[itemKey] ?? (index === 0 ? substitutions[getSubstitutionMapKey(range.key, range.value)] : undefined); diff --git a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts index a61b3556bc089..7cefd7667e8b3 100644 --- a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts +++ b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts @@ -1,4 +1,4 @@ -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'; import {getSubstitutionMapKeyWithIndex} from './getQueryWithSubstitutions'; @@ -34,7 +34,7 @@ function getUpdatedSubstitutionsMap(query: string, substitutions: SubstitutionMa const index = keyValueCount.get(baseKey) ?? 0; keyValueCount.set(baseKey, index + 1); - const fullKey = getSubstitutionMapKeyWithIndex(range.key as SearchFilterKey, range.value, index); + const fullKey = getSubstitutionMapKeyWithIndex(range.key, range.value, index); const value = substitutions[fullKey] ?? (index === 0 ? substitutions[baseKey] : undefined); if (value) { updatedSubstitutionMap[fullKey] = value; From 2f7ac09455598c3c785e1eaa78d4ab540d3097d9 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Tue, 7 Apr 2026 23:54:38 +0500 Subject: [PATCH 3/6] Fixed AI feedbacks --- .../Search/SearchPageHeader/SearchPageHeaderInput.tsx | 6 ++++-- src/components/Search/SearchRouter/SearchRouter.tsx | 10 ++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx index fce9e99c813e2..0db162255f896 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx @@ -233,10 +233,12 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo if (item.mapKey && item.autocompleteID && fieldKey) { const parsed = parseSearchQuery(newSearchQuery) as {ranges: Array<{key: string; value: string}>}; const sameKeyRanges = parsed.ranges?.filter((r) => r.key === fieldKey) ?? []; - const index = sameKeyRanges.length - 1; const lastRange = sameKeyRanges.at(-1); const rangeValue = lastRange?.value ?? item.searchQuery; - const substitutionKey = index <= 0 ? item.mapKey : getSubstitutionMapKeyWithIndex(fieldKey as SearchFilterKey, rangeValue, index); + // Index must be per (key, value), not just key, so mixed values don't collide. + const index = sameKeyRanges.filter((range) => range.value === rangeValue).length - 1; + const substitutionBaseKey = `${fieldKey}:${rangeValue}`; + const substitutionKey = index <= 0 ? substitutionBaseKey : `${substitutionBaseKey}:${index}`; const substitutions = {...autocompleteSubstitutions, [substitutionKey]: item.autocompleteID}; setAutocompleteSubstitutions(substitutions); } diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 0dec16d51e343..b1d59ef15a36a 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -13,7 +13,7 @@ import {useSearchActionsContext} from '@components/Search/SearchContext'; import SearchInputSelectionWrapper from '@components/Search/SearchInputSelectionWrapper'; import type {SearchQueryItem} from '@components/Search/SearchList/ListItem/SearchQueryListItem'; import {isSearchQueryItem} from '@components/Search/SearchList/ListItem/SearchQueryListItem'; -import type {SearchFilterKey, SearchQueryString} from '@components/Search/types'; +import type {SearchQueryString} from '@components/Search/types'; import type {SelectionListWithSectionsHandle} from '@components/SelectionList/SelectionListWithSections/types'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; @@ -46,7 +46,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type Report from '@src/types/onyx/Report'; import type {SubstitutionMap} from './getQueryWithSubstitutions'; -import {getQueryWithSubstitutions, getSubstitutionMapKeyWithIndex} from './getQueryWithSubstitutions'; +import {getQueryWithSubstitutions} from './getQueryWithSubstitutions'; import {getUpdatedSubstitutionsMap} from './getUpdatedSubstitutionsMap'; import {getContextualReportData, getContextualSearchAutocompleteKey, getContextualSearchQuery} from './SearchRouterUtils'; @@ -282,10 +282,12 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla if (item.mapKey && item.autocompleteID && fieldKey) { const parsed = parseSearchQuery(newSearchQuery) as {ranges: Array<{key: string; value: string}>}; const sameKeyRanges = parsed.ranges?.filter((r) => r.key === fieldKey) ?? []; - const index = sameKeyRanges.length - 1; const lastRange = sameKeyRanges.at(-1); const rangeValue = lastRange?.value ?? item.searchQuery; - const substitutionKey = index <= 0 ? item.mapKey : getSubstitutionMapKeyWithIndex(fieldKey as SearchFilterKey, rangeValue, index); + // Index must be per (key, value), not just key, so mixed values don't collide. + const index = sameKeyRanges.filter((range) => range.value === rangeValue).length - 1; + const substitutionBaseKey = `${fieldKey}:${rangeValue}`; + const substitutionKey = index <= 0 ? substitutionBaseKey : `${substitutionBaseKey}:${index}`; const substitutions = {...autocompleteSubstitutions, [substitutionKey]: item.autocompleteID}; setAutocompleteSubstitutions(substitutions); } From 4c02a00d480eebff3557d5b8376b874bbd320743 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Wed, 8 Apr 2026 00:07:14 +0500 Subject: [PATCH 4/6] Fixed esLint issues --- .../Search/SearchPageHeader/SearchPageHeaderInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx index 0db162255f896..0e3f791cab4d7 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx @@ -15,10 +15,10 @@ import type {SearchQueryItem} from '@components/Search/SearchList/ListItem/Searc import {isSearchQueryItem} from '@components/Search/SearchList/ListItem/SearchQueryListItem'; import {buildSubstitutionsMap} from '@components/Search/SearchRouter/buildSubstitutionsMap'; import type {SubstitutionMap} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; -import {getQueryWithSubstitutions, getSubstitutionMapKeyWithIndex} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; +import {getQueryWithSubstitutions} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; import {getUpdatedSubstitutionsMap} from '@components/Search/SearchRouter/getUpdatedSubstitutionsMap'; import {useSearchRouterActions} from '@components/Search/SearchRouter/SearchRouterContext'; -import type {SearchFilterKey, SearchQueryJSON, SearchQueryString} from '@components/Search/types'; +import type {SearchQueryJSON, SearchQueryString} from '@components/Search/types'; import type {SelectionListWithSectionsHandle} from '@components/SelectionList/SelectionListWithSections/types'; import SidePanelButton from '@components/SidePanel/SidePanelButton'; import useFeedKeysWithAssignedCards from '@hooks/useFeedKeysWithAssignedCards'; From 5911c943cc05dbf8e602906d10e999f2c8e6c7fb Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 9 Apr 2026 01:05:48 +0500 Subject: [PATCH 5/6] Refactor code and added unit test cases --- .../SearchPageHeaderInput.tsx | 11 ++---- .../Search/SearchRouter/SearchRouter.tsx | 11 ++---- ...getAutocompleteSelectionSubstitutionKey.ts | 13 +++++++ ...utocompleteSelectionSubstitutionKeyTest.ts | 18 ++++++++++ .../Search/getQueryWithSubstitutionsTest.ts | 13 +++++++ .../Search/getUpdatedSubstitutionsMapTest.ts | 16 +++++++++ .../hooks/useAutocompleteSuggestions.test.ts | 34 +++++++++++++++++++ 7 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 src/components/Search/SearchRouter/getAutocompleteSelectionSubstitutionKey.ts create mode 100644 tests/unit/Search/getAutocompleteSelectionSubstitutionKeyTest.ts diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx index 0e3f791cab4d7..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'; @@ -35,7 +36,6 @@ import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; import {getAutocompleteQueryWithComma, getTrimmedUserSearchQueryPreservingComma} from '@libs/SearchAutocompleteUtils'; -import {parse as parseSearchQuery} from '@libs/SearchParser/autocompleteParser'; import {buildUserReadableQueryString, getQueryWithUpdatedValues, sanitizeSearchValue} from '@libs/SearchQueryUtils'; import StringUtils from '@libs/StringUtils'; import variables from '@styles/variables'; @@ -231,14 +231,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo setSelection({start: newSearchQuery.length, end: newSearchQuery.length}); if (item.mapKey && item.autocompleteID && fieldKey) { - const parsed = parseSearchQuery(newSearchQuery) as {ranges: Array<{key: string; value: string}>}; - const sameKeyRanges = parsed.ranges?.filter((r) => r.key === fieldKey) ?? []; - const lastRange = sameKeyRanges.at(-1); - const rangeValue = lastRange?.value ?? item.searchQuery; - // Index must be per (key, value), not just key, so mixed values don't collide. - const index = sameKeyRanges.filter((range) => range.value === rangeValue).length - 1; - const substitutionBaseKey = `${fieldKey}:${rangeValue}`; - const substitutionKey = index <= 0 ? substitutionBaseKey : `${substitutionBaseKey}:${index}`; + const substitutionKey = getAutocompleteSelectionSubstitutionKey(newSearchQuery, fieldKey, item.mapKey, item.searchQuery); const substitutions = {...autocompleteSubstitutions, [substitutionKey]: item.autocompleteID}; setAutocompleteSubstitutions(substitutions); } diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index b1d59ef15a36a..1e4c41fc8f740 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -34,7 +34,6 @@ import {getReportAction} from '@libs/ReportActionsUtils'; import {getReportOrDraftReport} from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; import {getAutocompleteQueryWithComma, getTrimmedUserSearchQueryPreservingComma} from '@libs/SearchAutocompleteUtils'; -import {parse as parseSearchQuery} from '@libs/SearchParser/autocompleteParser'; import {getQueryWithUpdatedValues, sanitizeSearchValue} from '@libs/SearchQueryUtils'; import StringUtils from '@libs/StringUtils'; import Navigation from '@navigation/Navigation'; @@ -45,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'; @@ -280,14 +280,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla setSelection({start: newSearchQuery.length, end: newSearchQuery.length}); if (item.mapKey && item.autocompleteID && fieldKey) { - const parsed = parseSearchQuery(newSearchQuery) as {ranges: Array<{key: string; value: string}>}; - const sameKeyRanges = parsed.ranges?.filter((r) => r.key === fieldKey) ?? []; - const lastRange = sameKeyRanges.at(-1); - const rangeValue = lastRange?.value ?? item.searchQuery; - // Index must be per (key, value), not just key, so mixed values don't collide. - const index = sameKeyRanges.filter((range) => range.value === rangeValue).length - 1; - const substitutionBaseKey = `${fieldKey}:${rangeValue}`; - const substitutionKey = index <= 0 ? substitutionBaseKey : `${substitutionBaseKey}:${index}`; + const substitutionKey = getAutocompleteSelectionSubstitutionKey(newSearchQuery, fieldKey, item.mapKey, item.searchQuery); const substitutions = {...autocompleteSubstitutions, [substitutionKey]: item.autocompleteID}; setAutocompleteSubstitutions(substitutions); } 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/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..2ae4eda7b25dc 100644 --- a/tests/unit/Search/getQueryWithSubstitutionsTest.ts +++ b/tests/unit/Search/getQueryWithSubstitutionsTest.ts @@ -89,4 +89,17 @@ 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'); + }); }); diff --git a/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts index f54bbf46aacdd..9810566c888d0 100644 --- a/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts +++ b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts @@ -53,4 +53,20 @@ 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', + }); + }); }); 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'); + }); }); From 521f91dec39c48b109cea86c4721906affe400dd Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 9 Apr 2026 01:52:33 +0500 Subject: [PATCH 6/6] Fix workspace duplicate substitution rebuild to preserve indexed IDs and add regression tests --- .../SearchRouter/buildSubstitutionsMap.ts | 17 +++++++++----- .../SearchRouter/getQueryWithSubstitutions.ts | 13 +++++++++-- .../getUpdatedSubstitutionsMap.ts | 2 +- .../unit/Search/buildSubstitutionsMapTest.ts | 22 +++++++++++++++++++ .../Search/getQueryWithSubstitutionsTest.ts | 11 ++++++++++ .../Search/getUpdatedSubstitutionsMapTest.ts | 14 ++++++++++++ 6 files changed, 71 insertions(+), 8 deletions(-) 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/getQueryWithSubstitutions.ts b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts index 90e46b0299d3e..aefebb345297c 100644 --- a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts +++ b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts @@ -13,6 +13,16 @@ const getSubstitutionMapKey = (filterKey: SearchFilterKey, value: string) => `${ 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 @@ -54,8 +64,7 @@ function getQueryWithSubstitutions(changedQuery: string, substitutions: Substitu if (range === undefined || index === undefined) { continue; } - const itemKey = getSubstitutionMapKeyWithIndex(range.key, range.value, index); - let substitutionEntry = substitutions[itemKey] ?? (index === 0 ? substitutions[getSubstitutionMapKey(range.key, range.value)] : undefined); + let substitutionEntry = getSubstitutionForOccurrence(substitutions, range.key, range.value, index); if (substitutionEntry) { const substitutionStart = range.start + lengthDiff; diff --git a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts index 7cefd7667e8b3..fc7671b95bc7d 100644 --- a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts +++ b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts @@ -35,7 +35,7 @@ function getUpdatedSubstitutionsMap(query: string, substitutions: SubstitutionMa keyValueCount.set(baseKey, index + 1); const fullKey = getSubstitutionMapKeyWithIndex(range.key, range.value, index); - const value = substitutions[fullKey] ?? (index === 0 ? substitutions[baseKey] : undefined); + const value = substitutions[fullKey] ?? substitutions[baseKey]; if (value) { updatedSubstitutionMap[fullKey] = value; } 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/getQueryWithSubstitutionsTest.ts b/tests/unit/Search/getQueryWithSubstitutionsTest.ts index 2ae4eda7b25dc..4b85f0cc3e846 100644 --- a/tests/unit/Search/getQueryWithSubstitutionsTest.ts +++ b/tests/unit/Search/getQueryWithSubstitutionsTest.ts @@ -102,4 +102,15 @@ describe('getQueryWithSubstitutions should compute and return correct new query' 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 9810566c888d0..99355bdad92d7 100644 --- a/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts +++ b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts @@ -69,4 +69,18 @@ describe('getUpdatedSubstitutionsMap should return updated and cleaned substitut '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', + }); + }); });