Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Intuitive pair search #574

Merged
merged 8 commits into from
Sep 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions src/app/components/PairSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down
225 changes: 225 additions & 0 deletions src/app/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
saidam90 marked this conversation as resolved.
Show resolved Hide resolved
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", () => {
saidam90 marked this conversation as resolved.
Show resolved Hide resolved
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",
},
},
]);
});
});
84 changes: 84 additions & 0 deletions src/app/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
});
}
Loading