From de7002cf64d31b851c5b64d36ccfc762a7e2b42b Mon Sep 17 00:00:00 2001 From: saidam90 Date: Mon, 2 Sep 2024 01:16:30 +0400 Subject: [PATCH 1/5] Add search functionality --- src/app/components/PairSelector.tsx | 74 ++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/src/app/components/PairSelector.tsx b/src/app/components/PairSelector.tsx index 7e2381bb..f5d97d13 100644 --- a/src/app/components/PairSelector.tsx +++ b/src/app/components/PairSelector.tsx @@ -162,14 +162,74 @@ export function PairSelector() { }; }, [handleKeyDown]); + const searchPairs = (query: string, pairsList: PairInfo[]): PairInfo[] => { + const searchQuery = query.trim().toLowerCase().replace(/\s+/g, " "); + + const hasTypoTolerance = (source: string, target: string): boolean => { + const maxTyposAllowed = Math.floor(source.length / 4); + + if (source.length > target.length + maxTyposAllowed) { + return false; + } + + if (target.includes(source)) { + return true; + } + + // Check for typo tolerance + for (let i = 0; i <= target.length - source.length; i++) { + const substring = target.substring(i, i + source.length); + let mismatchCount = 0; + for (let j = 0; j < source.length; j++) { + if (source[j] !== substring[j]) { + mismatchCount++; + } + } + if (mismatchCount <= maxTyposAllowed) { + return true; + } + } + + return false; + }; + + const preprocessPairName = (name: string): string => + name.toLowerCase().replace(/\//g, " "); + const preprocessToken = (token: { + symbol: string; + name: string; + }): { symbol: string; name: string } => ({ + symbol: token.symbol.toLowerCase(), + name: token.name.toLowerCase(), + }); + + return pairsList.filter((pair) => { + const pairName = preprocessPairName(pair.name); + const pairNameReversed = pairName.split(" ").reverse().join(" "); + const token1 = preprocessToken(pair.token1); + const token2 = preprocessToken(pair.token2); + + const nameMatches = [ + pairName, + pairNameReversed, + token1.symbol, + token2.symbol, + token1.name, + token2.name, + ]; + + return nameMatches.some( + (nameMatch) => + nameMatch.includes(searchQuery) || + hasTypoTolerance(searchQuery, nameMatch) + ); + }); + }; + 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 From 144b2866ffcde1718ddb08063c7f8a507997d5d9 Mon Sep 17 00:00:00 2001 From: saidam90 Date: Mon, 2 Sep 2024 02:37:04 +0400 Subject: [PATCH 2/5] Add tests and move the functio to utils.ts --- src/app/components/PairSelector.tsx | 65 +----------- src/app/utils.test.ts | 147 ++++++++++++++++++++++++++++ src/app/utils.ts | 63 ++++++++++++ 3 files changed, 211 insertions(+), 64 deletions(-) diff --git a/src/app/components/PairSelector.tsx b/src/app/components/PairSelector.tsx index f5d97d13..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"; @@ -162,70 +163,6 @@ export function PairSelector() { }; }, [handleKeyDown]); - const searchPairs = (query: string, pairsList: PairInfo[]): PairInfo[] => { - const searchQuery = query.trim().toLowerCase().replace(/\s+/g, " "); - - const hasTypoTolerance = (source: string, target: string): boolean => { - const maxTyposAllowed = Math.floor(source.length / 4); - - if (source.length > target.length + maxTyposAllowed) { - return false; - } - - if (target.includes(source)) { - return true; - } - - // Check for typo tolerance - for (let i = 0; i <= target.length - source.length; i++) { - const substring = target.substring(i, i + source.length); - let mismatchCount = 0; - for (let j = 0; j < source.length; j++) { - if (source[j] !== substring[j]) { - mismatchCount++; - } - } - if (mismatchCount <= maxTyposAllowed) { - return true; - } - } - - return false; - }; - - const preprocessPairName = (name: string): string => - name.toLowerCase().replace(/\//g, " "); - const preprocessToken = (token: { - symbol: string; - name: string; - }): { symbol: string; name: string } => ({ - symbol: token.symbol.toLowerCase(), - name: token.name.toLowerCase(), - }); - - return pairsList.filter((pair) => { - const pairName = preprocessPairName(pair.name); - const pairNameReversed = pairName.split(" ").reverse().join(" "); - const token1 = preprocessToken(pair.token1); - const token2 = preprocessToken(pair.token2); - - const nameMatches = [ - pairName, - pairNameReversed, - token1.symbol, - token2.symbol, - token1.name, - token2.name, - ]; - - return nameMatches.some( - (nameMatch) => - nameMatch.includes(searchQuery) || - hasTypoTolerance(searchQuery, nameMatch) - ); - }); - }; - const onQueryChange = (userInputQuery: string) => { const filteredPairs = searchPairs(userInputQuery, options); const sortedOptions = sortOptions(filteredPairs); diff --git a/src/app/utils.test.ts b/src/app/utils.test.ts index 3847a81c..ec652974 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,148 @@ 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("DEXTR/XRD", pairsList); + expect(result).toEqual([ + { + name: "DEXTR/XRD", + token1: { + name: "Dexter", + symbol: "DEXTR", + }, + 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([]); + }); +}); diff --git a/src/app/utils.ts b/src/app/utils.ts index e540f6a9..4ac0084d 100644 --- a/src/app/utils.ts +++ b/src/app/utils.ts @@ -540,3 +540,66 @@ export function getLocalStoragePaginationValue(id?: string) { return undefined; } + +export const searchPairs = (query: string, pairsList: any): any => { + const searchQuery = query.trim().toLowerCase().replace(/\s+/g, " "); + + const hasTypoTolerance = (source: string, target: string): boolean => { + const maxTyposAllowed = Math.floor(source.length / 4); + + if (source.length > target.length + maxTyposAllowed) { + return false; + } + + if (target.includes(source)) { + return true; + } + + for (let i = 0; i <= target.length - source.length; i++) { + const substring = target.substring(i, i + source.length); + let mismatchCount = 0; + for (let j = 0; j < source.length; j++) { + if (source[j] !== substring[j]) { + mismatchCount++; + } + } + if (mismatchCount <= maxTyposAllowed) { + return true; + } + } + + return false; + }; + + const preprocessPairName = (name: string): string => + name.toLowerCase().replace(/\//g, " "); + const preprocessToken = (token: { + symbol: string; + name: string; + }): { symbol: string; name: string } => ({ + symbol: token.symbol.toLowerCase(), + name: token.name.toLowerCase(), + }); + + 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 nameMatches = [ + pairName, + pairNameReversed, + token1.symbol, + token2.symbol, + token1.name, + token2.name, + ]; + + return nameMatches.some( + (nameMatch) => + nameMatch.includes(searchQuery) || + hasTypoTolerance(searchQuery, nameMatch) + ); + }); +}; From 9419ff316372e77c7e35c8d051f2c0ee2fc58bce Mon Sep 17 00:00:00 2001 From: saidam90 Date: Mon, 2 Sep 2024 12:04:09 +0400 Subject: [PATCH 3/5] Implement typo tolerance using Levenshtein distance and add more tests --- src/app/utils.test.ts | 80 ++++++++++++++++++++++++++++++++++++++++++- src/app/utils.ts | 68 +++++++++++++++++++++++------------- 2 files changed, 123 insertions(+), 25 deletions(-) diff --git a/src/app/utils.test.ts b/src/app/utils.test.ts index ec652974..9daf8884 100644 --- a/src/app/utils.test.ts +++ b/src/app/utils.test.ts @@ -385,7 +385,7 @@ describe("searchPairs", () => { ]; test("should find pairs by full name", () => { - const result = searchPairs("DEXTR/XRD", pairsList); + const result = searchPairs("Radix", pairsList); expect(result).toEqual([ { name: "DEXTR/XRD", @@ -398,6 +398,28 @@ describe("searchPairs", () => { 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", + }, + }, ]); }); @@ -490,4 +512,60 @@ describe("searchPairs", () => { 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 4ac0084d..89193fa9 100644 --- a/src/app/utils.ts +++ b/src/app/utils.ts @@ -541,53 +541,69 @@ export function getLocalStoragePaginationValue(id?: string) { return undefined; } -export const searchPairs = (query: string, pairsList: any): any => { +// 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, " "); - const hasTypoTolerance = (source: string, target: string): boolean => { - const maxTyposAllowed = Math.floor(source.length / 4); + function levenshteinDistance(a: string, b: string): number { + const matrix: number[][] = Array.from({ length: a.length + 1 }, () => []); - if (source.length > target.length + maxTyposAllowed) { - return false; + for (let i = 0; i <= a.length; i++) { + matrix[i][0] = i; } - - if (target.includes(source)) { - return true; + for (let j = 0; j <= b.length; j++) { + matrix[0][j] = j; } - for (let i = 0; i <= target.length - source.length; i++) { - const substring = target.substring(i, i + source.length); - let mismatchCount = 0; - for (let j = 0; j < source.length; j++) { - if (source[j] !== substring[j]) { - mismatchCount++; + 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, // deletion + matrix[i][j - 1] + 1, // insertion + matrix[i - 1][j - 1] + 1 // substitution + ); } } - if (mismatchCount <= maxTyposAllowed) { - return true; - } } - return false; + 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: string; name: string } => ({ + + 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 nameMatches = [ + const baseMatches = [ pairName, pairNameReversed, token1.symbol, @@ -596,10 +612,14 @@ export const searchPairs = (query: string, pairsList: any): any => { token2.name, ]; + const dynamicMatches = generateCombinations(baseMatches); + + const nameMatches = [...baseMatches, ...dynamicMatches]; + return nameMatches.some( (nameMatch) => nameMatch.includes(searchQuery) || hasTypoTolerance(searchQuery, nameMatch) ); }); -}; +} From 1f7ba6070541185d99dc41e6130faf026f238210 Mon Sep 17 00:00:00 2001 From: saidam90 Date: Mon, 2 Sep 2024 14:03:55 +0400 Subject: [PATCH 4/5] Delete commented out code --- src/app/utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/utils.ts b/src/app/utils.ts index 89193fa9..81c9008d 100644 --- a/src/app/utils.ts +++ b/src/app/utils.ts @@ -561,9 +561,9 @@ export function searchPairs(query: string, pairsList: any): any { matrix[i][j] = matrix[i - 1][j - 1]; } else { matrix[i][j] = Math.min( - matrix[i - 1][j] + 1, // deletion - matrix[i][j - 1] + 1, // insertion - matrix[i - 1][j - 1] + 1 // substitution + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + 1 ); } } From af6d625babd6b558eca913f4363051ea1650322e Mon Sep 17 00:00:00 2001 From: saidam90 Date: Tue, 3 Sep 2024 08:33:35 +0400 Subject: [PATCH 5/5] Fix copy paste error --- src/app/utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/utils.ts b/src/app/utils.ts index 81c9008d..56d38f1f 100644 --- a/src/app/utils.ts +++ b/src/app/utils.ts @@ -541,7 +541,8 @@ 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. +// 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, " ");