Skip to content

Commit

Permalink
Merge pull request #2 from LaunchPlatform/fuzzy-match
Browse files Browse the repository at this point in the history
Fuzzy match
  • Loading branch information
fangpenlin authored Sep 20, 2024
2 parents 35599eb + 35c38cf commit 714c8d8
Show file tree
Hide file tree
Showing 13 changed files with 509 additions and 44 deletions.
5 changes: 5 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ jobs:
- add_yarn_binaries_to_path
- restore_yarn_cache:
platform: linux
- run:
name: Run tests
command: |
yarn
yarn jest
- run:
name: Run build
command: |
Expand Down
93 changes: 93 additions & 0 deletions __tests__/fuzzyMatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { it, expect, describe } from "@jest/globals";
import { fuzzyMatch } from "../src/TransactionForm/fuzzyMatch";
import { MatchedText } from "../src/TransactionForm/PostingCandidateList";

describe.each([
[
"ABC",
"abc",
[
{
text: "ABC",
matched: true,
},
] as Array<MatchedText>,
],
[
"abc",
"ABC",
[
{
text: "abc",
matched: true,
},
] as Array<MatchedText>,
],
[
"aBcDeF",
"ABcE",
[
{
text: "aBc",
matched: true,
},
{
text: "D",
matched: false,
},
{
text: "e",
matched: true,
},
{
text: "F",
matched: false,
},
] as Array<MatchedText>,
],
[
"Assets:Bank",
"ab",
[
{
text: "A",
matched: true,
},
{
text: "ssets:",
matched: false,
},
{
text: "B",
matched: true,
},
{
text: "ank",
matched: false,
},
] as Array<MatchedText>,
],
[
"Assets:Bank",
"a",
[
{
text: "A",
matched: true,
},
{
text: "ssets:Bank",
matched: false,
},
] as Array<MatchedText>,
],
["Assets:Bank", "", null],
["Assets:Bank", "xyz", null],
["Assets:Bank", "aaa", null],
])(".fuzzyMatch(%s, %s, %s)", (text, keyword, expected) => {
it(`fuzzyMatch(${JSON.stringify(text)}, ${JSON.stringify(
keyword
)}, ${JSON.stringify(expected)})`, () => {
expect(fuzzyMatch(text, keyword)).toEqual(expected);
});
});
7 changes: 7 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
};

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"storybook": "start-storybook -p 8088"
"storybook": "start-storybook -p 8088",
"jest": "jest"
},
"eslintConfig": {
"extends": [
Expand All @@ -45,6 +46,7 @@
},
"devDependencies": {
"@babel/core": "^7.17.5",
"@babel/preset-typescript": "^7.24.7",
"@storybook/addon-actions": "^6.4.19",
"@storybook/addon-toolbars": "^6.4.19",
"@storybook/react": "^6.4.19",
Expand Down
8 changes: 8 additions & 0 deletions src/Shared/Selection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { FunctionComponent } from "react";
import Select, { GroupBase, Props as SelectProps } from "react-select";
import CreatableSelect from "react-select/creatable";
import FormRow from "./FormRow";
import { fuzzyMatch } from "../TransactionForm/fuzzyMatch";

export interface Props {
readonly title: string;
Expand Down Expand Up @@ -55,6 +56,13 @@ const SelectionInput: FunctionComponent<Props> = ({
name={name}
creatable={creatable}
className={error !== undefined ? "is-invalid" : ""}
filterOption={(options, keyword) => {
if (keyword.trim().length === 0) {
return true;
}
const matchedPieces = fuzzyMatch(options.value, keyword);
return matchedPieces !== null;
}}
styles={{
option: (provided, state) => ({
...provided,
Expand Down
12 changes: 6 additions & 6 deletions src/TransactionForm/PostingCandidate.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import React, { FunctionComponent } from "react";
import { MatchedText } from "./PostingCandidateList";

export interface Props {
readonly prefix: string;
readonly suffix: string;
readonly matchedPieces: Array<MatchedText>;
readonly active?: boolean;
readonly first?: boolean;
readonly onClick?: () => void;
}

const PostingCandidate: FunctionComponent<Props> = ({
prefix,
suffix,
matchedPieces,
active,
first,
onClick,
Expand All @@ -34,8 +33,9 @@ const PostingCandidate: FunctionComponent<Props> = ({
...(first ? { borderTopLeftRadius: 0, borderTopRightRadius: 0 } : {}),
}}
>
<strong>{prefix}</strong>
{suffix}
{matchedPieces.map((piece) =>
piece.matched ? <strong>{piece.text}</strong> : piece.text
)}
</a>
);

Expand Down
11 changes: 7 additions & 4 deletions src/TransactionForm/PostingCandidateList.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import React, { CSSProperties, FunctionComponent } from "react";
import PostingCandidate from "./PostingCandidate";

export interface MatchedText {
readonly text: string;
readonly matched: boolean;
}

export interface Candidate {
readonly prefix: string;
readonly suffix: string;
readonly value: string;
readonly matchedPieces: Array<MatchedText>;
}

export interface Props {
Expand All @@ -24,8 +28,7 @@ const PostingCandidateList: FunctionComponent<Props> = ({
{candidates.map((candidate, index) => (
<PostingCandidate
key={candidate.value}
prefix={candidate.prefix}
suffix={candidate.suffix}
matchedPieces={candidate.matchedPieces}
active={index === activeIndex}
first={index === 0}
onClick={() => onClick?.(candidate.value)}
Expand Down
14 changes: 5 additions & 9 deletions src/TransactionForm/PostingInput.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { FunctionComponent, KeyboardEvent } from "react";
import PostingCandidateList from "./PostingCandidateList";
import PostingCandidateList, { MatchedText } from "./PostingCandidateList";

export enum PriceMode {
INACTIVE = "INACTIVE",
Expand All @@ -10,9 +10,8 @@ export enum PriceMode {
}

export interface Candidate {
readonly prefix: string;
readonly suffix: string;
readonly value: string;
readonly matchedPieces: Array<MatchedText>;
}

export interface Props {
Expand Down Expand Up @@ -152,8 +151,7 @@ const PostingInput: FunctionComponent<Props> = ({
(item) =>
({
value: item.value,
prefix: item.prefix,
suffix: item.suffix,
matchedPieces: item.matchedPieces,
} as Candidate)
)}
onClick={(value) => onAccountCandidateClick?.(value)}
Expand Down Expand Up @@ -219,8 +217,7 @@ const PostingInput: FunctionComponent<Props> = ({
(item) =>
({
value: item.value,
prefix: item.prefix,
suffix: item.suffix,
matchedPieces: item.matchedPieces,
} as Candidate)
)}
onClick={(value) => onUnitCurrencyCandidateClick?.(value)}
Expand Down Expand Up @@ -316,8 +313,7 @@ const PostingInput: FunctionComponent<Props> = ({
(item) =>
({
value: item.value,
prefix: item.prefix,
suffix: item.suffix,
matchedPieces: item.matchedPieces,
} as Candidate)
)}
onClick={(value) => onPriceCurrencyCandidateClick?.(value)}
Expand Down
27 changes: 14 additions & 13 deletions src/TransactionForm/PostingInputContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React, {
} from "react";
import { Candidate } from "./PostingCandidateList";
import PostingInput, { PriceMode } from "./PostingInput";
import { fuzzyMatch } from "./fuzzyMatch";

export interface Props {
readonly index: number;
Expand Down Expand Up @@ -70,19 +71,19 @@ const useAutoComplete = (
const matchedValues: Array<Candidate> = useMemo(
() =>
candidateValues
.filter((candidateValue) =>
candidateValue.toLowerCase().startsWith(lowerTrimedValue)
)
.map(
(candidateValue) =>
({
value: candidateValue,
prefix: candidateValue.substring(0, lowerTrimedValue.length),
suffix: candidateValue.substring(
lowerTrimedValue.length,
candidateValue.length
),
} as Candidate)
.map((candidateValue) => {
const matchedPieces = fuzzyMatch(candidateValue, lowerTrimedValue);
if (matchedPieces === null) {
return null;
}
return {
value: candidateValue,
matchedPieces,
} as Candidate;
})
.filter(
(candidate: Candidate | null): candidate is Candidate =>
candidate !== null
),
[lowerTrimedValue, candidateValues]
);
Expand Down
43 changes: 43 additions & 0 deletions src/TransactionForm/fuzzyMatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { MatchedText } from "./PostingCandidateList";

export const fuzzyMatch = (
value: string,
keyword: string
): Array<MatchedText> | null => {
const pieces: Array<MatchedText> = [];
const chars = value.split("");
let i = 0;
let j = 0;
let chunks = [];
let matched = false;
let firstMatch = false;
for (; i < chars.length; ++i) {
const char = chars.at(i);
if (char?.toLowerCase() === keyword.substring(j, j + 1).toLowerCase()) {
j += 1;
if (chunks.length > 0 && !matched) {
pieces.push({ text: chunks.join(""), matched });
chunks = [];
}
matched = true;
firstMatch = true;
} else {
if (chunks.length > 0 && matched) {
pieces.push({ text: chunks.join(""), matched });
chunks = [];
}
matched = false;
}
chunks.push(char);
}
if (!firstMatch) {
return null;
}
if (chunks.length > 0) {
pieces.push({ text: chunks.join(""), matched });
}
if (j < keyword.length) {
return null;
}
return pieces;
};
29 changes: 26 additions & 3 deletions storybook/TransactionForm/PostingCandidateList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,32 @@ export const Primary: ComponentStory<typeof PostingCandidateList> = () => (
<PostingCandidateList
activeIndex={0}
candidates={[
{ value: "Assets", prefix: "Assets", suffix: "" },
{ value: "Assets:Bank", prefix: "Assets", suffix: ":Bank" },
{ value: "Assets:Cash", prefix: "Assets", suffix: ":Cash" },
{
value: "Assets",
matchedPieces: [
{ text: "A", matched: true },
{ text: "sse", matched: false },
{ text: "ts", matched: true },
],
},
{
value: "Assets:Bank",
matchedPieces: [
{ text: "A", matched: true },
{ text: "sse", matched: false },
{ text: "ts", matched: true },
{ text: ":Bank", matched: false },
],
},
{
value: "Assets:Cash",
matchedPieces: [
{ text: "A", matched: true },
{ text: "sse", matched: false },
{ text: "ts", matched: true },
{ text: ":Cash", matched: false },
],
},
]}
onClick={action("onClick")}
/>
Expand Down
Loading

0 comments on commit 714c8d8

Please sign in to comment.