feat: improve search

This commit is contained in:
Sonny
2023-12-23 16:08:00 +01:00
committed by Sonny
parent 9d59c3b993
commit cfba05c58d
5 changed files with 135 additions and 81 deletions

View File

@@ -8,7 +8,6 @@ interface SelectorProps {
labelComponent?: JSX.Element;
disabled?: boolean;
innerRef?: MutableRefObject<any>;
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 && (
<label
htmlFor={name}
title={`${name} field`}
title={label}
>
{label}
</label>
@@ -47,7 +45,7 @@ export default function Checkbox({
{labelComponent && (
<label
htmlFor={name}
title={`${name} field`}
title={name}
>
{labelComponent}
</label>

View File

@@ -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;
}) {

View File

@@ -64,7 +64,7 @@ export default function SearchListItem({
) : (
<AiOutlineFolder size={24} />
)}
<span>{name}</span>
{name}
</LinkTag>
</li>
);

View File

@@ -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 += `<b>${item.name[i]}</b>`;
currentIndex++;
} else {
formattedName += item.name[i];
}
}
if (currentIndex !== lowerCaseSearchTerm.length) {
// Search term not fully matched
return null;
}
return {
id: item.id,
name: <div dangerouslySetInnerHTML={{ __html: formattedName }} />,
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<string>(
(router.query.q as string) || '',
);
const [selectedItem, setSelectedItem] = useState<SearchResult | null>(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<SearchItem[]>(() => {
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<string>(searchQueryParam);
const [selectedItem, setSelectedItem] = useState<SearchItem | null>(
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<boolean>(() => 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<HTMLFormElement>) => {
@@ -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 (
<>
<ButtonLink
@@ -230,3 +276,5 @@ export default function SearchModal({
</>
);
}
export default SearchModal;

View File

@@ -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;