/* * MIT License * * Copyright (c) 2020-present Cloudogu GmbH and Contributors * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import React, { FC, KeyboardEvent as ReactKeyboardEvent, MouseEvent, useCallback, useState, useEffect } from "react"; import { Hit, Links, ValueHitField } from "@scm-manager/ui-types"; import styled from "styled-components"; import { BackendError, useSearch } from "@scm-manager/ui-api"; import classNames from "classnames"; import { Link, useHistory } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button, ErrorNotification, Notification } from "@scm-manager/ui-components"; const Field = styled.div` margin-bottom: 0 !important; `; const Input = styled.input` border-radius: 4px !important; `; type Props = { links: Links; }; const namespaceAndName = (hit: Hit) => { const namespace = (hit.fields["namespace"] as ValueHitField).value as string; const name = (hit.fields["name"] as ValueHitField).value as string; return `${namespace}/${name}`; }; type HitsProps = { hits: Hit[]; index: number; gotoDetailSearch: () => void; clear: () => void; }; const QuickSearchNotification: FC = ({ children }) =>
{children}
; type GotoProps = { gotoDetailSearch: () => void; }; const EmptyHits: FC = ({ gotoDetailSearch }) => { const [t] = useTranslation("commons"); return ( {t("search.quickSearch.noResults")} ); }; type ErrorProps = { error: Error; }; const ParseErrorNotification: FC = () => { const [t] = useTranslation("commons"); // TODO add link to query syntax page/modal return ( {t("search.quickSearch.parseError")} ); }; const isBackendError = (error: Error | BackendError): error is BackendError => { return (error as BackendError).errorCode !== undefined; }; const SearchErrorNotification: FC = ({ error }) => { // 5VScek8Xp1 is the id of sonia.scm.search.QueryParseException if (isBackendError(error) && error.errorCode === "5VScek8Xp1") { return ; } return ( ); }; const ResultHeading = styled.h3` border-bottom: 1px solid lightgray; margin: 0 0.5rem; padding: 0.375rem 0.5rem; font-weight: bold; `; const DropdownMenu = styled.div` max-width: 20rem; `; const ResultFooter = styled.div` border-top: 1px solid lightgray; margin: 0 0.5rem; padding: 0.375rem 0.5rem; `; const MoreResults: FC = ({ gotoDetailSearch }) => { const [t] = useTranslation("commons"); return ( ); }; const Hits: FC = ({ hits, index, clear, gotoDetailSearch }) => { const id = useCallback(namespaceAndName, [hits]); const [t] = useTranslation("commons"); if (hits.length === 0) { return ; } return (
{t("search.quickSearch.resultHeading")} {hits.map((hit, idx) => (
e.preventDefault()} onClick={clear}> {id(hit)}
))}
); }; const useKeyBoardNavigation = (gotoDetailSearch: () => void, clear: () => void, hits?: Array) => { const [index, setIndex] = useState(-1); const history = useHistory(); useEffect(() => { setIndex(-1); }, [hits]); const onKeyDown = (e: ReactKeyboardEvent) => { // We use e.which, because ie 11 does not support e.code // https://caniuse.com/keyboardevent-code switch (e.which) { case 40: // e.code: ArrowDown if (hits) { setIndex((idx) => { if (idx + 1 < hits.length) { return idx + 1; } return idx; }); } break; case 38: // e.code: ArrowUp if (hits) { setIndex((idx) => { if (idx > 0) { return idx - 1; } return idx; }); } break; case 13: // e.code: Enter if (hits && index >= 0) { const hit = hits[index]; history.push(`/repo/${namespaceAndName(hit)}`); clear(); } else { e.preventDefault(); gotoDetailSearch(); } break; case 27: // e.code: Escape if (index >= 0) { setIndex(-1); } else { clear(); } break; } }; return { onKeyDown, index, }; }; const useDebounce = (value: string, delay: number) => { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); return () => { clearTimeout(handler); }; }, [value, delay]); return debouncedValue; }; const isMoreResultsButton = (element: Element) => { return element.tagName.toLocaleLowerCase("en") === "button" && element.className.includes("is-primary"); }; const isOnmiSearchElement = (element: Element) => { return element.getAttribute("data-omnisearch") || isMoreResultsButton(element); }; const useShowResultsOnFocus = () => { const [showResults, setShowResults] = useState(false); useEffect(() => { if (showResults) { const close = () => { setShowResults(false); }; const onKeyUp = (e: KeyboardEvent) => { if (e.which === 9) { // tab const element = document.activeElement; if (!element || !isOnmiSearchElement(element)) { close(); } } }; window.addEventListener("click", close); window.addEventListener("keyup", onKeyUp); return () => { window.removeEventListener("click", close); window.removeEventListener("keyup", onKeyUp); }; } }, [showResults]); return { showResults, onClick: (e: MouseEvent) => { e.stopPropagation(); setShowResults(true); }, onKeyPress: () => setShowResults(true), onFocus: () => setShowResults(true), hideResults: () => setShowResults(false), }; }; const OmniSearch: FC = () => { const [query, setQuery] = useState(""); const debouncedQuery = useDebounce(query, 250); const { data, isLoading, error } = useSearch(debouncedQuery, { type: "repository", pageSize: 5 }); const { showResults, hideResults, ...handlers } = useShowResultsOnFocus(); const history = useHistory(); const clearQuery = () => { setQuery(""); }; const gotoDetailSearch = () => { history.push(`/search/repository/?q=${query}`); hideResults(); }; const { onKeyDown, index } = useKeyBoardNavigation(gotoDetailSearch, clearQuery, data?._embedded.hits); return (
setQuery(e.target.value)} onKeyDown={onKeyDown} value={query} role="combobox" aria-autocomplete="list" data-omnisearch="true" {...handlers} /> {isLoading ? null : ( )}
e.preventDefault()}> {error ? : null} {!error && data ? ( ) : null}
); }; const OmniSearchGuard: FC = ({ links }) => { if (!links.search) { return null; } return ; }; export default OmniSearchGuard;