From cfba05c58d6e0a3ef941ce7049fad47a7bcdec9b Mon Sep 17 00:00:00 2001 From: Sonny Date: Sat, 23 Dec 2023 16:08:00 +0100 Subject: [PATCH] feat: improve search --- src/components/Checkbox.tsx | 6 +- src/components/SearchModal/SearchList.tsx | 12 +- src/components/SearchModal/SearchListItem.tsx | 2 +- src/components/SearchModal/SearchModal.tsx | 188 +++++++++++------- src/types/types.d.ts | 8 + 5 files changed, 135 insertions(+), 81 deletions(-) diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx index 2de29ad..1732988 100644 --- a/src/components/Checkbox.tsx +++ b/src/components/Checkbox.tsx @@ -8,7 +8,6 @@ interface SelectorProps { labelComponent?: JSX.Element; disabled?: boolean; innerRef?: MutableRefObject; - fieldClass?: string; isChecked?: boolean; onChangeCallback?: (value, { target }) => void; dir?: 'ltr' | 'rtl'; @@ -20,7 +19,6 @@ export default function Checkbox({ labelComponent, disabled = false, innerRef = null, - fieldClass = '', isChecked, onChangeCallback, dir = 'ltr', @@ -39,7 +37,7 @@ export default function Checkbox({ {label && ( @@ -47,7 +45,7 @@ export default function Checkbox({ {labelComponent && ( diff --git a/src/components/SearchModal/SearchList.tsx b/src/components/SearchModal/SearchList.tsx index dc2ac62..4e44d1d 100644 --- a/src/components/SearchModal/SearchList.tsx +++ b/src/components/SearchModal/SearchList.tsx @@ -1,14 +1,14 @@ +import clsx from 'clsx'; import * as Keys from 'constants/keys'; import { useTranslation } from 'next-i18next'; import { ReactNode, useEffect, useMemo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import { SearchItem } from 'types'; +import { SearchResult } from 'types/types'; import { groupItemBy } from 'utils/array'; import SearchListItem from './SearchListItem'; import styles from './search.module.scss'; -import clsx from 'clsx'; -const isActiveItem = (item: SearchItem, otherItem: SearchItem) => +const isActiveItem = (item: SearchResult, otherItem: SearchResult) => item?.id === otherItem?.id && item?.type === otherItem?.type; export default function SearchList({ items, @@ -17,9 +17,9 @@ export default function SearchList({ noItem, closeModal, }: { - items: SearchItem[]; - selectedItem: SearchItem; - setSelectedItem: (item: SearchItem) => void; + items: SearchResult[]; + selectedItem: SearchResult; + setSelectedItem: (item: SearchResult) => void; noItem?: ReactNode; closeModal: () => void; }) { diff --git a/src/components/SearchModal/SearchListItem.tsx b/src/components/SearchModal/SearchListItem.tsx index 62544bb..4d8e06f 100644 --- a/src/components/SearchModal/SearchListItem.tsx +++ b/src/components/SearchModal/SearchListItem.tsx @@ -64,7 +64,7 @@ export default function SearchListItem({ ) : ( )} - {name} + {name} ); diff --git a/src/components/SearchModal/SearchModal.tsx b/src/components/SearchModal/SearchModal.tsx index 62d652c..cdd18a0 100644 --- a/src/components/SearchModal/SearchModal.tsx +++ b/src/components/SearchModal/SearchModal.tsx @@ -23,7 +23,12 @@ import { } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { BsSearch } from 'react-icons/bs'; -import { CategoryWithLinks, LinkWithCategory, SearchItem } from 'types/types'; +import { + CategoryWithLinks, + LinkWithCategory, + SearchItem, + SearchResult, +} from 'types/types'; import LabelSearchWithGoogle from './LabelSearchWithGoogle'; import { SearchFilter } from './SearchFilter'; import SearchList from './SearchList'; @@ -35,7 +40,56 @@ interface SearchModalProps { childClassname?: string; } -export default function SearchModal({ +function buildSearchItem( + item: CategoryWithLinks | LinkWithCategory, + type: SearchItem['type'], +): SearchItem { + return { + id: item.id, + name: item.name, + url: + type === 'link' + ? (item as LinkWithCategory).url + : `${PATHS.HOME}?categoryId=${item.id}`, + type, + category: type === 'link' ? (item as LinkWithCategory).category : undefined, + }; +} + +function formatSearchItem( + item: SearchItem, + searchTerm: string, +): SearchResult | null { + const lowerCaseSearchTerm = searchTerm.toLowerCase().trim(); + const lowerCaseName = item.name.toLowerCase().trim(); + + let currentIndex = 0; + let formattedName = ''; + + for (let i = 0; i < lowerCaseName.length; i++) { + if (lowerCaseName[i] === lowerCaseSearchTerm[currentIndex]) { + formattedName += `${item.name[i]}`; + currentIndex++; + } else { + formattedName += item.name[i]; + } + } + + if (currentIndex !== lowerCaseSearchTerm.length) { + // Search term not fully matched + return null; + } + + return { + id: item.id, + name:
, + url: item.url, + type: item.type, + category: item.category, + }; +} + +function SearchModal({ noHeader = true, children, childClassname = '', @@ -46,11 +100,20 @@ export default function SearchModal({ const { categories } = useCategories(); const { setActiveCategory } = useActiveCategory(); const { globalHotkeysEnabled, setGlobalHotkeysEnabled } = useGlobalHotkeys(); - - const searchQueryParam = (router.query.q as string) || ''; - const searchModal = useModal( - !!searchQueryParam && typeof window !== 'undefined', + const [canSearchLink, setCanSearchLink] = useLocalStorage( + 'search-link', + true, ); + const [canSearchCategory, setCanSearchCategory] = useLocalStorage( + 'search-category', + false, + ); + const [search, setSearch] = useState( + (router.query.q as string) || '', + ); + const [selectedItem, setSelectedItem] = useState(null); + + const searchModal = useModal(!!search && typeof window !== 'undefined'); useEffect( () => setGlobalHotkeysEnabled(!searchModal.isShowing), @@ -60,85 +123,54 @@ export default function SearchModal({ const handleCloseModal = useCallback(() => { searchModal.close(); setSearch(''); - router.replace({ - query: undefined, - }); - }, [router, searchModal]); - - useHotkeys( - Keys.OPEN_SEARCH_KEY, - (event) => { - event.preventDefault(); - searchModal.open(); - }, - { enabled: globalHotkeysEnabled }, - ); - - useHotkeys(Keys.CLOSE_SEARCH_KEY, handleCloseModal, { - enabled: searchModal.isShowing, - enableOnFormTags: ['INPUT'], - }); - - const searchItemBuilder = ( - item: CategoryWithLinks | LinkWithCategory, - type: SearchItem['type'], - ): SearchItem => ({ - id: item.id, - name: item.name, - url: - type === 'link' - ? (item as LinkWithCategory).url - : `${PATHS.HOME}?categoryId=${item.id}`, - type, - category: type === 'link' ? (item as LinkWithCategory).category : undefined, - }); + if (!!search) { + router.replace({ + query: undefined, + }); + } + }, [router, search, searchModal]); const itemsSearch = useMemo(() => { return categories.reduce((acc, category) => { - const categoryItem = searchItemBuilder(category, 'category'); + const categoryItem = buildSearchItem(category, 'category'); const items: SearchItem[] = category.links.map((link) => - searchItemBuilder(link, 'link'), + buildSearchItem(link, 'link'), ); return [...acc, ...items, categoryItem]; }, [] as SearchItem[]); }, [categories]); - const [canSearchLink, setCanSearchLink] = useLocalStorage( - 'search-link', - true, - ); - const [canSearchCategory, setCanSearchCategory] = useLocalStorage( - 'search-category', - false, - ); - const [search, setSearch] = useState(searchQueryParam); - const [selectedItem, setSelectedItem] = useState( - itemsSearch[0], - ); + const itemsCompletion = useMemo(() => { + return itemsSearch.reduce((acc, item) => { + const formattedItem = formatSearchItem(item, search); + + if ( + (canSearchLink && item.type === 'link') || + (canSearchCategory && item.type === 'category') + ) { + return formattedItem ? [...acc, formattedItem] : acc; + } + + return acc; + }, [] as SearchResult[]); + }, [itemsSearch, search, canSearchLink, canSearchCategory]); const canSubmit = useMemo(() => search.length > 0, [search]); - // TODO: extract this code into utils function - const itemsCompletion = useMemo( - () => - search.length === 0 - ? [] - : itemsSearch.filter( - (item) => - ((item.type === 'category' && canSearchCategory) || - (item.type === 'link' && canSearchLink)) && - item.name - .toLocaleLowerCase() - .includes(search.toLocaleLowerCase().trim()), - ), - [canSearchCategory, canSearchLink, itemsSearch, search], + const handleSearchInputChange = useCallback( + (value: string) => setSearch(value), + [], ); - const handleSearchInputChange = (value: string) => setSearch(value); + const handleCanSearchLink = useCallback( + (checked: boolean) => setCanSearchLink(checked), + [setCanSearchLink], + ); - const handleCanSearchLink = (checked: boolean) => setCanSearchLink(checked); - const handleCanSearchCategory = (checked: boolean) => - setCanSearchCategory(checked); + const handleCanSearchCategory = useCallback( + (checked: boolean) => setCanSearchCategory(checked), + [setCanSearchCategory], + ); const handleSubmit = useCallback( (event: FormEvent) => { @@ -168,6 +200,20 @@ export default function SearchModal({ ], ); + useHotkeys( + Keys.OPEN_SEARCH_KEY, + (event) => { + event.preventDefault(); + searchModal.open(); + }, + { enabled: globalHotkeysEnabled }, + ); + + useHotkeys(Keys.CLOSE_SEARCH_KEY, handleCloseModal, { + enabled: searchModal.isShowing, + enableOnFormTags: ['INPUT'], + }); + return ( <> ); } + +export default SearchModal; diff --git a/src/types/types.d.ts b/src/types/types.d.ts index d1e20ea..7f19203 100644 --- a/src/types/types.d.ts +++ b/src/types/types.d.ts @@ -18,6 +18,14 @@ export interface SearchItem { category?: undefined | LinkWithCategory['category']; } +export interface SearchResult { + id: number; + name: React.ReactNode; // React node to support bold text + url: string; + type: 'category' | 'link'; + category?: undefined | LinkWithCategory['category']; +} + export interface Favicon { buffer: Buffer; url: string;