Skip to content

Commit a831873

Browse files
committed
implement search box using fuzzy search
1 parent 076e595 commit a831873

File tree

6 files changed

+85
-37
lines changed

6 files changed

+85
-37
lines changed

src/app.tsx

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,15 @@ import { fetchStockRsList } from './services/data.service';
33
import { Stock } from './models/stock';
44
import { Box, Show, Skeleton } from '@chakra-ui/react';
55
import { DataTable } from './components/app/data-table';
6-
import { initialFilter } from './utils/table.util';
76
import { Topbar } from './components/app/topbar';
8-
import { useAtomValue } from 'jotai';
9-
import { appSettingsAtom } from './state/atom';
10-
// import { useEventListener } from 'usehooks-ts';
7+
import { useAtomValue, useSetAtom } from 'jotai';
8+
import { filteredStockListAtom, stockListAtom } from './state/atom';
119

1210
const App: FC = () => {
13-
const settings = useAtomValue(appSettingsAtom);
14-
const [stockList, setStockList] = useState<Stock[]>([]);
15-
const [filteredStockList, setFilteredStockList] = useState<Stock[]>([]);
1611
const [error, setError] = useState<null | string>(null);
17-
18-
// useEventListener('keydown', (event) => {
19-
// if (/[A-Za-z- ]/.test(event.key)) {
20-
// console.log('gonna open dialog => ', event.key);
21-
// }
22-
// });
12+
const [loading, setLoading] = useState<boolean>(true);
13+
const setStockList = useSetAtom(stockListAtom);
14+
const filteredStockList = useAtomValue(filteredStockListAtom);
2315

2416
// console.log('render app');
2517

@@ -33,15 +25,9 @@ const App: FC = () => {
3325
.catch((e) => {
3426
console.error(e);
3527
setError('Something went wrong. Please try again later.');
36-
});
37-
}, []);
38-
39-
useEffect(() => {
40-
if (stockList.length > 0) {
41-
const tmpList = initialFilter(stockList, settings);
42-
setFilteredStockList(tmpList);
43-
}
44-
}, [settings, stockList]);
28+
})
29+
.finally(() => setLoading(false));
30+
}, [setStockList]);
4531

