diff --git a/src/app/components/PairSelector.tsx b/src/app/components/PairSelector.tsx index 7e2381bb..06d646a3 100644 --- a/src/app/components/PairSelector.tsx +++ b/src/app/components/PairSelector.tsx @@ -4,6 +4,7 @@ import { orderInputSlice } from "../state/orderInputSlice"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Image from "next/image"; import React from "react"; +import { searchPairs } from "utils"; import { BLACKLISTED_PAIRS } from "../data/BLACKLISTED_PAIRS"; @@ -163,13 +164,9 @@ export function PairSelector() { }, [handleKeyDown]); const onQueryChange = (userInputQuery: string) => { - const sortedOptions = sortOptions( - options.filter( - (option) => - option["name"].toLowerCase().indexOf(userInputQuery.toLowerCase()) > - -1 - ) - ); + const filteredPairs = searchPairs(userInputQuery, options); + const sortedOptions = sortOptions(filteredPairs); + setFilteredOptions(sortedOptions); setQuery(userInputQuery); // Reset the current selected option to the first one that is available diff --git a/src/app/utils.test.ts b/src/app/utils.test.ts index 3847a81c..9daf8884 100644 --- a/src/app/utils.test.ts +++ b/src/app/utils.test.ts @@ -6,6 +6,8 @@ import { shortenWalletAddress, } from "./utils"; +import { searchPairs } from "./utils"; + // the separators are set to "." and " " for testing purposes // inside jest.setup.js describe("displayAmount", () => { @@ -344,3 +346,226 @@ describe("shortenWalletAddress", () => { expect(result).toBe(expectedShortened); }); }); + +describe("searchPairs", () => { + const pairsList: any = [ + { + name: "DEXTR/XRD", + token1: { + name: "Dexter", + symbol: "DEXTR", + }, + token2: { + name: "Radix", + symbol: "XRD", + }, + }, + { + name: "ADEX/XRD", + token1: { + name: "Adex", + symbol: "ADEX", + }, + token2: { + name: "Radix", + symbol: "XRD", + }, + }, + { + name: "CUPPA/XRD", + token1: { + name: "Cuppa", + symbol: "CUPPA", + }, + token2: { + name: "Radix", + symbol: "XRD", + }, + }, + ]; + + test("should find pairs by full name", () => { + const result = searchPairs("Radix", pairsList); + expect(result).toEqual([ + { + name: "DEXTR/XRD", + token1: { + name: "Dexter", + symbol: "DEXTR", + }, + token2: { + name: "Radix", + symbol: "XRD", + }, + }, + { + name: "ADEX/XRD", + token1: { + name: "Adex", + symbol: "ADEX", + }, + token2: { + name: "Radix", + symbol: "XRD", + }, + }, + { + name: "CUPPA/XRD", + token1: { + name: "Cuppa", + symbol: "CUPPA", + }, + token2: { + name: "Radix", + symbol: "XRD", + }, + }, + ]); + }); + + test("should find pairs by symbol", () => { + const result = searchPairs("DEXTR", pairsList); + expect(result).toEqual([ + { + name: "DEXTR/XRD", + token1: { + name: "Dexter", + symbol: "DEXTR", + }, + token2: { + name: "Radix", + symbol: "XRD", + }, + }, + ]); + }); + + test("should find pairs by symbol and full name with different case", () => { + const result = searchPairs("cupPa", pairsList); + expect(result).toEqual([ + { + name: "CUPPA/XRD", + token1: { + name: "Cuppa", + symbol: "CUPPA", + }, + token2: { + name: "Radix", + symbol: "XRD", + }, + }, + ]); + }); + + test("should find pairs with space as delimiter", () => { + const result = searchPairs("DEXTR XRD", pairsList); + expect(result).toEqual([ + { + name: "DEXTR/XRD", + token1: { + name: "Dexter", + symbol: "DEXTR", + }, + token2: { + name: "Radix", + symbol: "XRD", + }, + }, + ]); + }); + + test("should find pairs with swapped order of tokens", () => { + const result = searchPairs("XRD DEXTR", pairsList); + expect(result).toEqual([ + { + name: "DEXTR/XRD", + token1: { + name: "Dexter", + symbol: "DEXTR", + }, + token2: { + name: "Radix", + symbol: "XRD", + }, + }, + ]); + }); + + test("should handle minimal typos", () => { + const result = searchPairs("D3XTR", pairsList); + expect(result).toEqual([ + { + name: "DEXTR/XRD", + token1: { + name: "Dexter", + symbol: "DEXTR", + }, + token2: { + name: "Radix", + symbol: "XRD", + }, + }, + ]); + }); + + test("should return an empty array if no matches are found", () => { + const result = searchPairs("XYZ", pairsList); + expect(result).toEqual([]); + }); + + test("name with different case", () => { + const result = searchPairs("RaDIx", pairsList); + expect(result).toEqual([ + { + name: "DEXTR/XRD", + token1: { + name: "Dexter", + symbol: "DEXTR", + }, + token2: { + name: "Radix", + symbol: "XRD", + }, + }, + { + name: "ADEX/XRD", + token1: { + name: "Adex", + symbol: "ADEX", + }, + token2: { + name: "Radix", + symbol: "XRD", + }, + }, + { + name: "CUPPA/XRD", + token1: { + name: "Cuppa", + symbol: "CUPPA", + }, + token2: { + name: "Radix", + symbol: "XRD", + }, + }, + ]); + }); + + test("should find pairs by full name and lowercased", () => { + const result = searchPairs("Radix dexter", pairsList); + expect(result).toEqual([ + { + name: "DEXTR/XRD", + token1: { + name: "Dexter", + symbol: "DEXTR", + }, + token2: { + name: "Radix", + symbol: "XRD", + }, + }, + ]); + }); +}); diff --git a/src/app/utils.ts b/src/app/utils.ts index e540f6a9..56d38f1f 100644 --- a/src/app/utils.ts +++ b/src/app/utils.ts @@ -540,3 +540,87 @@ export function getLocalStoragePaginationValue(id?: string) { return undefined; } + +// TODO: Update input and return types to `PairInfo[]`. Currently using `any` +// due to unresolved issues. Investigate the cause of the problem. +export function searchPairs(query: string, pairsList: any): any { + const searchQuery = query.trim().toLowerCase().replace(/\s+/g, " "); + + function levenshteinDistance(a: string, b: string): number { + const matrix: number[][] = Array.from({ length: a.length + 1 }, () => []); + + for (let i = 0; i <= a.length; i++) { + matrix[i][0] = i; + } + for (let j = 0; j <= b.length; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + if (a[i - 1] === b[j - 1]) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + 1 + ); + } + } + } + + return matrix[a.length][b.length]; + } + + const hasTypoTolerance = (source: string, target: string): boolean => { + const maxTyposAllowed = Math.floor(source.length / 5); + const distance = levenshteinDistance(source, target); + return distance <= maxTyposAllowed; + }; + + const preprocessPairName = (name: string): string => + name.toLowerCase().replace(/\//g, " "); + + const preprocessToken = (token: { symbol: string; name: string }) => ({ + symbol: token.symbol.toLowerCase(), + name: token.name.toLowerCase(), + }); + + const generateCombinations = (items: string[]): string[] => { + const combinations: string[] = []; + for (let i = 0; i < items.length; i++) { + for (let j = i + 1; j < items.length; j++) { + combinations.push(`${items[i]} ${items[j]}`); + combinations.push(`${items[j]} ${items[i]}`); + } + } + return combinations; + }; + + return pairsList.filter((pair: any) => { + const pairName = preprocessPairName(pair.name); + const pairNameReversed = pairName.split(" ").reverse().join(" "); + const token1 = preprocessToken(pair.token1); + const token2 = preprocessToken(pair.token2); + + const baseMatches = [ + pairName, + pairNameReversed, + token1.symbol, + token2.symbol, + token1.name, + token2.name, + ]; + + const dynamicMatches = generateCombinations(baseMatches); + + const nameMatches = [...baseMatches, ...dynamicMatches]; + + return nameMatches.some( + (nameMatch) => + nameMatch.includes(searchQuery) || + hasTypoTolerance(searchQuery, nameMatch) + ); + }); +}