Skip to content
11 changes: 11 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {sub as dateSubtract} from 'date-fns/sub';
import Config from 'react-native-config';
import * as KeyCommand from 'react-native-key-command';
import type {ValueOf} from 'type-fest';
import type {SearchFilterKey} from '@components/Search/types';
import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types';
import type {MileageRate} from '@libs/DistanceRequestUtils';
import BankAccount from '@libs/models/BankAccount';
Expand Down Expand Up @@ -6975,6 +6976,14 @@ const CONST = {
},
} as const;

const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS = [
CONST.SEARCH.SYNTAX_FILTER_KEYS.TO,
CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM,
CONST.SEARCH.SYNTAX_FILTER_KEYS.ASSIGNEE,
CONST.SEARCH.SYNTAX_FILTER_KEYS.PAYER,
CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER,
] as SearchFilterKey[];

type Country = keyof typeof CONST.ALL_COUNTRIES;

type IOUType = ValueOf<typeof CONST.IOU.TYPE>;
Expand All @@ -6988,4 +6997,6 @@ type CancellationType = ValueOf<typeof CONST.CANCELLATION_TYPE>;

export type {Country, IOUAction, IOUType, IOURequestType, SubscriptionType, FeedbackSurveyOptionID, CancellationType, OnboardingInvite, OnboardingAccounting, IOUActionParams};

export {CONTINUATION_DETECTION_SEARCH_FILTER_KEYS};

export default CONST;
39 changes: 35 additions & 4 deletions src/components/Search/SearchAutocompleteList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {buildSearchQueryJSON, buildUserReadableQueryString, getQueryWithoutFilte
import {getDatePresets} from '@libs/SearchUIUtils';
import StringUtils from '@libs/StringUtils';
import Timing from '@userActions/Timing';
import CONST from '@src/CONST';
import CONST, {CONTINUATION_DETECTION_SEARCH_FILTER_KEYS} from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {CardFeeds, CardList, PersonalDetailsList, Policy, Report} from '@src/types/onyx';
import {getEmptyObject} from '@src/types/utils/EmptyObject';
Expand Down Expand Up @@ -254,8 +254,22 @@ function SearchAutocompleteList(

const autocompleteSuggestions = useMemo<AutocompleteItemData[]>(() => {
const {autocomplete, ranges = []} = autocompleteParsedQuery ?? {};
const autocompleteKey = autocomplete?.key;
const autocompleteValue = autocomplete?.value ?? '';

let autocompleteKey = autocomplete?.key;
let autocompleteValue = autocomplete?.value ?? '';

if (!autocomplete && ranges.length > 0) {
const lastRange = ranges.at(ranges.length - 1);
if (lastRange && CONTINUATION_DETECTION_SEARCH_FILTER_KEYS.includes(lastRange.key)) {
const afterLastRange = autocompleteQueryValue.substring(lastRange.start + lastRange.length);
const continuationMatch = afterLastRange.match(/^\s+(\w+)/);

if (continuationMatch) {
autocompleteKey = lastRange.key;
autocompleteValue = `${lastRange.value} ${continuationMatch[1]}`;
}
}
}

const alreadyAutocompletedKeys = ranges
.filter((range) => {
Expand Down Expand Up @@ -446,6 +460,7 @@ function SearchAutocompleteList(
}
}, [
autocompleteParsedQuery,
autocompleteQueryValue,
tagAutocompleteList,
recentTagsAutocompleteList,
categoryAutocompleteList,
Expand Down Expand Up @@ -652,7 +667,23 @@ function SearchAutocompleteList(
return;
}

const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(autocompleteQueryValue);
const fieldKey = focusedItem.mapKey?.includes(':') ? focusedItem.mapKey.split(':').at(0) : focusedItem.mapKey;
const isNameField = fieldKey && CONTINUATION_DETECTION_SEARCH_FILTER_KEYS.includes(fieldKey as SearchFilterKey);

let trimmedUserSearchQuery;
if (isNameField && fieldKey) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use this logic 3x, in only very slightly different ways; do you think we can strip it out into a helper function? Here and in the two files below.

@ZhenjaHorbach ZhenjaHorbach Aug 7, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic is similar
But not the same
I think the helper function will be a bit bulky

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah i guess so, it's different enough. Okay this works for me!

const fieldPattern = `${fieldKey}:`;
const keyIndex = autocompleteQueryValue.toLowerCase().lastIndexOf(fieldPattern.toLowerCase());

if (keyIndex !== -1) {
trimmedUserSearchQuery = autocompleteQueryValue.substring(0, keyIndex + fieldPattern.length);
} else {
trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(autocompleteQueryValue);
}
} else {
trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(autocompleteQueryValue);
}

setTextQuery(`${trimmedUserSearchQuery}${sanitizeSearchValue(focusedItem.searchQuery)}\u00A0`);
updateAutocompleteSubstitutions(focusedItem);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,13 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
}

if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) {
const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue);
const fieldKey = item.mapKey?.includes(':') ? item.mapKey.split(':').at(0) : item.mapKey;
const keyIndex = fieldKey ? textInputValue.toLowerCase().lastIndexOf(`${fieldKey}:`) : -1;

const trimmedUserSearchQuery =
keyIndex !== -1 && fieldKey ? textInputValue.substring(0, keyIndex + fieldKey.length + 1) : getQueryWithoutAutocompletedPart(textInputValue);
Comment on lines +246 to +250

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic is inconsistent with search router. Later issue created:
Search - Not able to select multiple users with comma, when use Reports tab search page

More details about the root cause: #79695 (comment)

const newSearchQuery = `${trimmedUserSearchQuery}${sanitizeSearchValue(item.searchQuery)}\u00A0`;

onSearchQueryChange(newSearchQuery);
setSelection({start: newSearchQuery.length, end: newSearchQuery.length});

Expand Down
24 changes: 21 additions & 3 deletions src/components/Search/SearchRouter/SearchRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {AnimatedTextInputRef} from '@components/RNTextInput';
import type {GetAdditionalSectionsCallback} from '@components/Search/SearchAutocompleteList';
import SearchAutocompleteList from '@components/Search/SearchAutocompleteList';
import SearchInputSelectionWrapper from '@components/Search/SearchInputSelectionWrapper';
import type {SearchQueryString} from '@components/Search/types';
import type {SearchFilterKey, SearchQueryString} from '@components/Search/types';
import type {SearchQueryItem} from '@components/SelectionList/Search/SearchQueryListItem';
import {isSearchQueryItem} from '@components/SelectionList/Search/SearchQueryListItem';
import type {SelectionListHandle} from '@components/SelectionList/types';
Expand All @@ -34,7 +34,7 @@ import Navigation from '@navigation/Navigation';
import type {ReportsSplitNavigatorParamList} from '@navigation/types';
import variables from '@styles/variables';
import {navigateToAndOpenReport, searchInServer} from '@userActions/Report';
import CONST from '@src/CONST';
import CONST, {CONTINUATION_DETECTION_SEARCH_FILTER_KEYS} from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
Expand Down Expand Up @@ -349,7 +349,25 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
timestamp: endTime,
});
} else if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) {
const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue);
const fieldKey = item.mapKey?.includes(':') ? item.mapKey.split(':').at(0) : item.mapKey;
const isNameField = fieldKey && CONTINUATION_DETECTION_SEARCH_FILTER_KEYS.includes(fieldKey as SearchFilterKey);

