Skip to content
Open
6 changes: 6 additions & 0 deletions src/components/Search/SearchAutocompleteList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<SelectionListWithSectionsHandle>;
};
Expand Down Expand Up @@ -143,6 +147,7 @@ function SearchAutocompleteList({
reports,
allFeeds,
allCards = CONST.EMPTY_OBJECT,
autocompleteSubstitutions,
ref,
}: SearchAutocompleteListProps) {
const styles = useThemeStyles();
Expand Down Expand Up @@ -290,6 +295,7 @@ function SearchAutocompleteList({
personalDetails,
feedKeysWithCards,
translate,
autocompleteSubstitutions,
});

const autocompleteQueryWithoutFilters = getQueryWithoutFilters(autocompleteQueryValue);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -301,6 +303,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
allCards={personalAndWorkspaceCards}
allFeeds={allFeeds}
textInputRef={textInputRef}
autocompleteSubstitutions={autocompleteSubstitutions}
/>
</View>
)}
Expand Down Expand Up @@ -373,6 +376,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
allCards={personalAndWorkspaceCards}
allFeeds={allFeeds}
textInputRef={textInputRef}
autocompleteSubstitutions={autocompleteSubstitutions}
/>
</View>
)}
Expand Down
7 changes: 5 additions & 2 deletions src/components/Search/SearchRouter/SearchRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -359,6 +361,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
allFeeds={allFeeds}
allCards={personalAndWorkspaceCards}
textInputRef={textInputRef}
autocompleteSubstitutions={autocompleteSubstitutions}
/>
</View>
);
Expand Down
17 changes: 12 additions & 5 deletions src/components/Search/SearchRouter/buildSubstitutionsMap.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -44,6 +43,8 @@ function buildSubstitutionsMap(
return {};
}

const substitutionKeyOccurrences = new Map<string, number>();

const substitutionsMap = searchAutocompleteQueryRanges.reduce((map, range) => {
const {key: filterKey, value: filterValue} = range;

Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
40 changes: 36 additions & 4 deletions src/components/Search/SearchRouter/getQueryWithSubstitutions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,29 @@ type SubstitutionMap = Record<string, string>;

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`
Expand All @@ -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<string, number>();
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;
Expand All @@ -48,5 +80,5 @@ function getQueryWithSubstitutions(changedQuery: string, substitutions: Substitu
return resultQuery;
}

export {getQueryWithSubstitutions, getSubstitutionMapKey};
export {getQueryWithSubstitutions, getSubstitutionMapKey, getSubstitutionMapKeyWithIndex};
export type {SubstitutionMap};
31 changes: 18 additions & 13 deletions src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -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<string, number>();
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;
}
Expand Down
29 changes: 28 additions & 1 deletion src/hooks/useAutocompleteSuggestions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -52,6 +54,8 @@ type UseAutocompleteSuggestionsParams = {
personalDetails: OnyxEntry<PersonalDetailsList>;
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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, number>();
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);

Expand Down
22 changes: 22 additions & 0 deletions tests/unit/Search/buildSubstitutionsMapTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ const cardFeedsMock: OnyxCollection<OnyxTypes.CardFeeds> = {
},
};

const policiesMock = {
[`${ONYXKEYS.COLLECTION.POLICY}policyA`]: {
id: 'policyA',
name: 'Test Workspace',
},
[`${ONYXKEYS.COLLECTION.POLICY}policyB`]: {
id: 'policyB',
name: 'Test Workspace',
},
} as OnyxCollection<OnyxTypes.Policy>;

describe('buildSubstitutionsMap should return correct substitutions map', () => {
test('when there were no substitutions', () => {
const userQuery = 'foo bar';
Expand Down Expand Up @@ -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',
});
});
});
Loading
Loading