From 4ededadca47eb05d08ccabcd75469063d81a06f6 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sun, 20 Feb 2022 05:51:39 -0800 Subject: [PATCH] Add fuzzy search (#511) * Add fuzzy search * Improve search performance --- site/package.json | 1 + site/src/components/IconList.tsx | 34 ++--- site/src/components/IconOverview.tsx | 118 +++--------------- site/src/components/SearchInput.tsx | 75 +++++++++++ site/src/hooks/useUpdateEffect.ts | 15 +++ .../lib/{useDebounce.tsx => useDebounce.ts} | 0 site/src/lib/useRouterParam.ts | 23 ++++ site/src/lib/useSearch.ts | 23 ++++ site/src/lib/useSearch.tsx | 19 --- site/src/types.ts | 12 ++ site/yarn.lock | 5 + 11 files changed, 185 insertions(+), 140 deletions(-) create mode 100644 site/src/components/SearchInput.tsx create mode 100644 site/src/hooks/useUpdateEffect.ts rename site/src/lib/{useDebounce.tsx => useDebounce.ts} (100%) create mode 100644 site/src/lib/useRouterParam.ts create mode 100644 site/src/lib/useSearch.ts delete mode 100644 site/src/lib/useSearch.tsx create mode 100644 site/src/types.ts diff --git a/site/package.json b/site/package.json index c49ae7afb..a2ebb527d 100644 --- a/site/package.json +++ b/site/package.json @@ -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", diff --git a/site/src/components/IconList.tsx b/site/src/components/IconList.tsx index dc7328f61..358aa83db 100644 --- a/site/src/components/IconList.tsx +++ b/site/src/components/IconList.tsx @@ -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 ( - {icons.map(icon => { - const actualIcon = icon.item ? icon.item : icon; - const { name, content, contributors } = actualIcon; - + {icons.map((icon) => { return ( @@ -42,6 +34,6 @@ const IconList = ({ icons }) => { })} ); -}; +}); export default IconList; diff --git a/site/src/components/IconOverview.tsx b/site/src/components/IconOverview.tsx index 01f2f9be3..915e261b8 100644 --- a/site/src/components/IconOverview.tsx +++ b/site/src/components/IconOverview.tsx @@ -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 ( <> - - - - - } - /> - - + + {searchResults.length > 0 ? ( ) : ( - - No results found for "{queryText}" + + No results found for "{query}" )} diff --git a/site/src/components/SearchInput.tsx b/site/src/components/SearchInput.tsx new file mode 100644 index 000000000..56b4ead44 --- /dev/null +++ b/site/src/components/SearchInput.tsx @@ -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 ( + + + + + } + /> + setInputValue(event.target.value)} + value={inputValue} + bg={colorMode == 'light' ? theme.colors.white : theme.colors.gray[700]} + /> + + ); + } +); diff --git a/site/src/hooks/useUpdateEffect.ts b/site/src/hooks/useUpdateEffect.ts new file mode 100644 index 000000000..e4fb1d10c --- /dev/null +++ b/site/src/hooks/useUpdateEffect.ts @@ -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); +}; diff --git a/site/src/lib/useDebounce.tsx b/site/src/lib/useDebounce.ts similarity index 100% rename from site/src/lib/useDebounce.tsx rename to site/src/lib/useDebounce.ts diff --git a/site/src/lib/useRouterParam.ts b/site/src/lib/useRouterParam.ts new file mode 100644 index 000000000..0143f183c --- /dev/null +++ b/site/src/lib/useRouterParam.ts @@ -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; +}; diff --git a/site/src/lib/useSearch.ts b/site/src/lib/useSearch.ts new file mode 100644 index 000000000..6a7f18770 --- /dev/null +++ b/site/src/lib/useSearch.ts @@ -0,0 +1,23 @@ +import Fuse from 'fuse.js'; +import { useMemo } from 'react'; + +const useSearch = (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; diff --git a/site/src/lib/useSearch.tsx b/site/src/lib/useSearch.tsx deleted file mode 100644 index 5003dc20d..000000000 --- a/site/src/lib/useSearch.tsx +++ /dev/null @@ -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; diff --git a/site/src/types.ts b/site/src/types.ts new file mode 100644 index 000000000..4227e88f9 --- /dev/null +++ b/site/src/types.ts @@ -0,0 +1,12 @@ +export interface IconEntity { + content: string; + contributors: Contributor[]; + name: string; + src: string; + tags: string[]; +} + +export interface Contributor { + author: string; + commit: string; +} diff --git a/site/yarn.lock b/site/yarn.lock index 55997e832..3482ce44b 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -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"