Add fuzzy search (#511)

* Add fuzzy search

* Improve search performance
This commit is contained in:
Kacper Kozak
2022-02-20 14:51:39 +01:00
committed by GitHub
parent 83c1aba563
commit 4ededadca4
11 changed files with 185 additions and 140 deletions
+1
View File
@@ -21,6 +21,7 @@
"@next/mdx": "^11.0.0",
"downloadjs": "^1.4.7",
"framer-motion": "^4",
"fuse.js": "^6.5.3",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0",
"jszip": "^3.7.0",
+13 -21
View File
@@ -1,38 +1,30 @@
import { Button, Flex, Grid, Text, useToast } from '@chakra-ui/react';
import download from 'downloadjs';
import { Grid } from '@chakra-ui/react';
import Link from 'next/link';
import copy from 'copy-to-clipboard';
import { useCallback, useContext, useMemo } from 'react';
import { IconStyleContext } from './CustomizeIconContext';
import { IconWrapper } from './IconWrapper';
import { useRouter } from 'next/router';
import ModifiedTooltip from './ModifiedTooltip';
import { memo } from 'react';
import { IconEntity } from '../types';
import IconListItem from './IconListItem';
const IconList = ({ icons }) => {
const router = useRouter();
const toast = useToast();
const { color, size, strokeWidth } = useContext(IconStyleContext);
const { search } = router.query;
interface IconListProps {
icons: IconEntity[];
}
const query = useMemo(() => (search !== undefined ? { search } : {}), [search]);
const IconList = memo(({ icons }: IconListProps) => {
const router = useRouter();
return (
<Grid templateColumns={`repeat(auto-fill, minmax(150px, 1fr))`} gap={5} marginBottom="320px">
{icons.map(icon => {
const actualIcon = icon.item ? icon.item : icon;
const { name, content, contributors } = actualIcon;
{icons.map((icon) => {
return (
<Link
key={name}
key={icon.name}
scroll={false}
shallow={true}
href={{
pathname: '/icon/[iconName]',
query: {
...query,
iconName: name,
...router.query,
iconName: icon.name,
},
}}
>
@@ -42,6 +34,6 @@ const IconList = ({ icons }) => {
})}
</Grid>
);
};
});
export default IconList;
+18 -100
View File
@@ -1,114 +1,32 @@
import {
Box,
Input,
InputGroup,
InputLeftElement,
Text,
useColorMode,
Icon,
} from '@chakra-ui/react';
import IconList from './IconList';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Text } from '@chakra-ui/react';
import React, { useState } from 'react';
import useSearch from '../lib/useSearch';
import { useRouter } from 'next/router';
import theme from '../lib/theme';
import { Search as SearchIcon } from 'lucide-react';
import debounce from 'lodash/debounce';
import { IconEntity } from '../types';
import IconList from './IconList';
import { SearchInput } from './SearchInput';
const isFilledString = (string) => string !== undefined && string !== null && string !== '';
interface IconOverviewProps {
data: IconEntity[];
}
const IconOverview = ({ data }) => {
const router = useRouter();
const { search } = router.query;
const IconOverview = ({ data }: IconOverviewProps) => {
const [query, setQuery] = useState('');
const [queryText, setQueryText] = useState(search);
const { colorMode } = useColorMode();
const inputElement = useRef(null);
function handleKeyDown(event) {
if (event.key === '/' && inputElement.current !== document.activeElement) {
event.preventDefault();
inputElement.current.focus();
}
}
const setQueryParam = (searchString) => {
const { query, asPath } = router;
if(isFilledString(searchString)) {
let route = {
pathname: '',
query
}
if(query.iconName) {
route.query.iconName = query.iconName;
route.pathname = '/icon/[iconName]';
}
route.query.search = searchString;
router.replace(route);
}
else {
if (query?.search) {
delete query.search;
router.replace({
query
})
}
}
}
// @ts-ignore
const searchResults = useMemo(() => useSearch(data, queryText), [data, queryText])
const handleSearchInput = debounce((event) => {
event.persist();
const { value = '' } = inputElement?.current;
setQueryText(value)
setQueryParam(value)
}, 400)
useEffect(() => {
setQueryText(search)
}, [search]);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
const searchResults = useSearch(query, data, [
{ name: 'name', weight: 2 },
{ name: 'tags', weight: 1 },
]);
return (
<>
<InputGroup position="sticky" top={4} zIndex={1}>
<InputLeftElement
children={
<Icon>
<SearchIcon />
</Icon>
}
/>
<Input
ref={inputElement}
placeholder={`Search ${Object.keys(data).length} icons (Press "/" to focus)`}
onChange={handleSearchInput}
defaultValue={queryText}
bg={colorMode == 'light' ? theme.colors.white : theme.colors.gray[700]}
/>
</InputGroup>
<SearchInput onChange={setQuery} count={data.length} />
<Box marginTop={5}>
{searchResults.length > 0 ? (
<IconList icons={searchResults} />
) : (
<Text
fontSize="2xl"
fontWeight="bold"
textAlign="center"
style={{ wordBreak: 'break-word' }}
>
No results found for "{queryText}"
<Text fontSize="2xl" fontWeight="bold" textAlign="center" wordBreak="break-word">
No results found for "{query}"
</Text>
)}
</Box>
+75
View File
@@ -0,0 +1,75 @@
import {
Icon,
Input,
InputGroup,
InputLeftElement,
useColorMode,
useUpdateEffect
} from '@chakra-ui/react';
import { Search as SearchIcon } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import theme from '../lib/theme';
import { useDebounce } from '../lib/useDebounce';
import { useRouterParam } from '../lib/useRouterParam';
interface SearchInputProps {
onChange(value: string): void;
count: number;
}
export const SearchInput = (
({ onChange, count }: SearchInputProps) => {
const { colorMode } = useColorMode();
const [urlValue, setUrlValue] = useRouterParam('search');
const [inputValue, setInputValue] = useState('');
const debouncedValue = useDebounce(inputValue.trim(), 300);
useUpdateEffect(() => {
onChange(debouncedValue);
setUrlValue(debouncedValue);
}, [debouncedValue]);
useEffect(() => {
if (urlValue && !inputValue) {
setInputValue(urlValue);
onChange(urlValue);
}
}, [urlValue]);
const ref = useRef(null);
// Keyboard `/` shortcut
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === '/' && ref.current !== document.activeElement) {
event.preventDefault();
ref.current.focus();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
return (
<InputGroup position="sticky" top={4} zIndex={1}>
<InputLeftElement
children={
<Icon>
<SearchIcon />
</Icon>
}
/>
<Input
ref={ref}
placeholder={`Search ${count} icons (Press "/" to focus)`}
onChange={(event) => setInputValue(event.target.value)}
value={inputValue}
bg={colorMode == 'light' ? theme.colors.white : theme.colors.gray[700]}
/>
</InputGroup>
);
}
);
+15
View File
@@ -0,0 +1,15 @@
import { DependencyList, EffectCallback, useEffect, useRef } from 'react';
export const useUpdateEffect = (effect: EffectCallback, deps?: DependencyList) => {
const isFirstRef = useRef(true);
if (isFirstRef.current) {
isFirstRef.current = false;
}
useEffect(() => {
if (!isFirstRef.current) {
return effect();
}
}, deps);
};
+23
View File
@@ -0,0 +1,23 @@
import { useRouter } from 'next/router';
export const useRouterParam = (key: string) => {
const router = useRouter();
const queryValue = router.query[key];
const urlValue = typeof queryValue === 'string' ? queryValue : '';
const setParam = (value: string) => {
router.push(
{
query: value ? { [key]: value } : undefined,
},
undefined,
{
scroll: false,
shallow: true,
}
);
};
return [urlValue, setParam] as const;
};
+23
View File
@@ -0,0 +1,23 @@
import Fuse from 'fuse.js';
import { useMemo } from 'react';
const useSearch = <T>(query = '', collection: T[], keys: Fuse.FuseOptionKey[]) => {
const index = useMemo(() => {
return new Fuse(collection, {
threshold: 0.2,
keys,
});
}, [collection]);
const results = useMemo(() => {
if (query) {
return index.search(query).map((result) => result.item);
}
return collection;
}, [query, index]);
return results;
};
export default useSearch;
-19
View File
@@ -1,19 +0,0 @@
interface Icon {
name: string;
tags: string[],
}
function useSearch(icons: Icon[], query:string) {
if(!query) return icons;
const searchString = query.toLowerCase()
return icons.filter(({ name, tags }: Icon) => [name, ...tags].some(
(item:string) => item
.toLowerCase()
.includes(searchString)
)
);
}
export default useSearch;
+12
View File
@@ -0,0 +1,12 @@
export interface IconEntity {
content: string;
contributors: Contributor[];
name: string;
src: string;
tags: string[];
}
export interface Contributor {
author: string;
commit: string;
}
+5
View File
@@ -3785,6 +3785,11 @@ functional-red-black-tree@^1.0.1:
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
fuse.js@^6.5.3:
version "6.5.3"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.5.3.tgz#7446c0acbc4ab0ab36fa602e97499bdb69452b93"
integrity sha512-sA5etGE7yD/pOqivZRBvUBd/NaL2sjAu6QuSaFoe1H2BrJSkH/T/UXAJ8CdXdw7DvY3Hs8CXKYkDWX7RiP5KOg==
gensync@^1.0.0-beta.1, gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"