Skip to content

Commit 5f7de00

Browse files
authored
Merge pull request Expensify#67979 from lorretheboy/fix/67375
2 parents 9b5aca3 + ea80fd3 commit 5f7de00

5 files changed

Lines changed: 302 additions & 8 deletions

File tree

src/CONST/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {sub as dateSubtract} from 'date-fns/sub';
44
import Config from 'react-native-config';
55
import * as KeyCommand from 'react-native-key-command';
66
import type {ValueOf} from 'type-fest';
7+
import type {SearchFilterKey} from '@components/Search/types';
78
import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types';
89
import type {MileageRate} from '@libs/DistanceRequestUtils';
910
import BankAccount from '@libs/models/BankAccount';
@@ -6979,6 +6980,14 @@ const CONST = {
69796980
},
69806981
} as const;
69816982

6983+
const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS = [
6984+
CONST.SEARCH.SYNTAX_FILTER_KEYS.TO,
6985+
CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM,
6986+
CONST.SEARCH.SYNTAX_FILTER_KEYS.ASSIGNEE,
6987+
CONST.SEARCH.SYNTAX_FILTER_KEYS.PAYER,
6988+
CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER,
6989+
] as SearchFilterKey[];
6990+
69826991
type Country = keyof typeof CONST.ALL_COUNTRIES;
69836992

69846993
type IOUType = ValueOf<typeof CONST.IOU.TYPE>;
@@ -6992,4 +7001,6 @@ type CancellationType = ValueOf<typeof CONST.CANCELLATION_TYPE>;
69927001

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

7004+
export {CONTINUATION_DETECTION_SEARCH_FILTER_KEYS};
7005+
69957006
export default CONST;

