From e90556ddfc055271e17482b61960285ebce9a5f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 29 Oct 2024 14:43:47 +0100 Subject: [PATCH 01/17] Revert "Revert "Revert "Revert "Search suffix tree implementation"""" This reverts commit 20af9f625b5e636f3e2cce928d4eb5ab5954f8ee. --- src/CONST.ts | 3 + .../Search/SearchRouter/SearchRouter.tsx | 62 ++++- src/libs/FastSearch.ts | 140 ++++++++++++ src/libs/OptionsListUtils.ts | 38 +++- src/libs/SuffixUkkonenTree/index.ts | 211 ++++++++++++++++++ src/libs/SuffixUkkonenTree/utils.ts | 115 ++++++++++ tests/unit/FastSearchTest.ts | 118 ++++++++++ tests/unit/SuffixUkkonenTreeTest.ts | 63 ++++++ 8 files changed, 736 insertions(+), 14 deletions(-) create mode 100644 src/libs/FastSearch.ts create mode 100644 src/libs/SuffixUkkonenTree/index.ts create mode 100644 src/libs/SuffixUkkonenTree/utils.ts create mode 100644 tests/unit/FastSearchTest.ts create mode 100644 tests/unit/SuffixUkkonenTreeTest.ts diff --git a/src/CONST.ts b/src/CONST.ts index 437ee4e7fd42..871f7730a03d 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1262,6 +1262,9 @@ const CONST = { SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300, RESIZE_DEBOUNCE_TIME: 100, UNREAD_UPDATE_DEBOUNCE_TIME: 300, + SEARCH_CONVERT_SEARCH_VALUES: 'search_convert_search_values', + SEARCH_MAKE_TREE: 'search_make_tree', + SEARCH_BUILD_TREE: 'search_build_tree', SEARCH_FILTER_OPTIONS: 'search_filter_options', USE_DEBOUNCED_STATE_DELAY: 300, }, diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 83d7d5d89b20..6f5481a17983 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -15,6 +15,7 @@ import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import FastSearch from '@libs/FastSearch'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -105,6 +106,49 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return OptionsListUtils.getSearchOptions(options, '', betas ?? []); }, [areOptionsInitialized, betas, options]); + /** + * Builds a suffix tree and returns a function to search in it. + */ + const findInSearchTree = useMemo(() => { + const fastSearch = FastSearch.createFastSearch([ + { + data: searchOptions.personalDetails, + toSearchableString: (option) => { + const displayName = option.participantsList?.[0]?.displayName ?? ''; + return [option.login ?? '', option.login !== displayName ? displayName : ''].join(); + }, + }, + { + data: searchOptions.recentReports, + toSearchableString: (option) => { + const searchStringForTree = [option.text ?? '', option.login ?? '']; + + if (option.isThread) { + if (option.alternateText) { + searchStringForTree.push(option.alternateText); + } + } else if (!!option.isChatRoom || !!option.isPolicyExpenseChat) { + if (option.subtitle) { + searchStringForTree.push(option.subtitle); + } + } + + return searchStringForTree.join(); + }, + }, + ]); + function search(searchInput: string) { + const [personalDetails, recentReports] = fastSearch.search(searchInput); + + return { + personalDetails, + recentReports, + }; + } + + return search; + }, [searchOptions.personalDetails, searchOptions.recentReports]); + const filteredOptions = useMemo(() => { if (debouncedInputValue.trim() === '') { return { @@ -115,15 +159,25 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS); - const newOptions = OptionsListUtils.filterOptions(searchOptions, debouncedInputValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); + const newOptions = findInSearchTree(debouncedInputValue); Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); - return { + const recentReports = newOptions.recentReports.concat(newOptions.personalDetails); + + const userToInvite = OptionsListUtils.pickUserToInvite({ + canInviteUser: true, recentReports: newOptions.recentReports, personalDetails: newOptions.personalDetails, - userToInvite: newOptions.userToInvite, + searchValue: debouncedInputValue, + optionsToExclude: [{login: CONST.EMAIL.NOTIFICATIONS}], + }); + + return { + recentReports, + personalDetails: [], + userToInvite, }; - }, [debouncedInputValue, searchOptions]); + }, [debouncedInputValue, findInSearchTree]); const recentReports: OptionData[] = useMemo(() => { if (debouncedInputValue === '') { diff --git a/src/libs/FastSearch.ts b/src/libs/FastSearch.ts new file mode 100644 index 000000000000..59d28dedd449 --- /dev/null +++ b/src/libs/FastSearch.ts @@ -0,0 +1,140 @@ +/* eslint-disable rulesdir/prefer-at */ +import CONST from '@src/CONST'; +import Timing from './actions/Timing'; +import SuffixUkkonenTree from './SuffixUkkonenTree'; + +type SearchableData = { + /** + * The data that should be searchable + */ + data: T[]; + /** + * A function that generates a string from a data entry. The string's value is used for searching. + * If you have multiple fields that should be searchable, simply concat them to the string and return it. + */ + toSearchableString: (data: T) => string; +}; + +// There are certain characters appear very often in our search data (email addresses), which we don't need to search for. +const charSetToSkip = new Set(['@', '.', '#', '$', '%', '&', '*', '+', '-', '/', ':', ';', '<', '=', '>', '?', '_', '~', '!', ' ']); + +/** + * Creates a new "FastSearch" instance. "FastSearch" uses a suffix tree to search for substrings in a list of strings. + * You can provide multiple datasets. The search results will be returned for each dataset. + * + * Note: Creating a FastSearch instance with a lot of data is computationally expensive. You should create an instance once and reuse it. + * Searches will be very fast though, even with a lot of data. + */ +function createFastSearch(dataSets: Array>) { + Timing.start(CONST.TIMING.SEARCH_CONVERT_SEARCH_VALUES); + const maxNumericListSize = 400_000; + // The user might provide multiple data sets, but internally, the search values will be stored in this one list: + let concatenatedNumericList = new Uint8Array(maxNumericListSize); + // Here we store the index of the data item in the original data list, so we can map the found occurrences back to the original data: + const occurrenceToIndex = new Uint32Array(maxNumericListSize * 4); + // As we are working with ArrayBuffers, we need to keep track of the current offset: + const offset = {value: 1}; + // We store the last offset for a dataSet, so we can map the found occurrences to the correct dataSet: + const listOffsets: number[] = []; + + for (const {data, toSearchableString} of dataSets) { + // Performance critical: the array parameters are passed by reference, so we don't have to create new arrays every time: + dataToNumericRepresentation(concatenatedNumericList, occurrenceToIndex, offset, {data, toSearchableString}); + listOffsets.push(offset.value); + } + concatenatedNumericList[offset.value++] = SuffixUkkonenTree.END_CHAR_CODE; + listOffsets[listOffsets.length - 1] = offset.value; + Timing.end(CONST.TIMING.SEARCH_CONVERT_SEARCH_VALUES); + + // The list might be larger than necessary, so we clamp it to the actual size: + concatenatedNumericList = concatenatedNumericList.slice(0, offset.value); + + // Create & build the suffix tree: + Timing.start(CONST.TIMING.SEARCH_MAKE_TREE); + const tree = SuffixUkkonenTree.makeTree(concatenatedNumericList); + Timing.end(CONST.TIMING.SEARCH_MAKE_TREE); + + Timing.start(CONST.TIMING.SEARCH_BUILD_TREE); + tree.build(); + Timing.end(CONST.TIMING.SEARCH_BUILD_TREE); + + /** + * Searches for the given input and returns results for each dataset. + */ + function search(searchInput: string): T[][] { + const cleanedSearchString = cleanString(searchInput); + const {numeric} = SuffixUkkonenTree.stringToNumeric(cleanedSearchString, { + charSetToSkip, + // stringToNumeric might return a list that is larger than necessary, so we clamp it to the actual size + // (otherwise the search could fail as we include in our search empty array values): + clamp: true, + }); + const result = tree.findSubstring(Array.from(numeric)); + + const resultsByDataSet = Array.from({length: dataSets.length}, () => new Set()); + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < result.length; i++) { + const occurrenceIndex = result[i]; + const itemIndexInDataSet = occurrenceToIndex[occurrenceIndex]; + const dataSetIndex = listOffsets.findIndex((listOffset) => occurrenceIndex < listOffset); + + if (dataSetIndex === -1) { + throw new Error(`[FastSearch] The occurrence index ${occurrenceIndex} is not in any dataset`); + } + const item = dataSets[dataSetIndex].data[itemIndexInDataSet]; + if (!item) { + throw new Error(`[FastSearch] The item with index ${itemIndexInDataSet} in dataset ${dataSetIndex} is not defined`); + } + resultsByDataSet[dataSetIndex].add(item); + } + + return resultsByDataSet.map((set) => Array.from(set)); + } + + return { + search, + }; +} + +/** + * The suffix tree can only store string like values, and internally stores those as numbers. + * This function converts the user data (which are most likely objects) to a numeric representation. + * Additionally a list of the original data and their index position in the numeric list is created, which is used to map the found occurrences back to the original data. + */ +function dataToNumericRepresentation(concatenatedNumericList: Uint8Array, occurrenceToIndex: Uint32Array, offset: {value: number}, {data, toSearchableString}: SearchableData): void { + data.forEach((option, index) => { + const searchStringForTree = toSearchableString(option); + const cleanedSearchStringForTree = cleanString(searchStringForTree); + + if (cleanedSearchStringForTree.length === 0) { + return; + } + + SuffixUkkonenTree.stringToNumeric(cleanedSearchStringForTree, { + charSetToSkip, + out: { + outArray: concatenatedNumericList, + offset, + outOccurrenceToIndex: occurrenceToIndex, + index, + }, + }); + // eslint-disable-next-line no-param-reassign + occurrenceToIndex[offset.value] = index; + // eslint-disable-next-line no-param-reassign + concatenatedNumericList[offset.value++] = SuffixUkkonenTree.DELIMITER_CHAR_CODE; + }); +} + +/** + * Everything in the tree is treated as lowercase. + */ +function cleanString(input: string) { + return input.toLowerCase(); +} + +const FastSearch = { + createFastSearch, +}; + +export default FastSearch; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 497a2d33cf56..f414d2328ef6 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2477,6 +2477,31 @@ function getPersonalDetailSearchTerms(item: Partial) { function getCurrentUserSearchTerms(item: ReportUtils.OptionData) { return [item.text ?? '', item.login ?? '', item.login?.replace(CONST.EMAIL_SEARCH_REGEX, '') ?? '']; } + +type PickUserToInviteParams = { + canInviteUser: boolean; + recentReports: ReportUtils.OptionData[]; + personalDetails: ReportUtils.OptionData[]; + searchValue: string; + config?: FilterOptionsConfig; + optionsToExclude: Option[]; +}; + +const pickUserToInvite = ({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude}: PickUserToInviteParams) => { + let userToInvite = null; + if (canInviteUser) { + if (recentReports.length === 0 && personalDetails.length === 0) { + userToInvite = getUserToInviteOption({ + searchValue, + selectedOptions: config?.selectedOptions, + optionsToExclude, + }); + } + } + + return userToInvite; +}; + /** * Filters options based on the search input value */ @@ -2564,16 +2589,7 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt recentReports = orderOptions(recentReports, searchValue); } - let userToInvite = null; - if (canInviteUser) { - if (recentReports.length === 0 && personalDetails.length === 0) { - userToInvite = getUserToInviteOption({ - searchValue, - selectedOptions: config?.selectedOptions, - optionsToExclude, - }); - } - } + const userToInvite = pickUserToInvite({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude}); if (maxRecentReportsToShow > 0 && recentReports.length > maxRecentReportsToShow) { recentReports.splice(maxRecentReportsToShow); @@ -2643,6 +2659,7 @@ export { formatMemberForList, formatSectionsFromSearchTerm, getShareLogOptions, + orderOptions, filterOptions, createOptionList, createOptionFromReport, @@ -2657,6 +2674,7 @@ export { shouldUseBoldText, getAttendeeOptions, getAlternateText, + pickUserToInvite, hasReportErrors, }; diff --git a/src/libs/SuffixUkkonenTree/index.ts b/src/libs/SuffixUkkonenTree/index.ts new file mode 100644 index 000000000000..bcefd1008493 --- /dev/null +++ b/src/libs/SuffixUkkonenTree/index.ts @@ -0,0 +1,211 @@ +/* eslint-disable rulesdir/prefer-at */ +// .at() has a performance overhead we explicitly want to avoid here + +/* eslint-disable no-continue */ +import {ALPHABET_SIZE, DELIMITER_CHAR_CODE, END_CHAR_CODE, SPECIAL_CHAR_CODE, stringToNumeric} from './utils'; + +/** + * This implements a suffix tree using Ukkonen's algorithm. + * A good visualization to learn about the algorithm can be found here: https://brenden.github.io/ukkonen-animation/ + * A good video explaining Ukkonen's algorithm can be found here: https://www.youtube.com/watch?v=ALEV0Hc5dDk + * Note: This implementation is optimized for performance, not necessarily for readability. + * + * You probably don't want to use this directly, but rather use @libs/FastSearch.ts as a easy to use wrapper around this. + */ + +/** + * Creates a new tree instance that can be used to build a suffix tree and search in it. + * The input is a numeric representation of the search string, which can be created using {@link stringToNumeric}. + * Separate search values must be separated by the {@link DELIMITER_CHAR_CODE}. The search string must end with the {@link END_CHAR_CODE}. + * + * The tree will be built using the Ukkonen's algorithm: https://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf + */ +function makeTree(numericSearchValues: Uint8Array) { + // Every leaf represents a suffix. There can't be more than n suffixes. + // Every internal node has to have at least 2 children. So the total size of ukkonen tree is not bigger than 2n - 1. + // + 1 is because an extra character at the beginning to offset the 1-based indexing. + const maxNodes = 2 * numericSearchValues.length + 1; + /* + This array represents all internal nodes in the suffix tree. + When building this tree, we'll be given a character in the string, and we need to be able to lookup in constant time + if there's any edge connected to a node starting with that character. For example, given a tree like this: + + root + / | \ + a b c + + and the next character in our string is 'd', we need to be able do check if any of the edges from the root node + start with the letter 'd', without looping through all the edges. + + To accomplish this, each node gets an array matching the alphabet size. + So you can imagine if our alphabet was just [a,b,c,d], then each node would get an array like [0,0,0,0]. + If we add an edge starting with 'a', then the root node would be [1,0,0,0] + So given an arbitrary letter such as 'd', then we can take the position of that letter in its alphabet (position 3 in our example) + and check whether that index in the array is 0 or 1. If it's a 1, then there's an edge starting with the letter 'd'. + + Note that for efficiency, all nodes are stored in a single flat array. That's how we end up with (maxNodes * alphabet_size). + In the example of a 4-character alphabet, we'd have an array like this: + + root root.left root.right last possible node + / \ / \ / \ / \ + [0,0,0,0, 0,0,0,0, 0,0,0,0, ................. 0,0,0,0] + */ + const transitionNodes = new Uint32Array(maxNodes * ALPHABET_SIZE); + + // Storing the range of the original string that each node represents: + const rangeStart = new Uint32Array(maxNodes); + const rangeEnd = new Uint32Array(maxNodes); + + const parent = new Uint32Array(maxNodes); + const suffixLink = new Uint32Array(maxNodes); + + let currentNode = 1; + let currentPosition = 1; + let nodeCounter = 3; + let currentIndex = 1; + + function initializeTree() { + rangeEnd.fill(numericSearchValues.length); + rangeEnd[1] = 0; + rangeEnd[2] = 0; + suffixLink[1] = 2; + for (let i = 0; i < ALPHABET_SIZE; ++i) { + transitionNodes[ALPHABET_SIZE * 2 + i] = 1; + } + } + + function processCharacter(char: number) { + // eslint-disable-next-line no-constant-condition + while (true) { + if (rangeEnd[currentNode] < currentPosition) { + if (transitionNodes[currentNode * ALPHABET_SIZE + char] === 0) { + createNewLeaf(char); + continue; + } + currentNode = transitionNodes[currentNode * ALPHABET_SIZE + char]; + currentPosition = rangeStart[currentNode]; + } + if (currentPosition === 0 || char === numericSearchValues[currentPosition]) { + currentPosition++; + } else { + splitEdge(char); + continue; + } + break; + } + } + + function createNewLeaf(c: number) { + transitionNodes[currentNode * ALPHABET_SIZE + c] = nodeCounter; + rangeStart[nodeCounter] = currentIndex; + parent[nodeCounter++] = currentNode; + currentNode = suffixLink[currentNode]; + + currentPosition = rangeEnd[currentNode] + 1; + } + + function splitEdge(c: number) { + rangeStart[nodeCounter] = rangeStart[currentNode]; + rangeEnd[nodeCounter] = currentPosition - 1; + parent[nodeCounter] = parent[currentNode]; + + transitionNodes[nodeCounter * ALPHABET_SIZE + numericSearchValues[currentPosition]] = currentNode; + transitionNodes[nodeCounter * ALPHABET_SIZE + c] = nodeCounter + 1; + rangeStart[nodeCounter + 1] = currentIndex; + parent[nodeCounter + 1] = nodeCounter; + rangeStart[currentNode] = currentPosition; + parent[currentNode] = nodeCounter; + + transitionNodes[parent[nodeCounter] * ALPHABET_SIZE + numericSearchValues[rangeStart[nodeCounter]]] = nodeCounter; + nodeCounter += 2; + handleDescent(nodeCounter); + } + + function handleDescent(latestNodeIndex: number) { + currentNode = suffixLink[parent[latestNodeIndex - 2]]; + currentPosition = rangeStart[latestNodeIndex - 2]; + while (currentPosition <= rangeEnd[latestNodeIndex - 2]) { + currentNode = transitionNodes[currentNode * ALPHABET_SIZE + numericSearchValues[currentPosition]]; + currentPosition += rangeEnd[currentNode] - rangeStart[currentNode] + 1; + } + if (currentPosition === rangeEnd[latestNodeIndex - 2] + 1) { + suffixLink[latestNodeIndex - 2] = currentNode; + } else { + suffixLink[latestNodeIndex - 2] = latestNodeIndex; + } + currentPosition = rangeEnd[currentNode] - (currentPosition - rangeEnd[latestNodeIndex - 2]) + 2; + } + + function build() { + initializeTree(); + for (currentIndex = 1; currentIndex < numericSearchValues.length; ++currentIndex) { + const c = numericSearchValues[currentIndex]; + processCharacter(c); + } + } + + /** + * Returns all occurrences of the given (sub)string in the input string. + * + * You can think of the tree that we create as a big string that looks like this: + * + * "banana$pancake$apple|" + * The example delimiter character '$' is used to separate the different strings. + * The end character '|' is used to indicate the end of our search string. + * + * This function will return the index(es) of found occurrences within this big string. + * So, when searching for "an", it would return [1, 3, 8]. + */ + function findSubstring(searchValue: number[]) { + const occurrences: number[] = []; + + function dfs(node: number, depth: number) { + const leftRange = rangeStart[node]; + const rightRange = rangeEnd[node]; + const rangeLen = node === 1 ? 0 : rightRange - leftRange + 1; + + for (let i = 0; i < rangeLen && depth + i < searchValue.length && leftRange + i < numericSearchValues.length; i++) { + if (searchValue[depth + i] !== numericSearchValues[leftRange + i]) { + return; + } + } + + let isLeaf = true; + for (let i = 0; i < ALPHABET_SIZE; ++i) { + const tNode = transitionNodes[node * ALPHABET_SIZE + i]; + + // Search speed optimization: don't go through the edge if it's different than the next char: + const correctChar = depth + rangeLen >= searchValue.length || i === searchValue[depth + rangeLen]; + + if (tNode !== 0 && tNode !== 1 && correctChar) { + isLeaf = false; + dfs(tNode, depth + rangeLen); + } + } + + if (isLeaf && depth + rangeLen >= searchValue.length) { + occurrences.push(numericSearchValues.length - (depth + rangeLen) + 1); + } + } + + dfs(1, 0); + return occurrences; + } + + return { + build, + findSubstring, + }; +} + +const SuffixUkkonenTree = { + makeTree, + + // Re-exported from utils: + DELIMITER_CHAR_CODE, + SPECIAL_CHAR_CODE, + END_CHAR_CODE, + stringToNumeric, +}; + +export default SuffixUkkonenTree; diff --git a/src/libs/SuffixUkkonenTree/utils.ts b/src/libs/SuffixUkkonenTree/utils.ts new file mode 100644 index 000000000000..96ee35b15796 --- /dev/null +++ b/src/libs/SuffixUkkonenTree/utils.ts @@ -0,0 +1,115 @@ +/* eslint-disable rulesdir/prefer-at */ // .at() has a performance overhead we explicitly want to avoid here +/* eslint-disable no-continue */ + +const CHAR_CODE_A = 'a'.charCodeAt(0); +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz'; +const LETTER_ALPHABET_SIZE = ALPHABET.length; +const ALPHABET_SIZE = LETTER_ALPHABET_SIZE + 3; // +3: special char, delimiter char, end char +const SPECIAL_CHAR_CODE = ALPHABET_SIZE - 3; +const DELIMITER_CHAR_CODE = ALPHABET_SIZE - 2; +const END_CHAR_CODE = ALPHABET_SIZE - 1; + +// Store the results for a char code in a lookup table to avoid recalculating the same values (performance optimization) +const base26LookupTable = new Array(); + +/** + * Converts a number to a base26 representation. + */ +function convertToBase26(num: number): number[] { + if (base26LookupTable[num]) { + return base26LookupTable[num]; + } + if (num < 0) { + throw new Error('convertToBase26: Input must be a non-negative integer'); + } + + const result: number[] = []; + + do { + // eslint-disable-next-line no-param-reassign + num--; + result.unshift(num % 26); + // eslint-disable-next-line no-bitwise, no-param-reassign + num >>= 5; // Equivalent to Math.floor(num / 26), but faster + } while (num > 0); + + base26LookupTable[num] = result; + return result; +} + +/** + * Converts a string to an array of numbers representing the characters of the string. + * Every number in the array is in the range [0, ALPHABET_SIZE-1] (0-28). + * + * The numbers are offset by the character code of 'a' (97). + * - This is so that the numbers from a-z are in the range 0-28. + * - 26 is for encoding special characters. Character numbers that are not within the range of a-z will be encoded as "specialCharacter + base26(charCode)" + * - 27 is for the delimiter character + * - 28 is for the end character + * + * Note: The string should be converted to lowercase first (otherwise uppercase letters get base26'ed taking more space than necessary). + */ +function stringToNumeric( + // The string we want to convert to a numeric representation + input: string, + options?: { + // A set of characters that should be skipped and not included in the numeric representation + charSetToSkip?: Set; + // When out is provided, the function will write the result to the provided arrays instead of creating new ones (performance) + out?: { + outArray: Uint8Array; + // As outArray is a ArrayBuffer we need to keep track of the current offset + offset: {value: number}; + // A map of to map the found occurrences to the correct data set + // As the search string can be very long for high traffic accounts (500k+), this has to be big enough, thus its a Uint32Array + outOccurrenceToIndex?: Uint32Array; + // The index that will be used in the outOccurrenceToIndex array (this is the index of your original data position) + index?: number; + }; + // By default false. By default the outArray may be larger than necessary. If clamp is set to true the outArray will be clamped to the actual size. + clamp?: boolean; + }, +): { + numeric: Uint8Array; + occurrenceToIndex: Uint32Array; + offset: {value: number}; +} { + // The out array might be longer than our input string length, because we encode special characters as multiple numbers using the base26 encoding. + // * 6 is because the upper limit of encoding any char in UTF-8 to base26 is at max 6 numbers. + const outArray = options?.out?.outArray ?? new Uint8Array(input.length * 6); + const offset = options?.out?.offset ?? {value: 0}; + const occurrenceToIndex = options?.out?.outOccurrenceToIndex ?? new Uint32Array(input.length * 16 * 4); + const index = options?.out?.index ?? 0; + + for (let i = 0; i < input.length; i++) { + const char = input[i]; + + if (options?.charSetToSkip?.has(char)) { + continue; + } + + if (char >= 'a' && char <= 'z') { + // char is an alphabet character + occurrenceToIndex[offset.value] = index; + outArray[offset.value++] = char.charCodeAt(0) - CHAR_CODE_A; + } else { + const charCode = input.charCodeAt(i); + occurrenceToIndex[offset.value] = index; + outArray[offset.value++] = SPECIAL_CHAR_CODE; + const asBase26Numeric = convertToBase26(charCode); + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let j = 0; j < asBase26Numeric.length; j++) { + occurrenceToIndex[offset.value] = index; + outArray[offset.value++] = asBase26Numeric[j]; + } + } + } + + return { + numeric: options?.clamp ? outArray.slice(0, offset.value) : outArray, + occurrenceToIndex, + offset, + }; +} + +export {stringToNumeric, ALPHABET, ALPHABET_SIZE, SPECIAL_CHAR_CODE, DELIMITER_CHAR_CODE, END_CHAR_CODE}; diff --git a/tests/unit/FastSearchTest.ts b/tests/unit/FastSearchTest.ts new file mode 100644 index 000000000000..029e05e15b1f --- /dev/null +++ b/tests/unit/FastSearchTest.ts @@ -0,0 +1,118 @@ +import FastSearch from '../../src/libs/FastSearch'; + +describe('FastSearch', () => { + it('should insert, and find the word', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['banana'], + toSearchableString: (data) => data, + }, + ]); + expect(search('an')).toEqual([['banana']]); + }); + + it('should work with multiple words', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['banana', 'test'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('es')).toEqual([['test']]); + }); + + it('should work when providing two data sets', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['erica', 'banana'], + toSearchableString: (data) => data, + }, + { + data: ['banana', 'test'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('es')).toEqual([[], ['test']]); + }); + + it('should work with numbers', () => { + const {search} = FastSearch.createFastSearch([ + { + data: [1, 2, 3, 4, 5], + toSearchableString: (data) => String(data), + }, + ]); + + expect(search('2')).toEqual([[2]]); + }); + + it('should work with unicodes', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['banana', 'ñèşťǒř', 'test'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('èşť')).toEqual([['ñèşťǒř']]); + }); + + it('should work with words containing "reserved special characters"', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['ba|nana', 'te{st', 'he}llo'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('st')).toEqual([['te{st']]); + expect(search('llo')).toEqual([['he}llo']]); + expect(search('nana')).toEqual([['ba|nana']]); + }); + + it('should be case insensitive', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['banana', 'TeSt', 'TEST', 'X'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('test')).toEqual([['TeSt', 'TEST']]); + }); + + it('should work with large random data sets', () => { + const data = Array.from({length: 1000}, () => { + return Array.from({length: Math.floor(Math.random() * 22 + 9)}, () => { + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789@-_.'; + return alphabet.charAt(Math.floor(Math.random() * alphabet.length)); + }).join(''); + }); + + const {search} = FastSearch.createFastSearch([ + { + data, + toSearchableString: (x) => x, + }, + ]); + + data.forEach((word) => { + expect(search(word)).toEqual([expect.arrayContaining([word])]); + }); + }); + + it('should find email addresses without dots', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['test.user@example.com', 'unrelated'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('testuser')).toEqual([['test.user@example.com']]); + expect(search('test.user')).toEqual([['test.user@example.com']]); + expect(search('examplecom')).toEqual([['test.user@example.com']]); + }); +}); diff --git a/tests/unit/SuffixUkkonenTreeTest.ts b/tests/unit/SuffixUkkonenTreeTest.ts new file mode 100644 index 000000000000..c0c556c16e14 --- /dev/null +++ b/tests/unit/SuffixUkkonenTreeTest.ts @@ -0,0 +1,63 @@ +import SuffixUkkonenTree from '@libs/SuffixUkkonenTree/index'; + +describe('SuffixUkkonenTree', () => { + // The suffix tree doesn't take strings, but expects an array buffer, where strings have been separated by a delimiter. + function helperStringsToNumericForTree(strings: string[]) { + const numericLists = strings.map((s) => SuffixUkkonenTree.stringToNumeric(s, {clamp: true})); + const numericList = numericLists.reduce( + (acc, {numeric}) => { + acc.push(...numeric, SuffixUkkonenTree.DELIMITER_CHAR_CODE); + return acc; + }, + // The value we pass to makeTree needs to be offset by one + [0], + ); + numericList.push(SuffixUkkonenTree.END_CHAR_CODE); + return Uint8Array.from(numericList); + } + + it('should insert, build, and find all occurrences', () => { + const strings = ['banana', 'pancake']; + const numericIntArray = helperStringsToNumericForTree(strings); + + const tree = SuffixUkkonenTree.makeTree(numericIntArray); + tree.build(); + const searchValue = SuffixUkkonenTree.stringToNumeric('an', {clamp: true}).numeric; + expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([2, 4, 9])); + }); + + it('should find by first character', () => { + const strings = ['pancake', 'banana']; + const numericIntArray = helperStringsToNumericForTree(strings); + const tree = SuffixUkkonenTree.makeTree(numericIntArray); + tree.build(); + const searchValue = SuffixUkkonenTree.stringToNumeric('p', {clamp: true}).numeric; + expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([1])); + }); + + it('should handle identical words', () => { + const strings = ['banana', 'banana', 'x']; + const numericIntArray = helperStringsToNumericForTree(strings); + const tree = SuffixUkkonenTree.makeTree(numericIntArray); + tree.build(); + const searchValue = SuffixUkkonenTree.stringToNumeric('an', {clamp: true}).numeric; + expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([2, 4, 9, 11])); + }); + + it('should convert string to numeric with a list of chars to skip', () => { + const {numeric} = SuffixUkkonenTree.stringToNumeric('abcabc', { + charSetToSkip: new Set(['b']), + clamp: true, + }); + expect(Array.from(numeric)).toEqual([0, 2, 0, 2]); + }); + + it('should convert string outside of a-z to numeric with clamping', () => { + const {numeric} = SuffixUkkonenTree.stringToNumeric('2', { + clamp: true, + }); + + // "2" in ASCII is 50, so base26(50) = [0, 23] + expect(Array.from(numeric)).toEqual([SuffixUkkonenTree.SPECIAL_CHAR_CODE, 0, 23]); + }); +}); From caa7dc5fcba285de19651acdf38900325c7b5874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 24 Oct 2024 10:53:58 +0200 Subject: [PATCH 02/17] exclude comma from search values --- src/libs/FastSearch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/FastSearch.ts b/src/libs/FastSearch.ts index 59d28dedd449..d514c269320c 100644 --- a/src/libs/FastSearch.ts +++ b/src/libs/FastSearch.ts @@ -16,7 +16,7 @@ type SearchableData = { }; // There are certain characters appear very often in our search data (email addresses), which we don't need to search for. -const charSetToSkip = new Set(['@', '.', '#', '$', '%', '&', '*', '+', '-', '/', ':', ';', '<', '=', '>', '?', '_', '~', '!', ' ']); +const charSetToSkip = new Set(['@', '.', '#', '$', '%', '&', '*', '+', '-', '/', ':', ';', '<', '=', '>', '?', '_', '~', '!', ' ', ',']); /** * Creates a new "FastSearch" instance. "FastSearch" uses a suffix tree to search for substrings in a list of strings. From a2d8012769451cccaea1e27f2ddf4154abcad201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 5 Nov 2024 09:59:18 +0100 Subject: [PATCH 03/17] wip: refactoring test to be reusable --- tests/unit/OptionsListUtilsTest.ts | 741 +------------------------- tests/utils/OptionListUtilsHelper.ts | 765 +++++++++++++++++++++++++++ 2 files changed, 783 insertions(+), 723 deletions(-) create mode 100644 tests/utils/OptionListUtilsHelper.ts diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 5a0cd6638a07..76ed984d9194 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -1,405 +1,37 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import type {SelectedTagOption} from '@components/TagPicker'; -import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; import * as OptionsListUtils from '@src/libs/OptionsListUtils'; import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, Policy, PolicyCategories, Report, TaxRatesWithDefault, Transaction} from '@src/types/onyx'; +import type {Policy, PolicyCategories, Report, TaxRatesWithDefault, Transaction} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {PersonalDetailsList} from '../utils/OptionListUtilsHelper'; +import createOptionsListUtilsHelper from '../utils/OptionListUtilsHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -type PersonalDetailsList = Record; - describe('OptionsListUtils', () => { - // Given a set of reports with both single participants and multiple participants some pinned and some not - const REPORTS: OnyxCollection = { - '1': { - lastReadTime: '2021-01-14 11:25:39.295', - lastVisibleActionCreated: '2022-11-22 03:26:02.015', - isPinned: false, - reportID: '1', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Iron Man, Mister Fantastic, Invisible Woman', - type: CONST.REPORT.TYPE.CHAT, - }, - '2': { - lastReadTime: '2021-01-14 11:25:39.296', - lastVisibleActionCreated: '2022-11-22 03:26:02.016', - isPinned: false, - reportID: '2', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Spider-Man', - type: CONST.REPORT.TYPE.CHAT, - }, - - // This is the only report we are pinning in this test - '3': { - lastReadTime: '2021-01-14 11:25:39.297', - lastVisibleActionCreated: '2022-11-22 03:26:02.170', - isPinned: true, - reportID: '3', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Mister Fantastic', - type: CONST.REPORT.TYPE.CHAT, - }, - '4': { - lastReadTime: '2021-01-14 11:25:39.298', - lastVisibleActionCreated: '2022-11-22 03:26:02.180', - isPinned: false, - reportID: '4', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Black Panther', - type: CONST.REPORT.TYPE.CHAT, - }, - '5': { - lastReadTime: '2021-01-14 11:25:39.299', - lastVisibleActionCreated: '2022-11-22 03:26:02.019', - isPinned: false, - reportID: '5', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Invisible Woman', - type: CONST.REPORT.TYPE.CHAT, - }, - '6': { - lastReadTime: '2021-01-14 11:25:39.300', - lastVisibleActionCreated: '2022-11-22 03:26:02.020', - isPinned: false, - reportID: '6', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 6: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Thor', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has the largest lastVisibleActionCreated - '7': { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:03.999', - isPinned: false, - reportID: '7', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Captain America', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has no lastVisibleActionCreated - '8': { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:02.000', - isPinned: false, - reportID: '8', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 12: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Silver Surfer', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has an IOU - '9': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.998', - isPinned: false, - reportID: '9', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 8: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Mister Sinister', - iouReportID: '100', - type: CONST.REPORT.TYPE.CHAT, - }, - - // This report is an archived room – it does not have a name and instead falls back on oldPolicyName - '10': { - lastReadTime: '2021-01-14 11:25:39.200', - lastVisibleActionCreated: '2022-11-22 03:26:02.001', - reportID: '10', - isPinned: false, - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: '', - oldPolicyName: "SHIELD's workspace", - chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, - isOwnPolicyExpenseChat: true, - type: CONST.REPORT.TYPE.CHAT, - - // This indicates that the report is archived - stateNum: 2, - statusNum: 2, - // eslint-disable-next-line @typescript-eslint/naming-convention - private_isArchived: DateUtils.getDBTime(), - }, - }; - - // And a set of personalDetails some with existing reports and some without - const PERSONAL_DETAILS: PersonalDetailsList = { - // These exist in our reports - '1': { - accountID: 1, - displayName: 'Mister Fantastic', - login: 'reedrichards@expensify.com', - isSelected: true, - reportID: '1', - }, - '2': { - accountID: 2, - displayName: 'Iron Man', - login: 'tonystark@expensify.com', - reportID: '1', - }, - '3': { - accountID: 3, - displayName: 'Spider-Man', - login: 'peterparker@expensify.com', - reportID: '1', - }, - '4': { - accountID: 4, - displayName: 'Black Panther', - login: 'tchalla@expensify.com', - reportID: '1', - }, - '5': { - accountID: 5, - displayName: 'Invisible Woman', - login: 'suestorm@expensify.com', - reportID: '1', - }, - '6': { - accountID: 6, - displayName: 'Thor', - login: 'thor@expensify.com', - reportID: '1', - }, - '7': { - accountID: 7, - displayName: 'Captain America', - login: 'steverogers@expensify.com', - reportID: '1', - }, - '8': { - accountID: 8, - displayName: 'Mr Sinister', - login: 'mistersinister@marauders.com', - reportID: '1', - }, - - // These do not exist in reports at all - '9': { - accountID: 9, - displayName: 'Black Widow', - login: 'natasharomanoff@expensify.com', - reportID: '', - }, - '10': { - accountID: 10, - displayName: 'The Incredible Hulk', - login: 'brucebanner@expensify.com', - reportID: '', - }, - }; - - const REPORTS_WITH_CONCIERGE: OnyxCollection = { - ...REPORTS, - - '11': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '11', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 999: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Concierge', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_CHRONOS: OnyxCollection = { - ...REPORTS, - '12': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '12', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1000: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Chronos', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_RECEIPTS: OnyxCollection = { - ...REPORTS, - '13': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '13', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1001: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Receipts', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_WORKSPACE_ROOMS: OnyxCollection = { - ...REPORTS, - '14': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '14', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 10: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: '', - oldPolicyName: 'Avengers Room', - chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, - isOwnPolicyExpenseChat: true, - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_CHAT_ROOM: OnyxCollection = { - ...REPORTS, - 15: { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:02.000', - isPinned: false, - reportID: '15', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Spider-Man, Black Panther', - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, - }, - }; - - const PERSONAL_DETAILS_WITH_CONCIERGE: PersonalDetailsList = { - ...PERSONAL_DETAILS, - '999': { - accountID: 999, - displayName: 'Concierge', - login: 'concierge@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_CHRONOS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1000': { - accountID: 1000, - displayName: 'Chronos', - login: 'chronos@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_RECEIPTS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1001': { - accountID: 1001, - displayName: 'Receipts', - login: 'receipts@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_PERIODS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1002': { - accountID: 1002, - displayName: 'The Flash', - login: 'barry.allen@expensify.com', - reportID: '', - }, - }; - - const policyID = 'ABC123'; - - const POLICY: Policy = { - id: policyID, - name: 'Hero Policy', - role: 'user', - type: CONST.POLICY.TYPE.TEAM, - owner: '', - outputCurrency: '', - isPolicyExpenseChatEnabled: false, - }; - - // Set the currently logged in user, report data, and personal details - beforeAll(() => { - Onyx.init({ - keys: ONYXKEYS, - initialKeyStates: { - [ONYXKEYS.SESSION]: {accountID: 2, email: 'tonystark@expensify.com'}, - [`${ONYXKEYS.COLLECTION.REPORT}100` as const]: { - reportID: '', - ownerAccountID: 8, - total: 1000, - }, - [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: POLICY, - }, - }); - Onyx.registerLogger(() => {}); - return waitForBatchedUpdates().then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS)); - }); - let OPTIONS: OptionsListUtils.OptionList; let OPTIONS_WITH_CONCIERGE: OptionsListUtils.OptionList; let OPTIONS_WITH_CHRONOS: OptionsListUtils.OptionList; let OPTIONS_WITH_RECEIPTS: OptionsListUtils.OptionList; let OPTIONS_WITH_WORKSPACE_ROOM: OptionsListUtils.OptionList; + const optionListUtilsHelper = createOptionsListUtilsHelper(); + + beforeAll(() => { + return optionListUtilsHelper.beforeAll(); + }); + beforeEach(() => { - OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); - OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); - OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); - OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); - OPTIONS_WITH_WORKSPACE_ROOM = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_WORKSPACE_ROOMS); + const options = optionListUtilsHelper.generateOptions(); + OPTIONS = options.OPTIONS; + OPTIONS_WITH_CONCIERGE = options.OPTIONS_WITH_CONCIERGE; + OPTIONS_WITH_CHRONOS = options.OPTIONS_WITH_CHRONOS; + OPTIONS_WITH_RECEIPTS = options.OPTIONS_WITH_RECEIPTS; + OPTIONS_WITH_WORKSPACE_ROOM = options.OPTIONS_WITH_WORKSPACE_ROOM; }); it('getSearchOptions()', () => { @@ -2544,7 +2176,7 @@ describe('OptionsListUtils', () => { }); it('formatMemberForList()', () => { - const formattedMembers = Object.values(PERSONAL_DETAILS).map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail)); + const formattedMembers = Object.values(optionListUtilsHelper.PERSONAL_DETAILS).map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail)); // We're only formatting items inside the array, so the order should be the same as the original PERSONAL_DETAILS array expect(formattedMembers.at(0)?.text).toBe('Mister Fantastic'); @@ -2561,344 +2193,7 @@ describe('OptionsListUtils', () => { expect(formattedMembers.every((personalDetail) => !personalDetail.isDisabled)).toBe(true); }); - describe('filterOptions', () => { - it('should return all options when search is empty', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - const filteredOptions = OptionsListUtils.filterOptions(options, ''); - - expect(filteredOptions.recentReports.length + filteredOptions.personalDetails.length).toBe(12); - }); - - it('should return filtered options in correct order', () => { - const searchText = 'man'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); - expect(filteredOptions.recentReports.length).toBe(4); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Invisible Woman'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Spider-Man'); - expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Widow'); - expect(filteredOptions.recentReports.at(3)?.text).toBe('Mister Fantastic, Invisible Woman'); - }); - - it('should filter users by email', () => { - const searchText = 'mistersinister@marauders.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mr Sinister'); - }); - - it('should find archived chats', () => { - const searchText = 'Archived'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(!!filteredOptions.recentReports.at(0)?.private_isArchived).toBe(true); - }); - - it('should filter options by email if dot is skipped in the email', () => { - const searchText = 'barryallen'; - const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.login).toBe('barry.allen@expensify.com'); - }); - - it('should include workspace rooms in the search results', () => { - const searchText = 'avengers'; - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_WORKSPACE_ROOM, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.subtitle).toBe('Avengers Room'); - }); - - it('should put exact match by login on the top of the list', () => { - const searchText = 'reedrichards@expensify.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.login).toBe(searchText); - }); - - it('should prioritize options with matching display name over chatrooms', () => { - const searchText = 'spider'; - const OPTIONS_WITH_CHATROOMS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_CHAT_ROOM); - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_CHATROOMS, '', [CONST.BETAS.ALL]); - - const filterOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filterOptions.recentReports.length).toBe(2); - expect(filterOptions.recentReports.at(1)?.isChatRoom).toBe(true); - }); - - it('should put the item with latest lastVisibleActionCreated on top when search value match multiple items', () => { - const searchText = 'fantastic'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(2); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); - }); - - it('should return the user to invite when the search value is a valid, non-existent email', () => { - const searchText = 'test@email.com'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.userToInvite?.login).toBe(searchText); - }); - - it('should not return any results if the search value is on an exluded logins list', () => { - const searchText = 'admin@expensify.com'; - - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails, excludeLogins: CONST.EXPENSIFY_EMAILS}); - const filterOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); - expect(filterOptions.recentReports.length).toBe(0); - }); - - it('should return the user to invite when the search value is a valid, non-existent email and the user is not excluded', () => { - const searchText = 'test@email.com'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); - - expect(filteredOptions.userToInvite?.login).toBe(searchText); - }); - - it('should return limited amount of recent reports if the limit is set', () => { - const searchText = ''; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {maxRecentReportsToShow: 2}); - - expect(filteredOptions.recentReports.length).toBe(2); - }); - - it('should not return any user to invite if email exists on the personal details list', () => { - const searchText = 'natasharomanoff@expensify.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options if search value does not match any personal details (getMemberInviteOptions)', () => { - const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return one personal detail if search value matches an email (getMemberInviteOptions)', () => { - const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com'); - - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.personalDetails.at(0)?.text).toBe('Spider-Man'); - }); - - it('should not show any recent reports if a search value does not match the group chat name (getShareDestinationsOptions)', () => { - // Filter current REPORTS as we do in the component, before getting share destination options - const filteredReports = Object.values(OPTIONS.reports).reduce((filtered, option) => { - const report = option.item; - if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - const options = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'mutants'); - - expect(filteredOptions.recentReports.length).toBe(0); - }); - - it('should return a workspace room when we search for a workspace room(getShareDestinationsOptions)', () => { - const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { - const report = option.item; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - - const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'Avengers Room'); - - expect(filteredOptions.recentReports.length).toBe(1); - }); - - it('should not show any results if searching for a non-existing workspace room(getShareDestinationOptions)', () => { - const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { - const report = option.item; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - - const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'Mutants Lair'); - - expect(filteredOptions.recentReports.length).toBe(0); - }); - - it('should show the option from personal details when searching for personal detail with no existing report (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'hulk'); - - expect(filteredOptions.recentReports.length).toBe(0); - - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('brucebanner@expensify.com'); - }); - - it('should return all matching reports and personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); - - expect(filteredOptions.recentReports.length).toBe(5); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); - - expect(filteredOptions.personalDetails.length).toBe(4); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); - }); - - it('should not return any options or user to invite if there are no search results and the string does not match a potential email or phone (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options but should return an user to invite if no matching options exist and the search value is a potential email (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify.com'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - }); - - it('should return user to invite when search term has a period with options for it that do not contain the period (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peter.parker@expensify.com'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '5005550006'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with country code added (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '+15005550006'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with special characters added (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '+1 (800)324-3233'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+18003243233'); - }); - - it('should not return any options or user to invite if contact number contains alphabet characters (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '998243aaaa'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options if search value does not match any personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); - - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return one recent report and no personal details if a search value provides an email (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com', {sortByReportTypeInSearch: true}); - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return all matching reports and personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); - - expect(filteredOptions.personalDetails.length).toBe(4); - expect(filteredOptions.recentReports.length).toBe(5); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mr Sinister'); - expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Panther'); - }); - - it('should return matching option when searching (getSearchOptions)', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'spider'); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); - }); - - it('should return latest lastVisibleActionCreated item on top when search value matches multiple items (getSearchOptions)', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'fantastic'); - - expect(filteredOptions.recentReports.length).toBe(2); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); - - return waitForBatchedUpdates() - .then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS_WITH_PERIODS)) - .then(() => { - const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); - const results = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, ''); - const filteredResults = OptionsListUtils.filterOptions(results, 'barry.allen@expensify.com', {sortByReportTypeInSearch: true}); - - expect(filteredResults.recentReports.length).toBe(1); - expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); - }); - }); - }); + describe('filterOptions', () => optionListUtilsHelper.createFilterTests()); describe('canCreateOptimisticPersonalDetailOption', () => { const VALID_EMAIL = 'valid@email.com'; diff --git a/tests/utils/OptionListUtilsHelper.ts b/tests/utils/OptionListUtilsHelper.ts new file mode 100644 index 000000000000..d5055aebc5e5 --- /dev/null +++ b/tests/utils/OptionListUtilsHelper.ts @@ -0,0 +1,765 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import Onyx from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; +import DateUtils from '@libs/DateUtils'; +import CONST from '@src/CONST'; +import * as OptionsListUtils from '@src/libs/OptionsListUtils'; +import * as ReportUtils from '@src/libs/ReportUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetails, Policy, Report} from '@src/types/onyx'; +import waitForBatchedUpdates from './waitForBatchedUpdates'; + +type PersonalDetailsList = Record; + +const createOptionsListUtilsHelper = () => { + // Given a set of reports with both single participants and multiple participants some pinned and some not + const REPORTS: OnyxCollection = { + '1': { + lastReadTime: '2021-01-14 11:25:39.295', + lastVisibleActionCreated: '2022-11-22 03:26:02.015', + isPinned: false, + reportID: '1', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Iron Man, Mister Fantastic, Invisible Woman', + type: CONST.REPORT.TYPE.CHAT, + }, + '2': { + lastReadTime: '2021-01-14 11:25:39.296', + lastVisibleActionCreated: '2022-11-22 03:26:02.016', + isPinned: false, + reportID: '2', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Spider-Man', + type: CONST.REPORT.TYPE.CHAT, + }, + + // This is the only report we are pinning in this test + '3': { + lastReadTime: '2021-01-14 11:25:39.297', + lastVisibleActionCreated: '2022-11-22 03:26:02.170', + isPinned: true, + reportID: '3', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Mister Fantastic', + type: CONST.REPORT.TYPE.CHAT, + }, + '4': { + lastReadTime: '2021-01-14 11:25:39.298', + lastVisibleActionCreated: '2022-11-22 03:26:02.180', + isPinned: false, + reportID: '4', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Black Panther', + type: CONST.REPORT.TYPE.CHAT, + }, + '5': { + lastReadTime: '2021-01-14 11:25:39.299', + lastVisibleActionCreated: '2022-11-22 03:26:02.019', + isPinned: false, + reportID: '5', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Invisible Woman', + type: CONST.REPORT.TYPE.CHAT, + }, + '6': { + lastReadTime: '2021-01-14 11:25:39.300', + lastVisibleActionCreated: '2022-11-22 03:26:02.020', + isPinned: false, + reportID: '6', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 6: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Thor', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has the largest lastVisibleActionCreated + '7': { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:03.999', + isPinned: false, + reportID: '7', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Captain America', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has no lastVisibleActionCreated + '8': { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:02.000', + isPinned: false, + reportID: '8', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 12: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Silver Surfer', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has an IOU + '9': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.998', + isPinned: false, + reportID: '9', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 8: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Mister Sinister', + iouReportID: '100', + type: CONST.REPORT.TYPE.CHAT, + }, + + // This report is an archived room – it does not have a name and instead falls back on oldPolicyName + '10': { + lastReadTime: '2021-01-14 11:25:39.200', + lastVisibleActionCreated: '2022-11-22 03:26:02.001', + reportID: '10', + isPinned: false, + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: '', + oldPolicyName: "SHIELD's workspace", + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + isOwnPolicyExpenseChat: true, + type: CONST.REPORT.TYPE.CHAT, + + // This indicates that the report is archived + stateNum: 2, + statusNum: 2, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: DateUtils.getDBTime(), + }, + }; + + // And a set of personalDetails some with existing reports and some without + const PERSONAL_DETAILS: PersonalDetailsList = { + // These exist in our reports + '1': { + accountID: 1, + displayName: 'Mister Fantastic', + login: 'reedrichards@expensify.com', + isSelected: true, + reportID: '1', + }, + '2': { + accountID: 2, + displayName: 'Iron Man', + login: 'tonystark@expensify.com', + reportID: '1', + }, + '3': { + accountID: 3, + displayName: 'Spider-Man', + login: 'peterparker@expensify.com', + reportID: '1', + }, + '4': { + accountID: 4, + displayName: 'Black Panther', + login: 'tchalla@expensify.com', + reportID: '1', + }, + '5': { + accountID: 5, + displayName: 'Invisible Woman', + login: 'suestorm@expensify.com', + reportID: '1', + }, + '6': { + accountID: 6, + displayName: 'Thor', + login: 'thor@expensify.com', + reportID: '1', + }, + '7': { + accountID: 7, + displayName: 'Captain America', + login: 'steverogers@expensify.com', + reportID: '1', + }, + '8': { + accountID: 8, + displayName: 'Mr Sinister', + login: 'mistersinister@marauders.com', + reportID: '1', + }, + + // These do not exist in reports at all + '9': { + accountID: 9, + displayName: 'Black Widow', + login: 'natasharomanoff@expensify.com', + reportID: '', + }, + '10': { + accountID: 10, + displayName: 'The Incredible Hulk', + login: 'brucebanner@expensify.com', + reportID: '', + }, + }; + + const REPORTS_WITH_CONCIERGE: OnyxCollection = { + ...REPORTS, + + '11': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '11', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 999: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Concierge', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_CHRONOS: OnyxCollection = { + ...REPORTS, + '12': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '12', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1000: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Chronos', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_RECEIPTS: OnyxCollection = { + ...REPORTS, + '13': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '13', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1001: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Receipts', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_WORKSPACE_ROOMS: OnyxCollection = { + ...REPORTS, + '14': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '14', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 10: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: '', + oldPolicyName: 'Avengers Room', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, + isOwnPolicyExpenseChat: true, + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const PERSONAL_DETAILS_WITH_CONCIERGE: PersonalDetailsList = { + ...PERSONAL_DETAILS, + '999': { + accountID: 999, + displayName: 'Concierge', + login: 'concierge@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_CHRONOS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1000': { + accountID: 1000, + displayName: 'Chronos', + login: 'chronos@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_RECEIPTS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1001': { + accountID: 1001, + displayName: 'Receipts', + login: 'receipts@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_PERIODS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1002': { + accountID: 1002, + displayName: 'The Flash', + login: 'barry.allen@expensify.com', + reportID: '', + }, + }; + + const REPORTS_WITH_CHAT_ROOM: OnyxCollection = { + ...REPORTS, + 15: { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:02.000', + isPinned: false, + reportID: '15', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Spider-Man, Black Panther', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, + }, + }; + + const policyID = 'ABC123'; + + const POLICY: Policy = { + id: policyID, + name: 'Hero Policy', + role: 'user', + type: CONST.POLICY.TYPE.TEAM, + owner: '', + outputCurrency: '', + isPolicyExpenseChatEnabled: false, + }; + + // Set the currently logged in user, report data, and personal details + const beforeAll = () => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: 2, email: 'tonystark@expensify.com'}, + [`${ONYXKEYS.COLLECTION.REPORT}100` as const]: { + reportID: '', + ownerAccountID: 8, + total: 1000, + }, + [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: POLICY, + }, + }); + Onyx.registerLogger(() => {}); + return waitForBatchedUpdates().then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS)); + }; + + // TODO: duplicate code? + let OPTIONS: OptionsListUtils.OptionList; + let OPTIONS_WITH_CONCIERGE: OptionsListUtils.OptionList; + let OPTIONS_WITH_CHRONOS: OptionsListUtils.OptionList; + let OPTIONS_WITH_RECEIPTS: OptionsListUtils.OptionList; + let OPTIONS_WITH_WORKSPACE_ROOM: OptionsListUtils.OptionList; + + const generateOptions = () => { + OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); + OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); + OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); + OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); + OPTIONS_WITH_WORKSPACE_ROOM = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_WORKSPACE_ROOMS); + + return { + OPTIONS, + OPTIONS_WITH_CONCIERGE, + OPTIONS_WITH_CHRONOS, + OPTIONS_WITH_RECEIPTS, + OPTIONS_WITH_WORKSPACE_ROOM, + }; + }; + + // TODO: our search is basically just for this use-case: filterOptions(searchOptions, debouncedInputValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); + const createFilterTests = () => { + it('should return all options when search is empty', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + const filteredOptions = OptionsListUtils.filterOptions(options, ''); + + expect(filteredOptions.recentReports.length + filteredOptions.personalDetails.length).toBe(12); + }); + + it('should return filtered options in correct order', () => { + const searchText = 'man'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); + expect(filteredOptions.recentReports.length).toBe(4); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Invisible Woman'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Spider-Man'); + expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Widow'); + expect(filteredOptions.recentReports.at(3)?.text).toBe('Mister Fantastic, Invisible Woman'); + }); + + it('should filter users by email', () => { + const searchText = 'mistersinister@marauders.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mr Sinister'); + }); + + it('should find archived chats', () => { + const searchText = 'Archived'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(!!filteredOptions.recentReports.at(0)?.private_isArchived).toBe(true); + }); + + it('should filter options by email if dot is skipped in the email', () => { + const searchText = 'barryallen'; + const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.login).toBe('barry.allen@expensify.com'); + }); + + it('should include workspace rooms in the search results', () => { + const searchText = 'avengers'; + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_WORKSPACE_ROOM, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.subtitle).toBe('Avengers Room'); + }); + + it('should put exact match by login on the top of the list', () => { + const searchText = 'reedrichards@expensify.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.login).toBe(searchText); + }); + + it('should prioritize options with matching display name over chatrooms', () => { + const searchText = 'spider'; + const OPTIONS_WITH_CHATROOMS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_CHAT_ROOM); + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_CHATROOMS, '', [CONST.BETAS.ALL]); + + const filterOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filterOptions.recentReports.length).toBe(2); + expect(filterOptions.recentReports.at(1)?.isChatRoom).toBe(true); + }); + + it('should put the item with latest lastVisibleActionCreated on top when search value match multiple items', () => { + const searchText = 'fantastic'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(2); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); + }); + + it('should return the user to invite when the search value is a valid, non-existent email', () => { + const searchText = 'test@email.com'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.userToInvite?.login).toBe(searchText); + }); + + it('should not return any results if the search value is on an exluded logins list', () => { + const searchText = 'admin@expensify.com'; + + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails, excludeLogins: CONST.EXPENSIFY_EMAILS}); + const filterOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); + expect(filterOptions.recentReports.length).toBe(0); + }); + + it('should return the user to invite when the search value is a valid, non-existent email and the user is not excluded', () => { + const searchText = 'test@email.com'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); + + expect(filteredOptions.userToInvite?.login).toBe(searchText); + }); + + it('should return limited amount of recent reports if the limit is set', () => { + const searchText = ''; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {maxRecentReportsToShow: 2}); + + expect(filteredOptions.recentReports.length).toBe(2); + }); + + it('should not return any user to invite if email exists on the personal details list', () => { + const searchText = 'natasharomanoff@expensify.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options if search value does not match any personal details (getMemberInviteOptions)', () => { + const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return one personal detail if search value matches an email (getMemberInviteOptions)', () => { + const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com'); + + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.personalDetails.at(0)?.text).toBe('Spider-Man'); + }); + + it('should not show any recent reports if a search value does not match the group chat name (getShareDestinationsOptions)', () => { + // Filter current REPORTS as we do in the component, before getting share destination options + const filteredReports = Object.values(OPTIONS.reports).reduce((filtered, option) => { + const report = option.item; + if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + const options = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'mutants'); + + expect(filteredOptions.recentReports.length).toBe(0); + }); + + it('should return a workspace room when we search for a workspace room(getShareDestinationsOptions)', () => { + const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { + const report = option.item; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + + const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'Avengers Room'); + + expect(filteredOptions.recentReports.length).toBe(1); + }); + + it('should not show any results if searching for a non-existing workspace room(getShareDestinationOptions)', () => { + const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { + const report = option.item; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + + const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'Mutants Lair'); + + expect(filteredOptions.recentReports.length).toBe(0); + }); + + it('should show the option from personal details when searching for personal detail with no existing report (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'hulk'); + + expect(filteredOptions.recentReports.length).toBe(0); + + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('brucebanner@expensify.com'); + }); + + it('should return all matching reports and personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); + + expect(filteredOptions.recentReports.length).toBe(5); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); + + expect(filteredOptions.personalDetails.length).toBe(4); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); + }); + + it('should not return any options or user to invite if there are no search results and the string does not match a potential email or phone (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options but should return an user to invite if no matching options exist and the search value is a potential email (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify.com'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + }); + + it('should return user to invite when search term has a period with options for it that do not contain the period (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peter.parker@expensify.com'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '5005550006'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with country code added (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '+15005550006'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with special characters added (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '+1 (800)324-3233'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+18003243233'); + }); + + it('should not return any options or user to invite if contact number contains alphabet characters (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '998243aaaa'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options if search value does not match any personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); + + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return one recent report and no personal details if a search value provides an email (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com', {sortByReportTypeInSearch: true}); + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return all matching reports and personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); + + expect(filteredOptions.personalDetails.length).toBe(4); + expect(filteredOptions.recentReports.length).toBe(5); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mr Sinister'); + expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Panther'); + }); + + it('should return matching option when searching (getSearchOptions)', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'spider'); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); + }); + + it('should return latest lastVisibleActionCreated item on top when search value matches multiple items (getSearchOptions)', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'fantastic'); + + expect(filteredOptions.recentReports.length).toBe(2); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); + + return waitForBatchedUpdates() + .then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS_WITH_PERIODS)) + .then(() => { + const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); + const results = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, ''); + const filteredResults = OptionsListUtils.filterOptions(results, 'barry.allen@expensify.com', {sortByReportTypeInSearch: true}); + + expect(filteredResults.recentReports.length).toBe(1); + expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); + }); + }); + }; + + return { + generateOptions, + beforeAll, + createFilterTests, + PERSONAL_DETAILS, + REPORTS, + PERSONAL_DETAILS_WITH_PERIODS, + REPORTS_WITH_CHAT_ROOM, + }; +}; + +export default createOptionsListUtilsHelper; + +export type {PersonalDetailsList}; From 9ed2253d8a2f774698c9ba08905b61671e117efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 5 Nov 2024 10:27:14 +0100 Subject: [PATCH 04/17] Revert "wip: refactoring test to be reusable" This reverts commit a2d8012769451cccaea1e27f2ddf4154abcad201. --- tests/unit/OptionsListUtilsTest.ts | 741 +++++++++++++++++++++++++- tests/utils/OptionListUtilsHelper.ts | 765 --------------------------- 2 files changed, 723 insertions(+), 783 deletions(-) delete mode 100644 tests/utils/OptionListUtilsHelper.ts diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 76ed984d9194..5a0cd6638a07 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -1,37 +1,405 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import Onyx from 'react-native-onyx'; import type {OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; import type {SelectedTagOption} from '@components/TagPicker'; +import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; import * as OptionsListUtils from '@src/libs/OptionsListUtils'; import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyCategories, Report, TaxRatesWithDefault, Transaction} from '@src/types/onyx'; +import type {PersonalDetails, Policy, PolicyCategories, Report, TaxRatesWithDefault, Transaction} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {PersonalDetailsList} from '../utils/OptionListUtilsHelper'; -import createOptionsListUtilsHelper from '../utils/OptionListUtilsHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +type PersonalDetailsList = Record; + describe('OptionsListUtils', () => { + // Given a set of reports with both single participants and multiple participants some pinned and some not + const REPORTS: OnyxCollection = { + '1': { + lastReadTime: '2021-01-14 11:25:39.295', + lastVisibleActionCreated: '2022-11-22 03:26:02.015', + isPinned: false, + reportID: '1', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Iron Man, Mister Fantastic, Invisible Woman', + type: CONST.REPORT.TYPE.CHAT, + }, + '2': { + lastReadTime: '2021-01-14 11:25:39.296', + lastVisibleActionCreated: '2022-11-22 03:26:02.016', + isPinned: false, + reportID: '2', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Spider-Man', + type: CONST.REPORT.TYPE.CHAT, + }, + + // This is the only report we are pinning in this test + '3': { + lastReadTime: '2021-01-14 11:25:39.297', + lastVisibleActionCreated: '2022-11-22 03:26:02.170', + isPinned: true, + reportID: '3', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Mister Fantastic', + type: CONST.REPORT.TYPE.CHAT, + }, + '4': { + lastReadTime: '2021-01-14 11:25:39.298', + lastVisibleActionCreated: '2022-11-22 03:26:02.180', + isPinned: false, + reportID: '4', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Black Panther', + type: CONST.REPORT.TYPE.CHAT, + }, + '5': { + lastReadTime: '2021-01-14 11:25:39.299', + lastVisibleActionCreated: '2022-11-22 03:26:02.019', + isPinned: false, + reportID: '5', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Invisible Woman', + type: CONST.REPORT.TYPE.CHAT, + }, + '6': { + lastReadTime: '2021-01-14 11:25:39.300', + lastVisibleActionCreated: '2022-11-22 03:26:02.020', + isPinned: false, + reportID: '6', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 6: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Thor', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has the largest lastVisibleActionCreated + '7': { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:03.999', + isPinned: false, + reportID: '7', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Captain America', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has no lastVisibleActionCreated + '8': { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:02.000', + isPinned: false, + reportID: '8', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 12: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Silver Surfer', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has an IOU + '9': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.998', + isPinned: false, + reportID: '9', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 8: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Mister Sinister', + iouReportID: '100', + type: CONST.REPORT.TYPE.CHAT, + }, + + // This report is an archived room – it does not have a name and instead falls back on oldPolicyName + '10': { + lastReadTime: '2021-01-14 11:25:39.200', + lastVisibleActionCreated: '2022-11-22 03:26:02.001', + reportID: '10', + isPinned: false, + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: '', + oldPolicyName: "SHIELD's workspace", + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + isOwnPolicyExpenseChat: true, + type: CONST.REPORT.TYPE.CHAT, + + // This indicates that the report is archived + stateNum: 2, + statusNum: 2, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: DateUtils.getDBTime(), + }, + }; + + // And a set of personalDetails some with existing reports and some without + const PERSONAL_DETAILS: PersonalDetailsList = { + // These exist in our reports + '1': { + accountID: 1, + displayName: 'Mister Fantastic', + login: 'reedrichards@expensify.com', + isSelected: true, + reportID: '1', + }, + '2': { + accountID: 2, + displayName: 'Iron Man', + login: 'tonystark@expensify.com', + reportID: '1', + }, + '3': { + accountID: 3, + displayName: 'Spider-Man', + login: 'peterparker@expensify.com', + reportID: '1', + }, + '4': { + accountID: 4, + displayName: 'Black Panther', + login: 'tchalla@expensify.com', + reportID: '1', + }, + '5': { + accountID: 5, + displayName: 'Invisible Woman', + login: 'suestorm@expensify.com', + reportID: '1', + }, + '6': { + accountID: 6, + displayName: 'Thor', + login: 'thor@expensify.com', + reportID: '1', + }, + '7': { + accountID: 7, + displayName: 'Captain America', + login: 'steverogers@expensify.com', + reportID: '1', + }, + '8': { + accountID: 8, + displayName: 'Mr Sinister', + login: 'mistersinister@marauders.com', + reportID: '1', + }, + + // These do not exist in reports at all + '9': { + accountID: 9, + displayName: 'Black Widow', + login: 'natasharomanoff@expensify.com', + reportID: '', + }, + '10': { + accountID: 10, + displayName: 'The Incredible Hulk', + login: 'brucebanner@expensify.com', + reportID: '', + }, + }; + + const REPORTS_WITH_CONCIERGE: OnyxCollection = { + ...REPORTS, + + '11': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '11', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 999: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Concierge', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_CHRONOS: OnyxCollection = { + ...REPORTS, + '12': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '12', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1000: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Chronos', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_RECEIPTS: OnyxCollection = { + ...REPORTS, + '13': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '13', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1001: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Receipts', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_WORKSPACE_ROOMS: OnyxCollection = { + ...REPORTS, + '14': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '14', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 10: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: '', + oldPolicyName: 'Avengers Room', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, + isOwnPolicyExpenseChat: true, + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_CHAT_ROOM: OnyxCollection = { + ...REPORTS, + 15: { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:02.000', + isPinned: false, + reportID: '15', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Spider-Man, Black Panther', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, + }, + }; + + const PERSONAL_DETAILS_WITH_CONCIERGE: PersonalDetailsList = { + ...PERSONAL_DETAILS, + '999': { + accountID: 999, + displayName: 'Concierge', + login: 'concierge@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_CHRONOS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1000': { + accountID: 1000, + displayName: 'Chronos', + login: 'chronos@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_RECEIPTS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1001': { + accountID: 1001, + displayName: 'Receipts', + login: 'receipts@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_PERIODS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1002': { + accountID: 1002, + displayName: 'The Flash', + login: 'barry.allen@expensify.com', + reportID: '', + }, + }; + + const policyID = 'ABC123'; + + const POLICY: Policy = { + id: policyID, + name: 'Hero Policy', + role: 'user', + type: CONST.POLICY.TYPE.TEAM, + owner: '', + outputCurrency: '', + isPolicyExpenseChatEnabled: false, + }; + + // Set the currently logged in user, report data, and personal details + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: 2, email: 'tonystark@expensify.com'}, + [`${ONYXKEYS.COLLECTION.REPORT}100` as const]: { + reportID: '', + ownerAccountID: 8, + total: 1000, + }, + [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: POLICY, + }, + }); + Onyx.registerLogger(() => {}); + return waitForBatchedUpdates().then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS)); + }); + let OPTIONS: OptionsListUtils.OptionList; let OPTIONS_WITH_CONCIERGE: OptionsListUtils.OptionList; let OPTIONS_WITH_CHRONOS: OptionsListUtils.OptionList; let OPTIONS_WITH_RECEIPTS: OptionsListUtils.OptionList; let OPTIONS_WITH_WORKSPACE_ROOM: OptionsListUtils.OptionList; - const optionListUtilsHelper = createOptionsListUtilsHelper(); - - beforeAll(() => { - return optionListUtilsHelper.beforeAll(); - }); - beforeEach(() => { - const options = optionListUtilsHelper.generateOptions(); - OPTIONS = options.OPTIONS; - OPTIONS_WITH_CONCIERGE = options.OPTIONS_WITH_CONCIERGE; - OPTIONS_WITH_CHRONOS = options.OPTIONS_WITH_CHRONOS; - OPTIONS_WITH_RECEIPTS = options.OPTIONS_WITH_RECEIPTS; - OPTIONS_WITH_WORKSPACE_ROOM = options.OPTIONS_WITH_WORKSPACE_ROOM; + OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); + OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); + OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); + OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); + OPTIONS_WITH_WORKSPACE_ROOM = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_WORKSPACE_ROOMS); }); it('getSearchOptions()', () => { @@ -2176,7 +2544,7 @@ describe('OptionsListUtils', () => { }); it('formatMemberForList()', () => { - const formattedMembers = Object.values(optionListUtilsHelper.PERSONAL_DETAILS).map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail)); + const formattedMembers = Object.values(PERSONAL_DETAILS).map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail)); // We're only formatting items inside the array, so the order should be the same as the original PERSONAL_DETAILS array expect(formattedMembers.at(0)?.text).toBe('Mister Fantastic'); @@ -2193,7 +2561,344 @@ describe('OptionsListUtils', () => { expect(formattedMembers.every((personalDetail) => !personalDetail.isDisabled)).toBe(true); }); - describe('filterOptions', () => optionListUtilsHelper.createFilterTests()); + describe('filterOptions', () => { + it('should return all options when search is empty', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + const filteredOptions = OptionsListUtils.filterOptions(options, ''); + + expect(filteredOptions.recentReports.length + filteredOptions.personalDetails.length).toBe(12); + }); + + it('should return filtered options in correct order', () => { + const searchText = 'man'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); + expect(filteredOptions.recentReports.length).toBe(4); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Invisible Woman'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Spider-Man'); + expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Widow'); + expect(filteredOptions.recentReports.at(3)?.text).toBe('Mister Fantastic, Invisible Woman'); + }); + + it('should filter users by email', () => { + const searchText = 'mistersinister@marauders.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mr Sinister'); + }); + + it('should find archived chats', () => { + const searchText = 'Archived'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(!!filteredOptions.recentReports.at(0)?.private_isArchived).toBe(true); + }); + + it('should filter options by email if dot is skipped in the email', () => { + const searchText = 'barryallen'; + const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.login).toBe('barry.allen@expensify.com'); + }); + + it('should include workspace rooms in the search results', () => { + const searchText = 'avengers'; + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_WORKSPACE_ROOM, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.subtitle).toBe('Avengers Room'); + }); + + it('should put exact match by login on the top of the list', () => { + const searchText = 'reedrichards@expensify.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.login).toBe(searchText); + }); + + it('should prioritize options with matching display name over chatrooms', () => { + const searchText = 'spider'; + const OPTIONS_WITH_CHATROOMS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_CHAT_ROOM); + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_CHATROOMS, '', [CONST.BETAS.ALL]); + + const filterOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filterOptions.recentReports.length).toBe(2); + expect(filterOptions.recentReports.at(1)?.isChatRoom).toBe(true); + }); + + it('should put the item with latest lastVisibleActionCreated on top when search value match multiple items', () => { + const searchText = 'fantastic'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(2); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); + }); + + it('should return the user to invite when the search value is a valid, non-existent email', () => { + const searchText = 'test@email.com'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.userToInvite?.login).toBe(searchText); + }); + + it('should not return any results if the search value is on an exluded logins list', () => { + const searchText = 'admin@expensify.com'; + + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails, excludeLogins: CONST.EXPENSIFY_EMAILS}); + const filterOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); + expect(filterOptions.recentReports.length).toBe(0); + }); + + it('should return the user to invite when the search value is a valid, non-existent email and the user is not excluded', () => { + const searchText = 'test@email.com'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); + + expect(filteredOptions.userToInvite?.login).toBe(searchText); + }); + + it('should return limited amount of recent reports if the limit is set', () => { + const searchText = ''; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {maxRecentReportsToShow: 2}); + + expect(filteredOptions.recentReports.length).toBe(2); + }); + + it('should not return any user to invite if email exists on the personal details list', () => { + const searchText = 'natasharomanoff@expensify.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options if search value does not match any personal details (getMemberInviteOptions)', () => { + const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return one personal detail if search value matches an email (getMemberInviteOptions)', () => { + const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com'); + + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.personalDetails.at(0)?.text).toBe('Spider-Man'); + }); + + it('should not show any recent reports if a search value does not match the group chat name (getShareDestinationsOptions)', () => { + // Filter current REPORTS as we do in the component, before getting share destination options + const filteredReports = Object.values(OPTIONS.reports).reduce((filtered, option) => { + const report = option.item; + if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + const options = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'mutants'); + + expect(filteredOptions.recentReports.length).toBe(0); + }); + + it('should return a workspace room when we search for a workspace room(getShareDestinationsOptions)', () => { + const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { + const report = option.item; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + + const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'Avengers Room'); + + expect(filteredOptions.recentReports.length).toBe(1); + }); + + it('should not show any results if searching for a non-existing workspace room(getShareDestinationOptions)', () => { + const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { + const report = option.item; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + + const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'Mutants Lair'); + + expect(filteredOptions.recentReports.length).toBe(0); + }); + + it('should show the option from personal details when searching for personal detail with no existing report (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'hulk'); + + expect(filteredOptions.recentReports.length).toBe(0); + + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('brucebanner@expensify.com'); + }); + + it('should return all matching reports and personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); + + expect(filteredOptions.recentReports.length).toBe(5); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); + + expect(filteredOptions.personalDetails.length).toBe(4); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); + }); + + it('should not return any options or user to invite if there are no search results and the string does not match a potential email or phone (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options but should return an user to invite if no matching options exist and the search value is a potential email (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify.com'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + }); + + it('should return user to invite when search term has a period with options for it that do not contain the period (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peter.parker@expensify.com'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '5005550006'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with country code added (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '+15005550006'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with special characters added (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '+1 (800)324-3233'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+18003243233'); + }); + + it('should not return any options or user to invite if contact number contains alphabet characters (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '998243aaaa'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options if search value does not match any personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); + + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return one recent report and no personal details if a search value provides an email (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com', {sortByReportTypeInSearch: true}); + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return all matching reports and personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); + + expect(filteredOptions.personalDetails.length).toBe(4); + expect(filteredOptions.recentReports.length).toBe(5); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mr Sinister'); + expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Panther'); + }); + + it('should return matching option when searching (getSearchOptions)', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'spider'); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); + }); + + it('should return latest lastVisibleActionCreated item on top when search value matches multiple items (getSearchOptions)', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'fantastic'); + + expect(filteredOptions.recentReports.length).toBe(2); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); + + return waitForBatchedUpdates() + .then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS_WITH_PERIODS)) + .then(() => { + const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); + const results = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, ''); + const filteredResults = OptionsListUtils.filterOptions(results, 'barry.allen@expensify.com', {sortByReportTypeInSearch: true}); + + expect(filteredResults.recentReports.length).toBe(1); + expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); + }); + }); + }); describe('canCreateOptimisticPersonalDetailOption', () => { const VALID_EMAIL = 'valid@email.com'; diff --git a/tests/utils/OptionListUtilsHelper.ts b/tests/utils/OptionListUtilsHelper.ts deleted file mode 100644 index d5055aebc5e5..000000000000 --- a/tests/utils/OptionListUtilsHelper.ts +++ /dev/null @@ -1,765 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import Onyx from 'react-native-onyx'; -import type {OnyxCollection} from 'react-native-onyx'; -import DateUtils from '@libs/DateUtils'; -import CONST from '@src/CONST'; -import * as OptionsListUtils from '@src/libs/OptionsListUtils'; -import * as ReportUtils from '@src/libs/ReportUtils'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, Policy, Report} from '@src/types/onyx'; -import waitForBatchedUpdates from './waitForBatchedUpdates'; - -type PersonalDetailsList = Record; - -const createOptionsListUtilsHelper = () => { - // Given a set of reports with both single participants and multiple participants some pinned and some not - const REPORTS: OnyxCollection = { - '1': { - lastReadTime: '2021-01-14 11:25:39.295', - lastVisibleActionCreated: '2022-11-22 03:26:02.015', - isPinned: false, - reportID: '1', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Iron Man, Mister Fantastic, Invisible Woman', - type: CONST.REPORT.TYPE.CHAT, - }, - '2': { - lastReadTime: '2021-01-14 11:25:39.296', - lastVisibleActionCreated: '2022-11-22 03:26:02.016', - isPinned: false, - reportID: '2', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Spider-Man', - type: CONST.REPORT.TYPE.CHAT, - }, - - // This is the only report we are pinning in this test - '3': { - lastReadTime: '2021-01-14 11:25:39.297', - lastVisibleActionCreated: '2022-11-22 03:26:02.170', - isPinned: true, - reportID: '3', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Mister Fantastic', - type: CONST.REPORT.TYPE.CHAT, - }, - '4': { - lastReadTime: '2021-01-14 11:25:39.298', - lastVisibleActionCreated: '2022-11-22 03:26:02.180', - isPinned: false, - reportID: '4', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Black Panther', - type: CONST.REPORT.TYPE.CHAT, - }, - '5': { - lastReadTime: '2021-01-14 11:25:39.299', - lastVisibleActionCreated: '2022-11-22 03:26:02.019', - isPinned: false, - reportID: '5', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Invisible Woman', - type: CONST.REPORT.TYPE.CHAT, - }, - '6': { - lastReadTime: '2021-01-14 11:25:39.300', - lastVisibleActionCreated: '2022-11-22 03:26:02.020', - isPinned: false, - reportID: '6', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 6: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Thor', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has the largest lastVisibleActionCreated - '7': { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:03.999', - isPinned: false, - reportID: '7', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Captain America', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has no lastVisibleActionCreated - '8': { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:02.000', - isPinned: false, - reportID: '8', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 12: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Silver Surfer', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has an IOU - '9': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.998', - isPinned: false, - reportID: '9', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 8: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Mister Sinister', - iouReportID: '100', - type: CONST.REPORT.TYPE.CHAT, - }, - - // This report is an archived room – it does not have a name and instead falls back on oldPolicyName - '10': { - lastReadTime: '2021-01-14 11:25:39.200', - lastVisibleActionCreated: '2022-11-22 03:26:02.001', - reportID: '10', - isPinned: false, - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: '', - oldPolicyName: "SHIELD's workspace", - chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, - isOwnPolicyExpenseChat: true, - type: CONST.REPORT.TYPE.CHAT, - - // This indicates that the report is archived - stateNum: 2, - statusNum: 2, - // eslint-disable-next-line @typescript-eslint/naming-convention - private_isArchived: DateUtils.getDBTime(), - }, - }; - - // And a set of personalDetails some with existing reports and some without - const PERSONAL_DETAILS: PersonalDetailsList = { - // These exist in our reports - '1': { - accountID: 1, - displayName: 'Mister Fantastic', - login: 'reedrichards@expensify.com', - isSelected: true, - reportID: '1', - }, - '2': { - accountID: 2, - displayName: 'Iron Man', - login: 'tonystark@expensify.com', - reportID: '1', - }, - '3': { - accountID: 3, - displayName: 'Spider-Man', - login: 'peterparker@expensify.com', - reportID: '1', - }, - '4': { - accountID: 4, - displayName: 'Black Panther', - login: 'tchalla@expensify.com', - reportID: '1', - }, - '5': { - accountID: 5, - displayName: 'Invisible Woman', - login: 'suestorm@expensify.com', - reportID: '1', - }, - '6': { - accountID: 6, - displayName: 'Thor', - login: 'thor@expensify.com', - reportID: '1', - }, - '7': { - accountID: 7, - displayName: 'Captain America', - login: 'steverogers@expensify.com', - reportID: '1', - }, - '8': { - accountID: 8, - displayName: 'Mr Sinister', - login: 'mistersinister@marauders.com', - reportID: '1', - }, - - // These do not exist in reports at all - '9': { - accountID: 9, - displayName: 'Black Widow', - login: 'natasharomanoff@expensify.com', - reportID: '', - }, - '10': { - accountID: 10, - displayName: 'The Incredible Hulk', - login: 'brucebanner@expensify.com', - reportID: '', - }, - }; - - const REPORTS_WITH_CONCIERGE: OnyxCollection = { - ...REPORTS, - - '11': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '11', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 999: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Concierge', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_CHRONOS: OnyxCollection = { - ...REPORTS, - '12': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '12', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1000: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Chronos', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_RECEIPTS: OnyxCollection = { - ...REPORTS, - '13': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '13', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1001: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Receipts', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_WORKSPACE_ROOMS: OnyxCollection = { - ...REPORTS, - '14': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '14', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 10: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: '', - oldPolicyName: 'Avengers Room', - chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, - isOwnPolicyExpenseChat: true, - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const PERSONAL_DETAILS_WITH_CONCIERGE: PersonalDetailsList = { - ...PERSONAL_DETAILS, - '999': { - accountID: 999, - displayName: 'Concierge', - login: 'concierge@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_CHRONOS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1000': { - accountID: 1000, - displayName: 'Chronos', - login: 'chronos@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_RECEIPTS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1001': { - accountID: 1001, - displayName: 'Receipts', - login: 'receipts@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_PERIODS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1002': { - accountID: 1002, - displayName: 'The Flash', - login: 'barry.allen@expensify.com', - reportID: '', - }, - }; - - const REPORTS_WITH_CHAT_ROOM: OnyxCollection = { - ...REPORTS, - 15: { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:02.000', - isPinned: false, - reportID: '15', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Spider-Man, Black Panther', - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, - }, - }; - - const policyID = 'ABC123'; - - const POLICY: Policy = { - id: policyID, - name: 'Hero Policy', - role: 'user', - type: CONST.POLICY.TYPE.TEAM, - owner: '', - outputCurrency: '', - isPolicyExpenseChatEnabled: false, - }; - - // Set the currently logged in user, report data, and personal details - const beforeAll = () => { - Onyx.init({ - keys: ONYXKEYS, - initialKeyStates: { - [ONYXKEYS.SESSION]: {accountID: 2, email: 'tonystark@expensify.com'}, - [`${ONYXKEYS.COLLECTION.REPORT}100` as const]: { - reportID: '', - ownerAccountID: 8, - total: 1000, - }, - [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: POLICY, - }, - }); - Onyx.registerLogger(() => {}); - return waitForBatchedUpdates().then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS)); - }; - - // TODO: duplicate code? - let OPTIONS: OptionsListUtils.OptionList; - let OPTIONS_WITH_CONCIERGE: OptionsListUtils.OptionList; - let OPTIONS_WITH_CHRONOS: OptionsListUtils.OptionList; - let OPTIONS_WITH_RECEIPTS: OptionsListUtils.OptionList; - let OPTIONS_WITH_WORKSPACE_ROOM: OptionsListUtils.OptionList; - - const generateOptions = () => { - OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); - OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); - OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); - OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); - OPTIONS_WITH_WORKSPACE_ROOM = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_WORKSPACE_ROOMS); - - return { - OPTIONS, - OPTIONS_WITH_CONCIERGE, - OPTIONS_WITH_CHRONOS, - OPTIONS_WITH_RECEIPTS, - OPTIONS_WITH_WORKSPACE_ROOM, - }; - }; - - // TODO: our search is basically just for this use-case: filterOptions(searchOptions, debouncedInputValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); - const createFilterTests = () => { - it('should return all options when search is empty', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - const filteredOptions = OptionsListUtils.filterOptions(options, ''); - - expect(filteredOptions.recentReports.length + filteredOptions.personalDetails.length).toBe(12); - }); - - it('should return filtered options in correct order', () => { - const searchText = 'man'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); - expect(filteredOptions.recentReports.length).toBe(4); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Invisible Woman'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Spider-Man'); - expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Widow'); - expect(filteredOptions.recentReports.at(3)?.text).toBe('Mister Fantastic, Invisible Woman'); - }); - - it('should filter users by email', () => { - const searchText = 'mistersinister@marauders.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mr Sinister'); - }); - - it('should find archived chats', () => { - const searchText = 'Archived'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(!!filteredOptions.recentReports.at(0)?.private_isArchived).toBe(true); - }); - - it('should filter options by email if dot is skipped in the email', () => { - const searchText = 'barryallen'; - const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.login).toBe('barry.allen@expensify.com'); - }); - - it('should include workspace rooms in the search results', () => { - const searchText = 'avengers'; - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_WORKSPACE_ROOM, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.subtitle).toBe('Avengers Room'); - }); - - it('should put exact match by login on the top of the list', () => { - const searchText = 'reedrichards@expensify.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.login).toBe(searchText); - }); - - it('should prioritize options with matching display name over chatrooms', () => { - const searchText = 'spider'; - const OPTIONS_WITH_CHATROOMS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_CHAT_ROOM); - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_CHATROOMS, '', [CONST.BETAS.ALL]); - - const filterOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filterOptions.recentReports.length).toBe(2); - expect(filterOptions.recentReports.at(1)?.isChatRoom).toBe(true); - }); - - it('should put the item with latest lastVisibleActionCreated on top when search value match multiple items', () => { - const searchText = 'fantastic'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(2); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); - }); - - it('should return the user to invite when the search value is a valid, non-existent email', () => { - const searchText = 'test@email.com'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.userToInvite?.login).toBe(searchText); - }); - - it('should not return any results if the search value is on an exluded logins list', () => { - const searchText = 'admin@expensify.com'; - - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails, excludeLogins: CONST.EXPENSIFY_EMAILS}); - const filterOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); - expect(filterOptions.recentReports.length).toBe(0); - }); - - it('should return the user to invite when the search value is a valid, non-existent email and the user is not excluded', () => { - const searchText = 'test@email.com'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); - - expect(filteredOptions.userToInvite?.login).toBe(searchText); - }); - - it('should return limited amount of recent reports if the limit is set', () => { - const searchText = ''; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {maxRecentReportsToShow: 2}); - - expect(filteredOptions.recentReports.length).toBe(2); - }); - - it('should not return any user to invite if email exists on the personal details list', () => { - const searchText = 'natasharomanoff@expensify.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options if search value does not match any personal details (getMemberInviteOptions)', () => { - const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return one personal detail if search value matches an email (getMemberInviteOptions)', () => { - const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com'); - - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.personalDetails.at(0)?.text).toBe('Spider-Man'); - }); - - it('should not show any recent reports if a search value does not match the group chat name (getShareDestinationsOptions)', () => { - // Filter current REPORTS as we do in the component, before getting share destination options - const filteredReports = Object.values(OPTIONS.reports).reduce((filtered, option) => { - const report = option.item; - if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - const options = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'mutants'); - - expect(filteredOptions.recentReports.length).toBe(0); - }); - - it('should return a workspace room when we search for a workspace room(getShareDestinationsOptions)', () => { - const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { - const report = option.item; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - - const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'Avengers Room'); - - expect(filteredOptions.recentReports.length).toBe(1); - }); - - it('should not show any results if searching for a non-existing workspace room(getShareDestinationOptions)', () => { - const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { - const report = option.item; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - - const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'Mutants Lair'); - - expect(filteredOptions.recentReports.length).toBe(0); - }); - - it('should show the option from personal details when searching for personal detail with no existing report (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'hulk'); - - expect(filteredOptions.recentReports.length).toBe(0); - - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('brucebanner@expensify.com'); - }); - - it('should return all matching reports and personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); - - expect(filteredOptions.recentReports.length).toBe(5); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); - - expect(filteredOptions.personalDetails.length).toBe(4); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); - }); - - it('should not return any options or user to invite if there are no search results and the string does not match a potential email or phone (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options but should return an user to invite if no matching options exist and the search value is a potential email (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify.com'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - }); - - it('should return user to invite when search term has a period with options for it that do not contain the period (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peter.parker@expensify.com'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '5005550006'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with country code added (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '+15005550006'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with special characters added (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '+1 (800)324-3233'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+18003243233'); - }); - - it('should not return any options or user to invite if contact number contains alphabet characters (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '998243aaaa'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options if search value does not match any personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); - - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return one recent report and no personal details if a search value provides an email (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com', {sortByReportTypeInSearch: true}); - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return all matching reports and personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); - - expect(filteredOptions.personalDetails.length).toBe(4); - expect(filteredOptions.recentReports.length).toBe(5); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mr Sinister'); - expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Panther'); - }); - - it('should return matching option when searching (getSearchOptions)', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'spider'); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); - }); - - it('should return latest lastVisibleActionCreated item on top when search value matches multiple items (getSearchOptions)', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'fantastic'); - - expect(filteredOptions.recentReports.length).toBe(2); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); - - return waitForBatchedUpdates() - .then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS_WITH_PERIODS)) - .then(() => { - const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); - const results = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, ''); - const filteredResults = OptionsListUtils.filterOptions(results, 'barry.allen@expensify.com', {sortByReportTypeInSearch: true}); - - expect(filteredResults.recentReports.length).toBe(1); - expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); - }); - }); - }; - - return { - generateOptions, - beforeAll, - createFilterTests, - PERSONAL_DETAILS, - REPORTS, - PERSONAL_DETAILS_WITH_PERIODS, - REPORTS_WITH_CHAT_ROOM, - }; -}; - -export default createOptionsListUtilsHelper; - -export type {PersonalDetailsList}; From a01a375a7368b2e69603fa4aed5401de48c7c74a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 5 Nov 2024 11:49:28 +0100 Subject: [PATCH 05/17] fix: sort search results correctly --- .../Search/SearchRouter/SearchRouter.tsx | 15 ++++++++++----- src/libs/OptionsListUtils.ts | 14 +++++++++----- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 6f5481a17983..4b1a7a9ed4ad 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -162,19 +162,24 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const newOptions = findInSearchTree(debouncedInputValue); Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); - const recentReports = newOptions.recentReports.concat(newOptions.personalDetails); + // See OptionListUtils.filterOptions#sortByReportTypeInSearch: + const filteredPersonalDetails = OptionsListUtils.filteredPersonalDetailsOfRecentReports(newOptions.recentReports, newOptions.personalDetails); + const recentReportsWithPersonalDetails = newOptions.recentReports.concat(filteredPersonalDetails); + const sortedReports = OptionsListUtils.orderOptions(recentReportsWithPersonalDetails, debouncedInputValue, { + preferChatroomsOverThreads: true, + }); const userToInvite = OptionsListUtils.pickUserToInvite({ canInviteUser: true, - recentReports: newOptions.recentReports, - personalDetails: newOptions.personalDetails, + recentReports: sortedReports, + personalDetails: filteredPersonalDetails, searchValue: debouncedInputValue, optionsToExclude: [{login: CONST.EMAIL.NOTIFICATIONS}], }); return { - recentReports, - personalDetails: [], + recentReports: sortedReports, + personalDetails: filteredPersonalDetails, userToInvite, }; }, [debouncedInputValue, findInSearchTree]); diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index f414d2328ef6..efeca80c1fd1 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2502,6 +2502,14 @@ const pickUserToInvite = ({canInviteUser, recentReports, personalDetails, search return userToInvite; }; +/** + * Remove the personal details for the DMs that are already in the recent reports so that we don't show duplicates + */ +function filteredPersonalDetailsOfRecentReports(recentReports: ReportUtils.OptionData[], personalDetails: ReportUtils.OptionData[]) { + const excludedLogins = new Set(recentReports.map((report) => report.login)); + return personalDetails.filter((personalDetail) => !excludedLogins.has(personalDetail.login)); +} + /** * Filters options based on the search input value */ @@ -2515,11 +2523,6 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt preferPolicyExpenseChat = false, preferRecentExpenseReports = false, } = config ?? {}; - // Remove the personal details for the DMs that are already in the recent reports so that we don't show duplicates - function filteredPersonalDetailsOfRecentReports(recentReports: ReportUtils.OptionData[], personalDetails: ReportUtils.OptionData[]) { - const excludedLogins = new Set(recentReports.map((report) => report.login)); - return personalDetails.filter((personalDetail) => !excludedLogins.has(personalDetail.login)); - } if (searchInputValue.trim() === '' && maxRecentReportsToShow > 0) { const recentReports = options.recentReports.slice(0, maxRecentReportsToShow); const personalDetails = filteredPersonalDetailsOfRecentReports(recentReports, options.personalDetails); @@ -2676,6 +2679,7 @@ export { getAlternateText, pickUserToInvite, hasReportErrors, + filteredPersonalDetailsOfRecentReports, }; export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, Tax, TaxRatesOption, Option, OptionTree}; From 1e02c8257083ae87dccfa30e4315672fb929664d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 7 Nov 2024 10:47:26 +0100 Subject: [PATCH 06/17] cleanup option list --- src/libs/OptionsListUtils.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 099efcc1bfd9..56ae9e94deb7 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2583,13 +2583,15 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt }; }, options); - let {recentReports, personalDetails} = matchResults; + const {recentReports, personalDetails} = matchResults; + const noneReportPersonalDetails = filteredPersonalDetailsOfRecentReports(recentReports, personalDetails); + + let filteredPersonalDetails: ReportUtils.OptionData[] = noneReportPersonalDetails; + let filteredRecentReports: ReportUtils.OptionData[] = recentReports; if (sortByReportTypeInSearch) { - personalDetails = filteredPersonalDetailsOfRecentReports(recentReports, personalDetails); - recentReports = recentReports.concat(personalDetails); - personalDetails = []; - recentReports = orderOptions(recentReports, searchValue); + filteredRecentReports = recentReports.concat(noneReportPersonalDetails); + filteredPersonalDetails = []; } const userToInvite = pickUserToInvite({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude}); @@ -2597,11 +2599,11 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt if (maxRecentReportsToShow > 0 && recentReports.length > maxRecentReportsToShow) { recentReports.splice(maxRecentReportsToShow); } - const filteredPersonalDetails = filteredPersonalDetailsOfRecentReports(recentReports, personalDetails); + const sortedRecentReports = orderOptions(filteredRecentReports, searchValue, {preferChatroomsOverThreads, preferPolicyExpenseChat, preferRecentExpenseReports}); return { personalDetails: filteredPersonalDetails, - recentReports: orderOptions(recentReports, searchValue, {preferChatroomsOverThreads, preferPolicyExpenseChat, preferRecentExpenseReports}), + recentReports: sortedRecentReports, userToInvite, currentUserOption: matchResults.currentUserOption, categoryOptions: [], From c73aad5b75f156928209d1236720f0653f96b358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 7 Nov 2024 10:50:30 +0100 Subject: [PATCH 07/17] fix duplicate search results --- src/components/Search/SearchRouter/SearchRouter.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 5d2d00bfeb43..968bb234ff5b 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -135,8 +135,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); // See OptionListUtils.filterOptions#sortByReportTypeInSearch: - const filteredPersonalDetails = OptionsListUtils.filteredPersonalDetailsOfRecentReports(newOptions.recentReports, newOptions.personalDetails); - const recentReportsWithPersonalDetails = newOptions.recentReports.concat(filteredPersonalDetails); + const noneReportPersonalDetails = OptionsListUtils.filteredPersonalDetailsOfRecentReports(newOptions.recentReports, newOptions.personalDetails); + const recentReportsWithPersonalDetails = newOptions.recentReports.concat(noneReportPersonalDetails); const sortedReports = OptionsListUtils.orderOptions(recentReportsWithPersonalDetails, debouncedInputValue, { preferChatroomsOverThreads: true, }); @@ -144,14 +144,14 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const userToInvite = OptionsListUtils.pickUserToInvite({ canInviteUser: true, recentReports: sortedReports, - personalDetails: filteredPersonalDetails, + personalDetails: [], searchValue: debouncedInputValue, optionsToExclude: [{login: CONST.EMAIL.NOTIFICATIONS}], }); return { recentReports: sortedReports, - personalDetails: filteredPersonalDetails, + personalDetails: [], userToInvite, }; }, [debouncedInputValue, findInSearchTree]); From 6a7b7e84a2f10cf5749b5c39494129bd05a9b8a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 7 Nov 2024 17:00:20 +0100 Subject: [PATCH 08/17] eslint --- src/components/Search/SearchRouter/SearchRouter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 968bb234ff5b..0c1fd1a9094b 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -16,8 +16,8 @@ import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import FastSearch from '@libs/FastSearch'; import * as CardUtils from '@libs/CardUtils'; +import FastSearch from '@libs/FastSearch'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; From 253d17b8baab9bad3d6fd97fa8a9cc6551b75980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 8 Nov 2024 10:16:51 +0100 Subject: [PATCH 09/17] wip --- src/libs/OptionsListUtils.ts | 6 +++++- tests/unit/OptionsListUtilsTest.ts | 24 ++++++++++++++++++++++++ tests/unit/SuffixUkkonenTreeTest.ts | 9 +++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 56ae9e94deb7..fba245b330b1 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1687,7 +1687,11 @@ function getUserToInviteOption({ } /** - * filter options based on specific conditions + * TODO: What is the purpose of this function + * + * - It seems to convert Report & PersonalDetails into a unified format + * - It applies ordering to the items + * - Given a searchValue, it will filter the items */ function getOptions( options: OptionList, diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 5a0cd6638a07..65ef0aebd000 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -225,6 +225,18 @@ describe('OptionsListUtils', () => { login: 'brucebanner@expensify.com', reportID: '', }, + '110': { + accountID: 110, + displayName: 'SubString', + login: 'SubString@mail.com', + reportID: '', + }, + '111': { + accountID: 111, + displayName: 'String', + login: 'String@mail.com', + reportID: '', + }, }; const REPORTS_WITH_CONCIERGE: OnyxCollection = { @@ -396,6 +408,7 @@ describe('OptionsListUtils', () => { beforeEach(() => { OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); + console.log(OPTIONS); OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); @@ -2898,6 +2911,17 @@ describe('OptionsListUtils', () => { expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); }); }); + + it('should return prefix match before suffix match', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + console.log(options); + const filteredOptions = OptionsListUtils.filterOptions(options, 'String'); + + console.log(filteredOptions); + expect(filteredOptions.personalDetails.length).toBe(2); + expect(filteredOptions.personalDetails.at(0)?.text).toBe('String'); + expect(filteredOptions.personalDetails.at(1)?.text).toBe('SubString'); + }); }); describe('canCreateOptimisticPersonalDetailOption', () => { diff --git a/tests/unit/SuffixUkkonenTreeTest.ts b/tests/unit/SuffixUkkonenTreeTest.ts index c0c556c16e14..0cf34fff3308 100644 --- a/tests/unit/SuffixUkkonenTreeTest.ts +++ b/tests/unit/SuffixUkkonenTreeTest.ts @@ -60,4 +60,13 @@ describe('SuffixUkkonenTree', () => { // "2" in ASCII is 50, so base26(50) = [0, 23] expect(Array.from(numeric)).toEqual([SuffixUkkonenTree.SPECIAL_CHAR_CODE, 0, 23]); }); + + it('should have prefix matches first, then substring matches', () => { + const strings = ['abcdef', 'cdef', 'def']; + const numericIntArray = helperStringsToNumericForTree(strings); + const tree = SuffixUkkonenTree.makeTree(numericIntArray); + tree.build(); + const searchValue = SuffixUkkonenTree.stringToNumeric('def', {clamp: true}).numeric; + expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([18, 15, 10])); + }); }); From 2c856b48a975004b58a0d0f0c5ad6d8a24a7e441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 12 Dec 2024 18:17:19 +0100 Subject: [PATCH 10/17] fixes after merge --- src/libs/OptionsListUtils.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index f461afaffcff..a38eb9e355ea 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1722,7 +1722,6 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt config, ); - const sortedRecentReports = orderOptions(filteredRecentReports, searchValue, {preferChatroomsOverThreads, preferPolicyExpenseChat, preferRecentExpenseReports}); return { personalDetails, recentReports, @@ -1819,12 +1818,12 @@ export { formatSectionsFromSearchTerm, getShareLogOptions, orderOptions, + filterUserToInvite, filterOptions, filteredPersonalDetailsOfRecentReports, orderReportOptions, orderReportOptionsWithSearch, orderPersonalDetailsOptions, - orderOptions, filterAndOrderOptions, createOptionList, createOptionFromReport, @@ -1838,9 +1837,7 @@ export { shouldUseBoldText, getAttendeeOptions, getAlternateText, - pickUserToInvite, hasReportErrors, - filteredPersonalDetailsOfRecentReports, }; export type {Section, SectionBase, MemberForList, Options, OptionList, SearchOption, PayeePersonalDetails, Option, OptionTree}; From 84873d817ffd8d09a15d36b4040f85a86a150ac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 12 Dec 2024 18:32:49 +0100 Subject: [PATCH 11/17] wip: use fast search in SearchRouterList --- .../Search/SearchRouter/SearchRouterList.tsx | 57 +++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 3fe7cc9e2de4..d191afb89b93 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -17,6 +17,7 @@ import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; +import FastSearch from '@libs/FastSearch'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import type {SearchOption} from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; @@ -372,21 +373,67 @@ function SearchRouterList( }; }); + /** + * Builds a suffix tree and returns a function to search in it. + */ + const findInSearchTree = useMemo(() => { + const fastSearch = FastSearch.createFastSearch([ + { + data: searchOptions.personalDetails, + toSearchableString: (option) => { + const displayName = option.participantsList?.[0]?.displayName ?? ''; + return [option.login ?? '', option.login !== displayName ? displayName : ''].join(); + }, + }, + { + data: searchOptions.recentReports, + toSearchableString: (option) => { + const searchStringForTree = [option.text ?? '', option.login ?? '']; + + if (option.isThread) { + if (option.alternateText) { + searchStringForTree.push(option.alternateText); + } + } else if (!!option.isChatRoom || !!option.isPolicyExpenseChat) { + if (option.subtitle) { + searchStringForTree.push(option.subtitle); + } + } + + return searchStringForTree.join(); + }, + }, + ]); + function search(searchInput: string) { + const [personalDetails, recentReports] = fastSearch.search(searchInput); + + return { + personalDetails, + recentReports, + }; + } + + return search; + }, [searchOptions.personalDetails, searchOptions.recentReports]); + const recentReportsOptions = useMemo(() => { if (autocompleteQueryValue.trim() === '') { return searchOptions.recentReports.slice(0, 20); } Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS); - const filteredOptions = OptionsListUtils.filterAndOrderOptions(searchOptions, autocompleteQueryValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); + // const filteredOptions = OptionsListUtils.filterAndOrderOptions(searchOptions, autocompleteQueryValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); + const filteredOptions = findInSearchTree(autocompleteQueryValue); + const orderedOptions = OptionsListUtils.orderOptions(filteredOptions, autocompleteQueryValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); + const filteredUserToInvite = OptionsListUtils.filterUserToInvite(searchOptions, autocompleteQueryValue); Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); - const reportOptions: OptionData[] = [...filteredOptions.recentReports, ...filteredOptions.personalDetails]; - if (filteredOptions.userToInvite) { - reportOptions.push(filteredOptions.userToInvite); + const reportOptions: OptionData[] = [...orderedOptions.recentReports, ...orderedOptions.personalDetails]; + if (filteredUserToInvite) { + reportOptions.push(filteredUserToInvite); } return reportOptions.slice(0, 20); - }, [autocompleteQueryValue, searchOptions]); + }, [autocompleteQueryValue, findInSearchTree, searchOptions]); useEffect(() => { ReportUserActions.searchInServer(autocompleteQueryValue.trim()); From 1f64b5726f195836dbf8fc347f95a94814532778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 13 Dec 2024 16:49:55 +0100 Subject: [PATCH 12/17] cleanup tests --- tests/unit/OptionsListUtilsTest.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 8022934abf0c..352f91f14c9a 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -406,7 +406,6 @@ describe('OptionsListUtils', () => { beforeEach(() => { OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); - console.log(OPTIONS); OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); @@ -1002,11 +1001,9 @@ describe('OptionsListUtils', () => { }); it('should return prefix match before suffix match', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - console.log(options); + const options = OptionsListUtils.getSearchOptions(OPTIONS); const filteredOptions = OptionsListUtils.filterOptions(options, 'String'); - console.log(filteredOptions); expect(filteredOptions.personalDetails.length).toBe(2); expect(filteredOptions.personalDetails.at(0)?.text).toBe('String'); expect(filteredOptions.personalDetails.at(1)?.text).toBe('SubString'); From 928d3303973b6516b933b911f0a386d7a6818205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 13 Dec 2024 17:13:39 +0100 Subject: [PATCH 13/17] add `useFastSearchFromOptions` hook --- .../Search/SearchRouter/SearchRouterList.tsx | 51 ++--------- src/hooks/useFastSearchFromOptions.ts | 84 +++++++++++++++++++ src/libs/OptionsListUtils.ts | 6 +- 3 files changed, 93 insertions(+), 48 deletions(-) create mode 100644 src/hooks/useFastSearchFromOptions.ts diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index d191afb89b93..8292c1f5f1bf 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -12,12 +12,12 @@ import type {SearchQueryItem, SearchQueryListItemProps} from '@components/Select import type {SectionListDataType, SelectionListHandle, UserListItemProps} from '@components/SelectionList/types'; import UserListItem from '@components/SelectionList/UserListItem'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; +import useFastSearchFromOptions from '@hooks/useFastSearchFromOptions'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; -import FastSearch from '@libs/FastSearch'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import type {SearchOption} from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; @@ -376,45 +376,7 @@ function SearchRouterList( /** * Builds a suffix tree and returns a function to search in it. */ - const findInSearchTree = useMemo(() => { - const fastSearch = FastSearch.createFastSearch([ - { - data: searchOptions.personalDetails, - toSearchableString: (option) => { - const displayName = option.participantsList?.[0]?.displayName ?? ''; - return [option.login ?? '', option.login !== displayName ? displayName : ''].join(); - }, - }, - { - data: searchOptions.recentReports, - toSearchableString: (option) => { - const searchStringForTree = [option.text ?? '', option.login ?? '']; - - if (option.isThread) { - if (option.alternateText) { - searchStringForTree.push(option.alternateText); - } - } else if (!!option.isChatRoom || !!option.isPolicyExpenseChat) { - if (option.subtitle) { - searchStringForTree.push(option.subtitle); - } - } - - return searchStringForTree.join(); - }, - }, - ]); - function search(searchInput: string) { - const [personalDetails, recentReports] = fastSearch.search(searchInput); - - return { - personalDetails, - recentReports, - }; - } - - return search; - }, [searchOptions.personalDetails, searchOptions.recentReports]); + const filterOptions = useFastSearchFromOptions(searchOptions, {includeUserToInvite: true}); const recentReportsOptions = useMemo(() => { if (autocompleteQueryValue.trim() === '') { @@ -423,17 +385,16 @@ function SearchRouterList( Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS); // const filteredOptions = OptionsListUtils.filterAndOrderOptions(searchOptions, autocompleteQueryValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); - const filteredOptions = findInSearchTree(autocompleteQueryValue); + const filteredOptions = filterOptions(autocompleteQueryValue); const orderedOptions = OptionsListUtils.orderOptions(filteredOptions, autocompleteQueryValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); - const filteredUserToInvite = OptionsListUtils.filterUserToInvite(searchOptions, autocompleteQueryValue); Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); const reportOptions: OptionData[] = [...orderedOptions.recentReports, ...orderedOptions.personalDetails]; - if (filteredUserToInvite) { - reportOptions.push(filteredUserToInvite); + if (filteredOptions.userToInvite) { + reportOptions.push(filteredOptions.userToInvite); } return reportOptions.slice(0, 20); - }, [autocompleteQueryValue, findInSearchTree, searchOptions]); + }, [autocompleteQueryValue, filterOptions, searchOptions]); useEffect(() => { ReportUserActions.searchInServer(autocompleteQueryValue.trim()); diff --git a/src/hooks/useFastSearchFromOptions.ts b/src/hooks/useFastSearchFromOptions.ts new file mode 100644 index 000000000000..949d275cadee --- /dev/null +++ b/src/hooks/useFastSearchFromOptions.ts @@ -0,0 +1,84 @@ +import {useMemo} from 'react'; +import FastSearch from '@libs/FastSearch'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; + +type AllOrSelectiveOptions = OptionsListUtils.ReportAndPersonalDetailOptions | OptionsListUtils.Options; + +type Options = { + includeUserToInvite: boolean; +}; + +// You can either use this to search within report and personal details options +function useFastSearchFromOptions( + options: OptionsListUtils.ReportAndPersonalDetailOptions, + config: {includeUserToInvite: false}, +): (searchInput: string) => OptionsListUtils.ReportAndPersonalDetailOptions; +// Or you can use this to include the user invite option. This will require passing all options +function useFastSearchFromOptions(options: OptionsListUtils.Options, config: {includeUserToInvite: true}): (searchInput: string) => OptionsListUtils.Options; + +/** + * Hook for making options from OptionsListUtils searchable with FastSearch. + * + * @example + * ``` + * const options = OptionsListUtils.getSearchOptions(...); + * const filterOptions = useFastSearchFromOptions(options); + */ +function useFastSearchFromOptions( + options: OptionsListUtils.ReportAndPersonalDetailOptions | OptionsListUtils.Options, + {includeUserToInvite}: Options = {includeUserToInvite: false}, +): (searchInput: string) => AllOrSelectiveOptions { + const findInSearchTree = useMemo(() => { + const fastSearch = FastSearch.createFastSearch([ + { + data: options.personalDetails, + toSearchableString: (option) => { + const displayName = option.participantsList?.[0]?.displayName ?? ''; + return [option.login ?? '', option.login !== displayName ? displayName : ''].join(); + }, + }, + { + data: options.recentReports, + toSearchableString: (option) => { + const searchStringForTree = [option.text ?? '', option.login ?? '']; + + if (option.isThread) { + if (option.alternateText) { + searchStringForTree.push(option.alternateText); + } + } else if (!!option.isChatRoom || !!option.isPolicyExpenseChat) { + if (option.subtitle) { + searchStringForTree.push(option.subtitle); + } + } + + return searchStringForTree.join(); + }, + }, + ]); + function search(searchInput: string): AllOrSelectiveOptions { + const [personalDetails, recentReports] = fastSearch.search(searchInput); + + if (includeUserToInvite && 'currentUserOption' in options) { + const userToInvite = OptionsListUtils.filterUserToInvite(options, searchInput); + return { + personalDetails, + recentReports, + userToInvite, + currentUserOption: options.currentUserOption, + }; + } + + return { + personalDetails, + recentReports, + }; + } + + return search; + }, [includeUserToInvite, options]); + + return findInSearchTree; +} + +export default useFastSearchFromOptions; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index a38eb9e355ea..994c6b8cbeea 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -157,6 +157,8 @@ type OrderOptionsConfig = { preferRecentExpenseReports?: boolean; }; +type ReportAndPersonalDetailOptions = Pick; + /** * OptionsListUtils is used to build a list options passed to the OptionsList component. Several different UI views can * be configured to display different results based on the options passed to the private getOptions() method. Public @@ -993,8 +995,6 @@ function sortComparatorReportOptionByDate(options: ReportUtils.OptionData) { return options.lastVisibleActionCreated ?? ''; } -type ReportAndPersonalDetailOptions = Pick; - function orderOptions(options: ReportAndPersonalDetailOptions): ReportAndPersonalDetailOptions; function orderOptions(options: ReportAndPersonalDetailOptions, searchValue: string, config?: OrderOptionsConfig): ReportAndPersonalDetailOptions; function orderOptions(options: ReportAndPersonalDetailOptions, searchValue?: string, config?: OrderOptionsConfig) { @@ -1840,4 +1840,4 @@ export { hasReportErrors, }; -export type {Section, SectionBase, MemberForList, Options, OptionList, SearchOption, PayeePersonalDetails, Option, OptionTree}; +export type {Section, SectionBase, MemberForList, Options, OptionList, SearchOption, PayeePersonalDetails, Option, OptionTree, ReportAndPersonalDetailOptions}; From eebf63857f5664fcf8255981d6e14bcb431e5925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 13 Dec 2024 17:28:07 +0100 Subject: [PATCH 14/17] remove comment --- src/components/Search/SearchRouter/SearchRouterList.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 8292c1f5f1bf..f41c48520878 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -384,7 +384,6 @@ function SearchRouterList( } Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS); - // const filteredOptions = OptionsListUtils.filterAndOrderOptions(searchOptions, autocompleteQueryValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); const filteredOptions = filterOptions(autocompleteQueryValue); const orderedOptions = OptionsListUtils.orderOptions(filteredOptions, autocompleteQueryValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); From 31ae881fd17139772073914c1277af6b8e8322ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 13 Dec 2024 17:46:41 +0100 Subject: [PATCH 15/17] remove unnecessary test case --- tests/unit/OptionsListUtilsTest.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 352f91f14c9a..8916b7c3bac8 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -223,18 +223,6 @@ describe('OptionsListUtils', () => { login: 'brucebanner@expensify.com', reportID: '', }, - '110': { - accountID: 110, - displayName: 'SubString', - login: 'SubString@mail.com', - reportID: '', - }, - '111': { - accountID: 111, - displayName: 'String', - login: 'String@mail.com', - reportID: '', - }, }; const REPORTS_WITH_CONCIERGE: OnyxCollection = { @@ -999,15 +987,6 @@ describe('OptionsListUtils', () => { expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); }); }); - - it('should return prefix match before suffix match', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS); - const filteredOptions = OptionsListUtils.filterOptions(options, 'String'); - - expect(filteredOptions.personalDetails.length).toBe(2); - expect(filteredOptions.personalDetails.at(0)?.text).toBe('String'); - expect(filteredOptions.personalDetails.at(1)?.text).toBe('SubString'); - }); }); describe('canCreateOptimisticPersonalDetailOption', () => { From 9cba20a47b6e08952b56e9fe5f06ac827cdaa303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 13 Dec 2024 17:46:51 +0100 Subject: [PATCH 16/17] add docs --- src/hooks/useFastSearchFromOptions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/useFastSearchFromOptions.ts b/src/hooks/useFastSearchFromOptions.ts index 949d275cadee..1d1151786e9d 100644 --- a/src/hooks/useFastSearchFromOptions.ts +++ b/src/hooks/useFastSearchFromOptions.ts @@ -18,6 +18,7 @@ function useFastSearchFromOptions(options: OptionsListUtils.Options, config: {in /** * Hook for making options from OptionsListUtils searchable with FastSearch. + * Builds a suffix tree and returns a function to search in it. * * @example * ``` From 555e884847cf4014057cf40a7a049a6694d08355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 13 Dec 2024 17:57:45 +0100 Subject: [PATCH 17/17] remove obsolete test --- tests/unit/SuffixUkkonenTreeTest.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/unit/SuffixUkkonenTreeTest.ts b/tests/unit/SuffixUkkonenTreeTest.ts index 0cf34fff3308..c0c556c16e14 100644 --- a/tests/unit/SuffixUkkonenTreeTest.ts +++ b/tests/unit/SuffixUkkonenTreeTest.ts @@ -60,13 +60,4 @@ describe('SuffixUkkonenTree', () => { // "2" in ASCII is 50, so base26(50) = [0, 23] expect(Array.from(numeric)).toEqual([SuffixUkkonenTree.SPECIAL_CHAR_CODE, 0, 23]); }); - - it('should have prefix matches first, then substring matches', () => { - const strings = ['abcdef', 'cdef', 'def']; - const numericIntArray = helperStringsToNumericForTree(strings); - const tree = SuffixUkkonenTree.makeTree(numericIntArray); - tree.build(); - const searchValue = SuffixUkkonenTree.stringToNumeric('def', {clamp: true}).numeric; - expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([18, 15, 10])); - }); });