4632
if (error) {
4733
return error;
@@ -50,10 +36,10 @@ const App: FC = () => {
5036
return (
5137
<Box>
5238
<Topbar />
53-
<Show when={filteredStockList.length > 0}>
39+
<Show when={!loading}>
5440
<DataTable data={filteredStockList}></DataTable>
5541
</Show>
56-
<Show when={filteredStockList.length === 0}>
42+
<Show when={loading}>
5743
<Skeleton flex="1" height="4" variant="pulse" marginY={4} />
5844
<Skeleton flex="1" height="4" variant="pulse" marginY={4} />
5945
<Skeleton flex="1" height="4" variant="pulse" marginY={4} />

src/components/app/data-table.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,15 @@ const columnHelper = createColumnHelper<Stock>();
4242
const columns = [
4343
columnHelper.accessor('ticker', {
4444
header: () => 'Ticker',
45-
cell: (cell) => cell.getValue(),
45+
cell: (cell) => cell.row.original.highlightedTicker ?? cell.getValue(),
4646
meta: { width: 85, sticky: true },
4747
enableColumnFilter: false
4848
}),
4949
columnHelper.accessor('companyName', {
5050
header: () => 'Company Name',
5151
cell: (cell) => (
5252
<EllipsisText width={200} color="gray.500" title={cell.getValue()}>
53-
{cell.getValue()}
53+
{cell.row.original.highlightedCompanyName ?? cell.getValue()}
5454
</EllipsisText>
5555
),
5656
meta: { width: 200 },

src/components/app/dropdown.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,6 @@ export const Dropdown: FC<DropdownProps> = ({ optionList, type }) => {
4242
}
4343
}, [dropdownState, type]);
4444

45-
// useEffect(() => {
46-
// setValue(optionList[0]);
47-
// }, [optionList]);
48-
4945
useEffect(() => {
5046
if (filterChanged > 0 && type === 'Preset') {
5147
setValue({ title: 'Manual', value: '-' });

src/components/app/search-box.tsx

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
import { IconButton, Input, Show } from '@chakra-ui/react';
2-
import { ChangeEvent, CSSProperties, useRef, useState } from 'react';
2+
import { ChangeEvent, CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
33
import { PiMagnifyingGlassBold, PiArrowCounterClockwiseDuotone, PiXDuotone } from 'react-icons/pi';
44
import { InputGroup } from '../ui/input-group';
5-
import { useEventListener, useMediaQuery, useOnClickOutside } from 'usehooks-ts';
5+
import { useDebounceCallback, useEventListener, useMediaQuery, useOnClickOutside } from 'usehooks-ts';
66
import { mobileMediaQuery } from '../../utils/constant';
7+
import { useAtomValue, useSetAtom } from 'jotai';
8+
import { fuzzyListAtom, preFilteredListAtom } from '../../state/atom';
9+
import { Stock } from '../../models/stock';
10+
import fuzzysort from 'fuzzysort';
711

812
export const SearchBox = () => {
13+
// console.log('SearchBox');
914
const [open, setOpen] = useState(false);
1015
const [keyword, setKeyword] = useState('');
16+
17+
const preFilteredList = useAtomValue(preFilteredListAtom);
18+
const setFuzzyList = useSetAtom(fuzzyListAtom);
19+
1120
const divRef = useRef<HTMLDivElement>(null);
1221
const inputRef = useRef<HTMLInputElement>(null);
1322

@@ -35,6 +44,38 @@ export const SearchBox = () => {
3544
setKeyword(event.target.value);
3645
};
3746

47+
const highlightFn = (m: string, i: number) => (
48+
<span className="highlight" key={i}>
49+
{m}
50+
</span>
51+
);
52+
53+
const fuzzySearch = useCallback(
54+
(keyword: string) => {
55+
const results = fuzzysort.go(keyword, preFilteredList, {
56+
threshold: 0.3,
57+
keys: ['ticker', 'companyName']
58+
});
59+
if (results.length === 0) {
60+
setFuzzyList(keyword.length > 0 ? [{ fuzzySearchEmpty: true } as Stock] : []);
61+
} else {
62+
setFuzzyList(
63+
results.map((stock) => {
64+
const highlightedTicker = stock[0].target.length > 0 ? stock[0].highlight(highlightFn) : null;
65+
const highlightedCompanyName = stock[1].target.length > 0 ? stock[1].highlight(highlightFn) : null;
66+
return { ...stock.obj, highlightedTicker, highlightedCompanyName };
67+
})
68+
);
69+
}
70+
},
71+
[preFilteredList, setFuzzyList]
72+
);
73+
const debouncedFuzzySearch = useDebounceCallback(fuzzySearch, 250);
74+
75+
useEffect(() => {
76+
debouncedFuzzySearch(keyword);
77+
}, [keyword, debouncedFuzzySearch]);
78+
3879
useEventListener('keydown', (event) => {
3980
const id = (event.target as HTMLElement)?.id ?? '';
4081
if (id === 'search-stocks' || id === '') {
@@ -45,14 +86,13 @@ export const SearchBox = () => {
4586
}
4687
return true;
4788
});
48-
setTimeout(() => {
49-
inputRef.current?.focus();
50-
}, 0);
5189
}
5290
if (event.key === 'Escape') {
53-
setOpen(false);
5491
setKeyword('');
5592
}
93+
setTimeout(() => {
94+
inputRef.current?.focus();
95+
}, 0);
5696
}
5797
});
5898

src/models/stock.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import { ReactNode } from 'react';
2+
13
export interface Stock {
24
key: number;
35
ticker: string;
46
companyName: string;
7+
highlightedTicker?: ReactNode | string;
8+
highlightedCompanyName?: ReactNode | string;
9+
fuzzySearchEmpty?: boolean;
510
sector: string;
611
industry: string;
712
exchange: string;

src/state/atom.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import {
77
defaultFilterState,
88
defaultPreset,
99
defaultSettings,
10-
defaultView
10+
defaultView,
11+
initialFilter
1112
} from '../utils/table.util';
13+
import { Stock } from '../models/stock';
1214

1315
export const rowCountAtom = atom(-1);
1416

@@ -20,6 +22,25 @@ export const dropdownFnAtom = atom<{
2022
resetPageIndex?: () => void;
2123
}>({ setColumnFilters: undefined, setColumnVisibility: undefined, resetPageIndex: undefined });
2224

25+
export const stockListAtom = atom<Stock[]>([]);
26+
export const fuzzyListAtom = atom<Stock[]>([]);
27+
28+
// stock list after exclude (OTC & Biotechnology)
29+
export const preFilteredListAtom = atom((get) => {
30+
const settings = get(appSettingsAtom);
31+
const stockList = get(stockListAtom);
32+
return initialFilter(stockList, settings);
33+
});
34+
35+
// stock list after exclude (OTC & Biotechnology) + fuzzy search
36+
export const filteredStockListAtom = atom((get) => {
37+
const fuzzyList = get(fuzzyListAtom);
38+
if (fuzzyList.length > 0) {
39+
return fuzzyList.some((e) => e.fuzzySearchEmpty) ? [] : fuzzyList;
40+
}
41+
return get(preFilteredListAtom);
42+
});
43+
2344
// atom with localstorage
2445
export const appSettingsAtom = atomWithStorage('appSettings', defaultSettings);
2546

0 commit comments

Comments
 (0)