src/components/Search/SearchAutocompleteList.tsx

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {buildSearchQueryJSON, buildUserReadableQueryString, getQueryWithoutFilte
3535
import {getDatePresets} from '@libs/SearchUIUtils';
3636
import StringUtils from '@libs/StringUtils';
3737
import Timing from '@userActions/Timing';
38-
import CONST from '@src/CONST';
38+
import CONST, {CONTINUATION_DETECTION_SEARCH_FILTER_KEYS} from '@src/CONST';
3939
import ONYXKEYS from '@src/ONYXKEYS';
4040
import type {CardFeeds, CardList, PersonalDetailsList, Policy, Report} from '@src/types/onyx';
4141
import {getEmptyObject} from '@src/types/utils/EmptyObject';
@@ -255,8 +255,22 @@ function SearchAutocompleteList(
255255

256256
const autocompleteSuggestions = useMemo<AutocompleteItemData[]>(() => {
257257
const {autocomplete, ranges = []} = autocompleteParsedQuery ?? {};
258-
const autocompleteKey = autocomplete?.key;
259-
const autocompleteValue = autocomplete?.value ?? '';
258+
259+
let autocompleteKey = autocomplete?.key;
260+
let autocompleteValue = autocomplete?.value ?? '';
261+
262+
if (!autocomplete && ranges.length > 0) {
263+
const lastRange = ranges.at(ranges.length - 1);
264+
if (lastRange && CONTINUATION_DETECTION_SEARCH_FILTER_KEYS.includes(lastRange.key)) {
265+
const afterLastRange = autocompleteQueryValue.substring(lastRange.start + lastRange.length);
266+
const continuationMatch = afterLastRange.match(/^\s+(\w+)/);
267+
268+
if (continuationMatch) {
269+
autocompleteKey = lastRange.key;
270+
autocompleteValue = `${lastRange.value} ${continuationMatch[1]}`;
271+
}
272+
}
273+
}
260274

261275
const alreadyAutocompletedKeys = ranges
262276
.filter((range) => {
@@ -458,6 +472,7 @@ function SearchAutocompleteList(
458472
}
459473
}, [
460474
autocompleteParsedQuery,
475+
autocompleteQueryValue,
461476
tagAutocompleteList,
462477
recentTagsAutocompleteList,
463478
categoryAutocompleteList,
@@ -664,7 +679,23 @@ function SearchAutocompleteList(
664679
return;
665680
}
666681

667-
const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(autocompleteQueryValue);
682+
const fieldKey = focusedItem.mapKey?.includes(':') ? focusedItem.mapKey.split(':').at(0) : focusedItem.mapKey;
683+
const isNameField = fieldKey && CONTINUATION_DETECTION_SEARCH_FILTER_KEYS.includes(fieldKey as SearchFilterKey);
684+
685+
let trimmedUserSearchQuery;
686+
if (isNameField && fieldKey) {
687+
const fieldPattern = `${fieldKey}:`;
688+
const keyIndex = autocompleteQueryValue.toLowerCase().lastIndexOf(fieldPattern.toLowerCase());
689+
690+
if (keyIndex !== -1) {
691+
trimmedUserSearchQuery = autocompleteQueryValue.substring(0, keyIndex + fieldPattern.length);
692+
} else {
693+
trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(autocompleteQueryValue);
694+
}
695+
} else {
696+
trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(autocompleteQueryValue);
697+
}
698+
668699
setTextQuery(`${trimmedUserSearchQuery}${sanitizeSearchValue(focusedItem.searchQuery)}\u00A0`);
669700
updateAutocompleteSubstitutions(focusedItem);
670701
},

src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,13 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
243243
}
244244

245245
if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) {
246-
const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue);
246+
const fieldKey = item.mapKey?.includes(':') ? item.mapKey.split(':').at(0) : item.mapKey;
247+
const keyIndex = fieldKey ? textInputValue.toLowerCase().lastIndexOf(`${fieldKey}:`) : -1;
248+
249+
const trimmedUserSearchQuery =
250+
keyIndex !== -1 && fieldKey ? textInputValue.substring(0, keyIndex + fieldKey.length + 1) : getQueryWithoutAutocompletedPart(textInputValue);
247251
const newSearchQuery = `${trimmedUserSearchQuery}${sanitizeSearchValue(item.searchQuery)}\u00A0`;
252+
248253
onSearchQueryChange(newSearchQuery);
249254
setSelection({start: newSearchQuery.length, end: newSearchQuery.length});
250255

src/components/Search/SearchRouter/SearchRouter.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {AnimatedTextInputRef} from '@components/RNTextInput';
1111
import type {GetAdditionalSectionsCallback} from '@components/Search/SearchAutocompleteList';
1212
import SearchAutocompleteList from '@components/Search/SearchAutocompleteList';
1313
import SearchInputSelectionWrapper from '@components/Search/SearchInputSelectionWrapper';
14-
import type {SearchQueryString} from '@components/Search/types';
14+
import type {SearchFilterKey, SearchQueryString} from '@components/Search/types';
1515
import type {SearchQueryItem} from '@components/SelectionList/Search/SearchQueryListItem';
1616
import {isSearchQueryItem} from '@components/SelectionList/Search/SearchQueryListItem';
1717
import type {SelectionListHandle} from '@components/SelectionList/types';
@@ -34,7 +34,7 @@ import Navigation from '@navigation/Navigation';
3434
import type {ReportsSplitNavigatorParamList} from '@navigation/types';
3535
import variables from '@styles/variables';
3636
import {navigateToAndOpenReport, searchInServer} from '@userActions/Report';
37-
import CONST from '@src/CONST';
37+
import CONST, {CONTINUATION_DETECTION_SEARCH_FILTER_KEYS} from '@src/CONST';
3838
import ONYXKEYS from '@src/ONYXKEYS';
3939
import ROUTES from '@src/ROUTES';
4040
import SCREENS from '@src/SCREENS';
@@ -349,7 +349,25 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
349349
timestamp: endTime,
350350
});
351351
} else if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) {
352-
const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue);
352+
const fieldKey = item.mapKey?.includes(':') ? item.mapKey.split(':').at(0) : item.mapKey;
353+
const isNameField = fieldKey && CONTINUATION_DETECTION_SEARCH_FILTER_KEYS.includes(fieldKey as SearchFilterKey);
354+
355+
let trimmedUserSearchQuery;
356+
if (isNameField && fieldKey) {
357+
const fieldPattern = `${fieldKey}:`;
358+
const keyIndex = textInputValue.toLowerCase().lastIndexOf(fieldPattern.toLowerCase());
359+
360+
if (keyIndex !== -1) {
361+
trimmedUserSearchQuery = textInputValue.substring(0, keyIndex + fieldPattern.length);
362+
} else {
363+
trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue);
364+
}
365+
} else {
366+
const keyIndex = fieldKey ? textInputValue.toLowerCase().lastIndexOf(`${fieldKey}:`) : -1;
367+
trimmedUserSearchQuery =
368+
keyIndex !== -1 && fieldKey ? textInputValue.substring(0, keyIndex + fieldKey.length + 1) : getQueryWithoutAutocompletedPart(textInputValue);
369+
}
370+
353371
const newSearchQuery = `${trimmedUserSearchQuery}${sanitizeSearchValue(item.searchQuery)}\u00A0`;
354372
onSearchQueryChange(newSearchQuery, true);
355373
setSelection({start: newSearchQuery.length, end: newSearchQuery.length});

