feat: add validation for search modal

This commit is contained in:
Sonny
2024-05-25 17:18:33 +02:00
committed by Sonny
parent 56c05f1bf6
commit 09700a1916
12 changed files with 306 additions and 64 deletions

View File

@@ -5,7 +5,8 @@ export default class SearchesController {
async search({ request, auth }: HttpContext) {
const term = request.qs()?.term;
if (!term) {
return console.warn('qs term null');
console.warn('qs term null');
return { error: 'missing "term" query param' };
}
const { rows } = await db.rawQuery('SELECT * FROM search_text(?, ?)', [

View File

@@ -18,6 +18,7 @@ export default class extends BaseSchema {
id INTEGER,
type TEXT,
name VARCHAR(254),
url TEXT,
collection_id INTEGER,
matched_part TEXT,
rank DOUBLE PRECISION
@@ -25,20 +26,20 @@ export default class extends BaseSchema {
AS $$
BEGIN
RETURN QUERY
SELECT links.id, 'link' AS type, links.name, collections.id AS collection_id,
ts_headline('english', unaccent(links.name), plainto_tsquery('english', unaccent(search_query))) AS matched_part,
ts_rank_cd(to_tsvector('english', unaccent(links.name)), plainto_tsquery('english', unaccent(search_query)))::DOUBLE PRECISION AS rank
SELECT links.id, 'link' AS type, links.name, links.url, collections.id AS collection_id,
ts_headline('english', unaccent(links.name), plainto_tsquery('english', unaccent(search_query))) AS matched_part,
ts_rank_cd(to_tsvector('english', unaccent(links.name)), plainto_tsquery('english', unaccent(search_query)))::DOUBLE PRECISION AS rank
FROM links
LEFT JOIN collections ON links.collection_id = collections.id
WHERE unaccent(links.name) ILIKE '%' || unaccent(search_query) || '%'
AND (p_author_id IS NULL OR links.author_id = p_author_id)
AND (p_author_id IS NULL OR links.author_id = p_author_id)
UNION ALL
SELECT collections.id, 'collection' AS type, collections.name, NULL AS collection_id,
ts_headline('english', unaccent(collections.name), plainto_tsquery('english', unaccent(search_query))) AS matched_part,
ts_rank_cd(to_tsvector('english', unaccent(collections.name)), plainto_tsquery('english', unaccent(search_query)))::DOUBLE PRECISION AS rank
SELECT collections.id, 'collection' AS type, collections.name, NULL AS url, NULL AS collection_id,
ts_headline('english', unaccent(collections.name), plainto_tsquery('english', unaccent(search_query))) AS matched_part,
ts_rank_cd(to_tsvector('english', unaccent(collections.name)), plainto_tsquery('english', unaccent(search_query)))::DOUBLE PRECISION AS rank
FROM collections
WHERE unaccent(collections.name) ILIKE '%' || unaccent(search_query) || '%'
AND (p_author_id IS NULL OR collections.author_id = p_author_id)
AND (p_author_id IS NULL OR collections.author_id = p_author_id)
ORDER BY rank DESC NULLS LAST, matched_part DESC NULLS LAST;
END;
$$

View File

@@ -44,7 +44,7 @@ export default function Dropdown({
const { isShowing, toggle, close } = useToggle();
useClickOutside(dropdownRef, close);
useShortcut('ESCAPE_KEY', close);
useShortcut('ESCAPE_KEY', close, { disableGlobalCheck: true });
return (
<DropdownStyle

View File

@@ -0,0 +1,29 @@
import styled from '@emotion/styled';
import { useTranslation } from 'react-i18next';
import { FcGoogle } from 'react-icons/fc';
const NoSearchResultStyle = styled.i({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25em',
'& > span': {
display: 'flex',
alignItems: 'center',
},
});
export default function NoSearchResult() {
const { t } = useTranslation('common');
return (
<NoSearchResultStyle>
{t('search-with')}
{''}
<span>
<FcGoogle size={20} />
oogle
</span>
</NoSearchResultStyle>
);
}

View File

@@ -2,11 +2,12 @@ import styled from '@emotion/styled';
import { route } from '@izzyjs/route/client';
import { FormEvent, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AiOutlineFolder } from 'react-icons/ai';
import { CiLink } from 'react-icons/ci';
import { IoIosSearch } from 'react-icons/io';
import Modal from '~/components/common/modal/modal';
import UnstyledList from '~/components/common/unstyled/unstyled_list';
import NoSearchResult from '~/components/dashboard/search/no_search_result';
import SearchResultList from '~/components/dashboard/search/search_result_list';
import { GOOGLE_SEARCH_URL } from '~/constants';
import useActiveCollection from '~/hooks/use_active_collection';
import useCollections from '~/hooks/use_collections';
import useToggle from '~/hooks/use_modal';
import useShortcut from '~/hooks/use_shortcut';
@@ -29,8 +30,11 @@ interface SearchModalProps {
function SearchModal({ openItem: OpenItem }: SearchModalProps) {
const { t } = useTranslation();
const { collections } = useCollections();
const { setActiveCollection } = useActiveCollection();
const [searchTerm, setSearchTerm] = useState<string>('');
const [results, setResults] = useState<SearchResult[]>([]);
const [selectedItem, setSelectedItem] = useState<SearchResult | null>(null);
const searchModal = useToggle(!!searchTerm);
@@ -40,25 +44,52 @@ function SearchModal({ openItem: OpenItem }: SearchModalProps) {
}, [searchModal]);
const handleSearchInputChange = (value: string) => setSearchTerm(value);
const handleSubmit = (event: FormEvent<HTMLFormElement>) =>
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
handleCloseModal();
if (results.length === 0) {
return window.open(GOOGLE_SEARCH_URL + encodeURI(searchTerm.trim()));
}
if (!selectedItem) return;
if (selectedItem.type === 'collection') {
const collection = collections.find((c) => c.id === selectedItem.id);
if (collection) {
setActiveCollection(collection);
}
return;
}
window.open(selectedItem.url);
};
useShortcut('OPEN_SEARCH_KEY', searchModal.open, {
enabled: !searchModal.isShowing,
});
useShortcut('ESCAPE_KEY', handleCloseModal, {
enabled: searchModal.isShowing,
disableGlobalCheck: true,
});
useEffect(() => {
if (searchTerm.trim() === '') {
return setResults([]);
}
const controller = new AbortController();
const { url, method } = route('search', { qs: { term: searchTerm } });
makeRequest({
method,
url,
}).then(({ results: _results }) => setResults(_results));
controller,
}).then(({ results: _results }) => {
setResults(_results);
setSelectedItem(_results?.[0]);
});
return () => controller.abort();
}, [searchTerm]);
return (
@@ -70,6 +101,7 @@ function SearchModal({ openItem: OpenItem }: SearchModalProps) {
close={handleCloseModal}
opened={searchModal.isShowing}
hideCloseBtn
css={{ width: '650px' }}
>
<form
onSubmit={handleSubmit}
@@ -100,37 +132,14 @@ function SearchModal({ openItem: OpenItem }: SearchModalProps) {
autoFocus
/>
</div>
{results.length > 0 && (
<UnstyledList css={{ maxHeight: '500px', overflow: 'auto' }}>
{results.map((result) => {
const ItemIcon =
result.type === 'link' ? CiLink : AiOutlineFolder;
const collection =
result.type === 'link'
? collections.find((c) => c.id === result.collection_id)
: undefined;
return (
<li
key={result.type + result.id.toString()}
css={{
fontSize: '16px',
display: 'flex',
gap: '0.35em',
alignItems: 'center',
}}
>
<ItemIcon size={24} />
<span
dangerouslySetInnerHTML={{
__html: result.matched_part ?? result.name,
}}
/>
{collection && <>({collection.name})</>}
</li>
);
})}
</UnstyledList>
{results.length > 0 && selectedItem && (
<SearchResultList
results={results}
selectedItem={selectedItem}
setSelectedItem={setSelectedItem}
/>
)}
{results.length === 0 && !!searchTerm.trim() && <NoSearchResult />}
<button
type="submit"
disabled={searchTerm.length === 0}

View File

@@ -0,0 +1,143 @@
import styled from '@emotion/styled';
import { RefObject, useEffect, useRef, useState } from 'react';
import { AiOutlineFolder } from 'react-icons/ai';
import TextEllipsis from '~/components/common/text_ellipsis';
import LinkFavicon from '~/components/dashboard/link/link_favicon';
import useCollections from '~/hooks/use_collections';
import {
SearchResult,
SearchResultCollection,
SearchResultLink,
} from '~/types/search';
const SearchItemStyle = styled('li', {
shouldForwardProp: (propName) => propName !== 'isActive',
})<{ isActive: boolean }>(({ theme, isActive }) => ({
fontSize: '16px',
backgroundColor: isActive ? theme.colors.background : 'transparent',
display: 'flex',
gap: '0.35em',
alignItems: 'center',
borderRadius: theme.border.radius,
padding: '0.25em 0.35em !important',
}));
const ItemLegeng = styled.span(({ theme }) => ({
fontSize: '13px',
color: theme.colors.grey,
}));
interface CommonResultProps {
isActive: boolean;
innerRef: RefObject<HTMLLIElement>;
onHoverEnter: () => void;
onHoverLeave: () => void;
}
export default function SearchResultItem({
result,
isActive,
onHover,
}: {
result: SearchResult;
isActive: boolean;
onHover: (result: SearchResult) => void;
}) {
const itemRef = useRef<HTMLLIElement>(null);
const [isHovering, setHovering] = useState<boolean>(false);
const handleHoverEnter = () => {
if (!isHovering) {
onHover(result);
setHovering(true);
}
};
const handleHoverLeave = () => setHovering(false);
useEffect(() => {
if (isActive && !isHovering) {
itemRef.current?.scrollIntoView({
behavior: 'instant',
block: 'nearest',
});
}
}, [itemRef, isActive]);
return result.type === 'collection' ? (
<ResultCollection
result={result}
isActive={isActive}
onHoverEnter={handleHoverEnter}
onHoverLeave={handleHoverLeave}
innerRef={itemRef}
/>
) : (
<ResultLink
result={result}
isActive={isActive}
onHoverEnter={handleHoverEnter}
onHoverLeave={handleHoverLeave}
innerRef={itemRef}
/>
);
}
function ResultLink({
result,
isActive,
innerRef,
onHoverEnter,
onHoverLeave,
}: {
result: SearchResultLink;
} & CommonResultProps) {
const { collections } = useCollections();
const collection = collections.find((c) => c.id === result.collection_id);
const link = collection?.links.find((l) => l.id === result.id);
if (!collection || !link) return <></>;
return (
<SearchItemStyle
onMouseEnter={onHoverEnter}
onMouseLeave={onHoverLeave}
isActive={isActive}
key={result.type + result.id.toString()}
ref={innerRef}
>
<LinkFavicon url={link.url} size={20} />
<TextEllipsis
dangerouslySetInnerHTML={{
__html: result.matched_part ?? result.name,
}}
/>
<ItemLegeng>({collection.name})</ItemLegeng>
</SearchItemStyle>
);
}
const ResultCollection = ({
result,
isActive,
innerRef,
onHoverEnter,
onHoverLeave,
}: {
result: SearchResultCollection;
} & CommonResultProps) => (
<SearchItemStyle
onMouseEnter={onHoverEnter}
onMouseLeave={onHoverLeave}
isActive={isActive}
key={result.type + result.id.toString()}
ref={innerRef}
>
<AiOutlineFolder size={24} />
<TextEllipsis
dangerouslySetInnerHTML={{
__html: result.matched_part ?? result.name,
}}
/>
</SearchItemStyle>
);

View File

@@ -0,0 +1,52 @@
import UnstyledList from '~/components/common/unstyled/unstyled_list';
import SearchResultItem from '~/components/dashboard/search/search_result_item';
import useShortcut from '~/hooks/use_shortcut';
import { SearchResult } from '~/types/search';
export default function SearchResultList({
results,
selectedItem,
setSelectedItem,
}: {
results: SearchResult[];
selectedItem: SearchResult;
setSelectedItem: (result: SearchResult) => void;
}) {
const selectedItemIndex = results.findIndex(
(item) => item.id === selectedItem.id && item.type === selectedItem.type
);
useShortcut(
'ARROW_UP',
() => setSelectedItem(results[selectedItemIndex - 1]),
{
enabled: results.length > 1 && selectedItemIndex !== 0,
disableGlobalCheck: true,
}
);
useShortcut(
'ARROW_DOWN',
() => setSelectedItem(results[selectedItemIndex + 1]),
{
enabled: results.length > 1 && selectedItemIndex !== results.length - 1,
disableGlobalCheck: true,
}
);
return (
<UnstyledList css={{ maxHeight: '500px', overflow: 'auto' }}>
{results.map((result) => (
<SearchResultItem
result={result}
onHover={setSelectedItem}
isActive={
selectedItem &&
selectedItem.id === result.id &&
selectedItem.type === result.type
}
key={result.type + result.id.toString()}
/>
))}
</UnstyledList>
);
}

View File

@@ -4,13 +4,15 @@ import useGlobalHotkeys from '~/hooks/use_global_hotkeys';
type ShortcutOptions = {
enabled?: boolean;
disableGlobalCheck?: boolean;
};
export default function useShortcut(
key: keyof typeof KEYS,
cb: () => void,
{ enabled }: ShortcutOptions = {
{ enabled, disableGlobalCheck }: ShortcutOptions = {
enabled: true,
disableGlobalCheck: false,
}
) {
const { globalHotkeysEnabled } = useGlobalHotkeys();
@@ -21,7 +23,7 @@ export default function useShortcut(
cb();
},
{
enabled: key === 'ESCAPE_KEY' ? true : enabled && globalHotkeysEnabled,
enabled: disableGlobalCheck ? enabled : enabled && globalHotkeysEnabled,
enableOnFormTags: ['INPUT'],
}
);

View File

@@ -35,6 +35,7 @@
"no-item-found": "No item found",
"admin": "Administrator",
"search": "Search",
"search-with": "Search with",
"avatar": "{{name}}'s avatar",
"generic-error": "Something went wrong",
"generic-error-description": "An error has occurred, if this happens again please <a href=\"https://github.com/Sonny93/my-links\" target=\"_blank\">create an issue</a> with as much detail as possible.",

View File

@@ -35,6 +35,7 @@
"no-item-found": "Aucun élément trouvé",
"admin": "Administrateur",
"search": "Rechercher",
"search-with": "Rechercher avec",
"avatar": "Avatar de {{name}}",
"generic-error": "Une erreur est survenue",
"generic-error-description": "Une erreur est survenue, si cela se reproduit merci de <a href=\"https://github.com/Sonny93/my-links\" target=\"_blank\">créer une issue</a> avec le maximum de détails.",

View File

@@ -4,10 +4,12 @@ export async function makeRequest({
method = 'GET',
url,
body,
controller,
}: {
method?: RequestInit['method'];
url: string;
body?: object | any[];
controller?: AbortController;
}): Promise<any> {
const request = await fetch(url, {
method,
@@ -15,6 +17,7 @@ export async function makeRequest({
headers: {
'Content-Type': 'application/json',
},
signal: controller ? controller.signal : undefined,
});
const data = await request.json();

View File

@@ -1,18 +1,18 @@
import type Link from '#models/link';
type SearchResultCommon = {
id: number;
name: string;
matched_part?: string;
rank?: number;
};
export type SearchResult =
| {
id: string;
type: 'collection';
name: string;
matched_part?: string;
rank?: number;
}
| {
id: string;
type: 'link';
name: string;
matched_part?: string;
rank?: number;
collection_id: number;
};
export type SearchResultCollection = SearchResultCommon & {
type: 'collection';
};
export type SearchResultLink = SearchResultCommon & {
type: 'link';
collection_id: number;
url: string;
};
export type SearchResult = SearchResultCollection | SearchResultLink;