let trimmedUserSearchQuery;
if (isNameField && fieldKey) {
const fieldPattern = `${fieldKey}:`;
const keyIndex = textInputValue.toLowerCase().lastIndexOf(fieldPattern.toLowerCase());

if (keyIndex !== -1) {
trimmedUserSearchQuery = textInputValue.substring(0, keyIndex + fieldPattern.length);
} else {
trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue);
}
} else {
const keyIndex = fieldKey ? textInputValue.toLowerCase().lastIndexOf(`${fieldKey}:`) : -1;
trimmedUserSearchQuery =
keyIndex !== -1 && fieldKey ? textInputValue.substring(0, keyIndex + fieldKey.length + 1) : getQueryWithoutAutocompletedPart(textInputValue);
}

const newSearchQuery = `${trimmedUserSearchQuery}${sanitizeSearchValue(item.searchQuery)}\u00A0`;
onSearchQueryChange(newSearchQuery, true);
setSelection({start: newSearchQuery.length, end: newSearchQuery.length});
Expand Down
229 changes: 229 additions & 0 deletions tests/unit/SearchAutocompleteParserTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,10 +249,239 @@ const tests = [
},
];

const nameFieldContinuationTests = [
{
query: 'to:John Smi',
expected: {
autocomplete: null,
ranges: [{key: 'to', value: 'John', start: 3, length: 4}],
},
description: 'Basic partial name - parser should return null autocomplete for continuation text',
},
{
query: 'from:Jane Do',
expected: {
autocomplete: null,
ranges: [{key: 'from', value: 'Jane', start: 5, length: 4}],
},
description: 'From field partial name - parser should return null autocomplete for continuation text',
},
{
query: 'assignee:Bob Mar',
expected: {
autocomplete: null,
ranges: [{key: 'assignee', value: 'Bob', start: 9, length: 3}],
},
description: 'Assignee field partial name - parser should return null autocomplete for continuation text',
},
{
query: 'payer:Alice Wind',
expected: {
autocomplete: null,
ranges: [{key: 'payer', value: 'Alice', start: 6, length: 5}],
},
description: 'Payer field partial name - parser should return null autocomplete for continuation text',
},
{
query: 'exporter:Charlie Bro',
expected: {
autocomplete: null,
ranges: [{key: 'exporter', value: 'Charlie', start: 9, length: 7}],
},
description: 'Exporter field partial name - parser should return null autocomplete for continuation text',
},
{
query: 'to:John Smith Doe',
expected: {
autocomplete: null,
ranges: [{key: 'to', value: 'John', start: 3, length: 4}],
},
description: 'Multiple word continuation - parser should only parse first token, rest is free text',
},
{
query: 'from:Mary Jane Wat',
expected: {
autocomplete: null,
ranges: [{key: 'from', value: 'Mary', start: 5, length: 4}],
},
description: 'Multiple word continuation with partial last name - parser should only parse first token',
},
{
query: 'to:John Smi',
expected: {
autocomplete: null,
ranges: [{key: 'to', value: 'John', start: 3, length: 4}],
},
description: 'Multiple spaces before continuation text',
},
{
query: 'to:John\tSmi',
expected: {
autocomplete: null,
ranges: [{key: 'to', value: 'John', start: 3, length: 4}],
},
description: 'Tab character before continuation text',
},
{
query: 'category:Travel Exp',
expected: {
autocomplete: null,
ranges: [{key: 'category', value: 'Travel', start: 9, length: 6}],
},
description: 'Non-name field with space - parser treats space as separator, continuation logic applies in UI',
},
{
query: 'tag:Office Sup',
expected: {
autocomplete: null,
ranges: [{key: 'tag', value: 'Office', start: 4, length: 6}],
},
description: 'Tag field with space - parser treats space as separator, continuation logic applies in UI',
},
{
query: 'type:expense to:John Smi amount>100',
expected: {
autocomplete: null,
ranges: [
{key: 'type', value: 'expense', start: 5, length: 7},
{key: 'to', value: 'John', start: 16, length: 4},
],
},
description: 'Complex query with name field continuation should return null autocomplete',
},
{
query: 'from:Jane Do category:Travel',
expected: {
autocomplete: {
key: 'category',
value: 'Travel',
start: 22,
length: 6,
},
ranges: [
{key: 'from', value: 'Jane', start: 5, length: 4},
{key: 'category', value: 'Travel', start: 22, length: 6},
],
},
description: 'Mixed query with name continuation and other field should autocomplete the other field',
},
{
query: 'to:John',
expected: {
autocomplete: {
key: 'to',
value: 'John',
start: 3,
length: 4,
},
ranges: [{key: 'to', value: 'John', start: 3, length: 4}],
},
description: 'Complete single name should still provide autocomplete',
},
{
query: 'to:"John Smith"',
expected: {
autocomplete: {
key: 'to',
value: 'John Smith',
start: 3,
length: 12,
},
ranges: [{key: 'to', value: 'John Smith', start: 3, length: 12}],
},
description: 'Quoted complete name should provide autocomplete',
},
{
query: "to:John O'Con",
expected: {
autocomplete: null,
ranges: [{key: 'to', value: 'John', start: 3, length: 4}],
},
description: 'Name continuation with apostrophe should return null autocomplete',
},
{
query: 'to:John-Paul Smi',
expected: {
autocomplete: null,
ranges: [{key: 'to', value: 'John-Paul', start: 3, length: 9}],
},
description: 'Hyphenated first name with continuation should return null autocomplete',
},
{
query: 'to:John Smi',
expected: {
autocomplete: null,
ranges: [{key: 'to', value: 'John', start: 3, length: 4}],
},
description: 'Original issue scenario - to:John Smi should return null autocomplete for continuation detection',
},
{
query: 'to:FirstName PartialLastName',
expected: {
autocomplete: null,
ranges: [{key: 'to', value: 'FirstName', start: 3, length: 9}],
},
description: 'Test case scenario - to:FirstName PartialLastName should return null autocomplete',
},
{
query: 'from:Alice Bob',
expected: {
autocomplete: null,
ranges: [{key: 'from', value: 'Alice', start: 5, length: 5}],
},
description: 'From field with two names should return null autocomplete for continuation',
},
{
query: 'assignee:Manager Partial',
expected: {
autocomplete: null,
ranges: [{key: 'assignee', value: 'Manager', start: 9, length: 7}],
},
description: 'Assignee field with partial second name should return null autocomplete',
},
{
query: 'to:John,Jane',
expected: {
autocomplete: {
key: 'to',
value: 'Jane',
start: 8,
length: 4,
},
ranges: [
{key: 'to', value: 'John', start: 3, length: 4},
{key: 'to', value: 'Jane', start: 8, length: 4},
],
},
description: 'Comma-separated names should provide autocomplete for last value',
},
{
query: 'to:"John Smith"',
expected: {
autocomplete: {
key: 'to',
value: 'John Smith',
start: 3,
length: 12,
},
ranges: [{key: 'to', value: 'John Smith', start: 3, length: 12}],
},
description: 'Quoted full name should provide autocomplete normally',
},
];

describe('autocomplete parser', () => {
test.each(tests)(`parsing: $query`, ({query, expected}) => {
const result = parse(query) as SearchQueryJSON;

expect(result).toEqual(expected);
});
});

describe('autocomplete parser - name field continuation detection', () => {
test.each(nameFieldContinuationTests)(`$description: $query`, ({query, expected}) => {
const result = parse(query) as SearchQueryJSON;

expect(result).toEqual(expected);
});
});
Loading