tests/unit/SearchAutocompleteParserTest.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,239 @@ const tests = [
249249
},
250250
];
251251

252+
const nameFieldContinuationTests = [
253+
{
254+
query: 'to:John Smi',
255+
expected: {
256+
autocomplete: null,
257+
ranges: [{key: 'to', value: 'John', start: 3, length: 4}],
258+
},
259+
description: 'Basic partial name - parser should return null autocomplete for continuation text',
260+
},
261+
{
262+
query: 'from:Jane Do',
263+
expected: {
264+
autocomplete: null,
265+
ranges: [{key: 'from', value: 'Jane', start: 5, length: 4}],
266+
},
267+
description: 'From field partial name - parser should return null autocomplete for continuation text',
268+
},
269+
{
270+
query: 'assignee:Bob Mar',
271+
expected: {
272+
autocomplete: null,
273+
ranges: [{key: 'assignee', value: 'Bob', start: 9, length: 3}],
274+
},
275+
description: 'Assignee field partial name - parser should return null autocomplete for continuation text',
276+
},
277+
{
278+
query: 'payer:Alice Wind',
279+
expected: {
280+
autocomplete: null,
281+
ranges: [{key: 'payer', value: 'Alice', start: 6, length: 5}],
282+
},
283+
description: 'Payer field partial name - parser should return null autocomplete for continuation text',
284+
},
285+
{
286+
query: 'exporter:Charlie Bro',
287+
expected: {
288+
autocomplete: null,
289+
ranges: [{key: 'exporter', value: 'Charlie', start: 9, length: 7}],
290+
},
291+
description: 'Exporter field partial name - parser should return null autocomplete for continuation text',
292+
},
293+
{
294+
query: 'to:John Smith Doe',
295+
expected: {
296+
autocomplete: null,
297+
ranges: [{key: 'to', value: 'John', start: 3, length: 4}],
298+
},
299+
description: 'Multiple word continuation - parser should only parse first token, rest is free text',
300+
},
301+
{
302+
query: 'from:Mary Jane Wat',
303+
expected: {
304+
autocomplete: null,
305+
ranges: [{key: 'from', value: 'Mary', start: 5, length: 4}],
306+
},
307+
description: 'Multiple word continuation with partial last name - parser should only parse first token',
308+
},
309+
{
310+
query: 'to:John Smi',
311+
expected: {
312+
autocomplete: null,
313+
ranges: [{key: 'to', value: 'John', start: 3, length: 4}],
314+
},
315+
description: 'Multiple spaces before continuation text',
316+
},
317+
{
318+
query: 'to:John\tSmi',
319+
expected: {
320+
autocomplete: null,
321+
ranges: [{key: 'to', value: 'John', start: 3, length: 4}],
322+
},
323+
description: 'Tab character before continuation text',
324+
},
325+
{
326+
query: 'category:Travel Exp',
327+
expected: {
328+
autocomplete: null,
329+
ranges: [{key: 'category', value: 'Travel', start: 9, length: 6}],
330+
},
331+
description: 'Non-name field with space - parser treats space as separator, continuation logic applies in UI',
332+
},
333+
{
334+
query: 'tag:Office Sup',
335+
expected: {
336+
autocomplete: null,
337+
ranges: [{key: 'tag', value: 'Office', start: 4, length: 6}],
338+
},
339+
description: 'Tag field with space - parser treats space as separator, continuation logic applies in UI',
340+
},
341+
{
342+
query: 'type:expense to:John Smi amount>100',
343+
expected: {
344+
autocomplete: null,
345+
ranges: [
346+
{key: 'type', value: 'expense', start: 5, length: 7},
347+
{key: 'to', value: 'John', start: 16, length: 4},
348+
],
349+
},
350+
description: 'Complex query with name field continuation should return null autocomplete',
351+
},
352+
{
353+
query: 'from:Jane Do category:Travel',
354+
expected: {
355+
autocomplete: {
356+
key: 'category',
357+
value: 'Travel',
358+
start: 22,
359+
length: 6,
360+
},
361+
ranges: [
362+
{key: 'from', value: 'Jane', start: 5, length: 4},
363+
{key: 'category', value: 'Travel', start: 22, length: 6},
364+
],
365+
},
366+
description: 'Mixed query with name continuation and other field should autocomplete the other field',
367+
},
368+
{
369+
query: 'to:John',
370+
expected: {
371+
autocomplete: {
372+
key: 'to',
373+
value: 'John',
374+
start: 3,
375+
length: 4,
376+
},
377+
ranges: [{key: 'to', value: 'John', start: 3, length: 4}],
378+
},
379+
description: 'Complete single name should still provide autocomplete',
380+
},
381+
{
382+
query: 'to:"John Smith"',
383+
expected: {
384+
autocomplete: {
385+
key: 'to',
386+
value: 'John Smith',
387+
start: 3,
388+
length: 12,
389+
},
390+
ranges: [{key: 'to', value: 'John Smith', start: 3, length: 12}],
391+
},
392+
description: 'Quoted complete name should provide autocomplete',
393+
},
394+
{
395+
query: "to:John O'Con",
396+
expected: {
397+
autocomplete: null,
398+
ranges: [{key: 'to', value: 'John', start: 3, length: 4}],
399+
},
400+
description: 'Name continuation with apostrophe should return null autocomplete',
401+
},
402+
{
403+
query: 'to:John-Paul Smi',
404+
expected: {
405+
autocomplete: null,
406+
ranges: [{key: 'to', value: 'John-Paul', start: 3, length: 9}],
407+
},
408+
description: 'Hyphenated first name with continuation should return null autocomplete',
409+
},
410+
{
411+
query: 'to:John Smi',
412+
expected: {
413+
autocomplete: null,
414+
ranges: [{key: 'to', value: 'John', start: 3, length: 4}],
415+
},
416+
description: 'Original issue scenario - to:John Smi should return null autocomplete for continuation detection',
417+
},
418+
{
419+
query: 'to:FirstName PartialLastName',
420+
expected: {
421+
autocomplete: null,
422+
ranges: [{key: 'to', value: 'FirstName', start: 3, length: 9}],
423+
},
424+
description: 'Test case scenario - to:FirstName PartialLastName should return null autocomplete',
425+
},
426+
{
427+
query: 'from:Alice Bob',
428+
expected: {
429+
autocomplete: null,
430+
ranges: [{key: 'from', value: 'Alice', start: 5, length: 5}],
431+
},
432+
description: 'From field with two names should return null autocomplete for continuation',
433+
},
434+
{
435+
query: 'assignee:Manager Partial',
436+
expected: {
437+
autocomplete: null,
438+
ranges: [{key: 'assignee', value: 'Manager', start: 9, length: 7}],
439+
},
440+
description: 'Assignee field with partial second name should return null autocomplete',
441+
},
442+
{
443+
query: 'to:John,Jane',
444+
expected: {
445+
autocomplete: {
446+
key: 'to',
447+
value: 'Jane',
448+
start: 8,
449+
length: 4,
450+
},
451+
ranges: [
452+
{key: 'to', value: 'John', start: 3, length: 4},
453+
{key: 'to', value: 'Jane', start: 8, length: 4},
454+
],
455+
},
456+
description: 'Comma-separated names should provide autocomplete for last value',
457+
},
458+
{
459+
query: 'to:"John Smith"',
460+
expected: {
461+
autocomplete: {
462+
key: 'to',
463+
value: 'John Smith',
464+
start: 3,
465+
length: 12,
466+
},
467+
ranges: [{key: 'to', value: 'John Smith', start: 3, length: 12}],
468+
},
469+
description: 'Quoted full name should provide autocomplete normally',
470+
},
471+
];
472+
252473
describe('autocomplete parser', () => {
253474
test.each(tests)(`parsing: $query`, ({query, expected}) => {
254475
const result = parse(query) as SearchQueryJSON;
255476

256477
expect(result).toEqual(expected);
257478
});
258479
});
480+
481+
describe('autocomplete parser - name field continuation detection', () => {
482+
test.each(nameFieldContinuationTests)(`$description: $query`, ({query, expected}) => {
483+
const result = parse(query) as SearchQueryJSON;
484+
485+
expect(result).toEqual(expected);
486+
});
487+
});

0 commit comments

Comments
 (0)