2021-07-14 11:49:38 +02:00
|
|
|
/*
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2021-07-28 11:19:00 +02:00
|
|
|
import React, { FC, KeyboardEvent as ReactKeyboardEvent, MouseEvent, useCallback, useState, useEffect } from "react";
|
|
|
|
|
import { Hit, Links, ValueHitField } from "@scm-manager/ui-types";
|
2021-07-14 11:49:38 +02:00
|
|
|
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";
|
2021-07-28 11:19:00 +02:00
|
|
|
import { Button, ErrorNotification, Notification } from "@scm-manager/ui-components";
|
2021-07-14 11:49:38 +02:00
|
|
|
|
|
|
|
|
const Field = styled.div`
|
|
|
|
|
margin-bottom: 0 !important;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const Input = styled.input`
|
|
|
|
|
border-radius: 4px !important;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
links: Links;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const namespaceAndName = (hit: Hit) => {
|
2021-07-28 11:19:00 +02:00
|
|
|
const namespace = (hit.fields["namespace"] as ValueHitField).value as string;
|
|
|
|
|
const name = (hit.fields["name"] as ValueHitField).value as string;
|
2021-07-14 11:49:38 +02:00
|
|
|
return `${namespace}/${name}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type HitsProps = {
|
|
|
|
|
hits: Hit[];
|
|
|
|
|
index: number;
|
2021-07-28 11:19:00 +02:00
|
|
|
gotoDetailSearch: () => void;
|
2021-07-14 11:49:38 +02:00
|
|
|
clear: () => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const QuickSearchNotification: FC = ({ children }) => <div className="dropdown-content p-4">{children}</div>;
|
|
|
|
|
|
2021-07-28 11:19:00 +02:00
|
|
|
type GotoProps = {
|
|
|
|
|
gotoDetailSearch: () => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const EmptyHits: FC<GotoProps> = ({ gotoDetailSearch }) => {
|
2021-07-14 11:49:38 +02:00
|
|
|
const [t] = useTranslation("commons");
|
|
|
|
|
return (
|
|
|
|
|
<QuickSearchNotification>
|
|
|
|
|
<Notification type="info">{t("search.quickSearch.noResults")}</Notification>
|
2021-07-28 11:19:00 +02:00
|
|
|
<MoreResults gotoDetailSearch={gotoDetailSearch} />
|
2021-07-14 11:49:38 +02:00
|
|
|
</QuickSearchNotification>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type ErrorProps = {
|
|
|
|
|
error: Error;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ParseErrorNotification: FC = () => {
|
|
|
|
|
const [t] = useTranslation("commons");
|
|
|
|
|
// TODO add link to query syntax page/modal
|
|
|
|
|
return (
|
|
|
|
|
<QuickSearchNotification>
|
|
|
|
|
<Notification type="warning">{t("search.quickSearch.parseError")}</Notification>
|
|
|
|
|
</QuickSearchNotification>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isBackendError = (error: Error | BackendError): error is BackendError => {
|
|
|
|
|
return (error as BackendError).errorCode !== undefined;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const SearchErrorNotification: FC<ErrorProps> = ({ error }) => {
|
|
|
|
|
// 5VScek8Xp1 is the id of sonia.scm.search.QueryParseException
|
|
|
|
|
if (isBackendError(error) && error.errorCode === "5VScek8Xp1") {
|
|
|
|
|
return <ParseErrorNotification />;
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<QuickSearchNotification>
|
|
|
|
|
<ErrorNotification error={error} />
|
|
|
|
|
</QuickSearchNotification>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2021-07-28 11:19:00 +02:00
|
|
|
const ResultHeading = styled.h3`
|
2021-07-14 11:49:38 +02:00
|
|
|
border-bottom: 1px solid lightgray;
|
|
|
|
|
margin: 0 0.5rem;
|
|
|
|
|
padding: 0.375rem 0.5rem;
|
2021-07-28 11:19:00 +02:00
|
|
|
font-weight: bold;
|
2021-07-14 11:49:38 +02:00
|
|
|
`;
|
|
|
|
|
|
2021-07-26 13:50:24 +02:00
|
|
|
const DropdownMenu = styled.div`
|
|
|
|
|
max-width: 20rem;
|
|
|
|
|
`;
|
|
|
|
|
|
2021-07-28 11:19:00 +02:00
|
|
|
const ResultFooter = styled.div`
|
|
|
|
|
border-top: 1px solid lightgray;
|
|
|
|
|
margin: 0 0.5rem;
|
|
|
|
|
padding: 0.375rem 0.5rem;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MoreResults: FC<GotoProps> = ({ gotoDetailSearch }) => {
|
|
|
|
|
const [t] = useTranslation("commons");
|
|
|
|
|
return (
|
|
|
|
|
<ResultFooter className="dropdown-item has-text-centered">
|
|
|
|
|
<Button action={gotoDetailSearch} color="primary" data-omnisearch="true">
|
|
|
|
|
{t("search.quickSearch.moreResults")}
|
|
|
|
|
</Button>
|
|
|
|
|
</ResultFooter>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const Hits: FC<HitsProps> = ({ hits, index, clear, gotoDetailSearch }) => {
|
2021-07-14 11:49:38 +02:00
|
|
|
const id = useCallback(namespaceAndName, [hits]);
|
|
|
|
|
const [t] = useTranslation("commons");
|
|
|
|
|
|
|
|
|
|
if (hits.length === 0) {
|
2021-07-28 11:19:00 +02:00
|
|
|
return <EmptyHits gotoDetailSearch={gotoDetailSearch} />;
|
2021-07-14 11:49:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2021-07-28 11:19:00 +02:00
|
|
|
<div aria-expanded="true" role="listbox" className="dropdown-content">
|
2021-07-14 11:49:38 +02:00
|
|
|
<ResultHeading className="dropdown-item">{t("search.quickSearch.resultHeading")}</ResultHeading>
|
|
|
|
|
{hits.map((hit, idx) => (
|
|
|
|
|
<div key={id(hit)} onMouseDown={(e) => e.preventDefault()} onClick={clear}>
|
|
|
|
|
<Link
|
2021-07-26 13:50:24 +02:00
|
|
|
className={classNames("dropdown-item", "has-text-weight-medium", "is-ellipsis-overflow", {
|
|
|
|
|
"is-active": idx === index,
|
|
|
|
|
})}
|
|
|
|
|
title={id(hit)}
|
2021-07-14 11:49:38 +02:00
|
|
|
to={`/repo/${id(hit)}`}
|
2021-07-28 11:19:00 +02:00
|
|
|
role="option"
|
|
|
|
|
data-omnisearch="true"
|
2021-07-14 11:49:38 +02:00
|
|
|
>
|
|
|
|
|
{id(hit)}
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
2021-07-28 11:19:00 +02:00
|
|
|
<MoreResults gotoDetailSearch={gotoDetailSearch} />
|
2021-07-14 11:49:38 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2021-07-28 11:19:00 +02:00
|
|
|
const useKeyBoardNavigation = (gotoDetailSearch: () => void, clear: () => void, hits?: Array<Hit>) => {
|
2021-07-14 11:49:38 +02:00
|
|
|
const [index, setIndex] = useState(-1);
|
|
|
|
|
const history = useHistory();
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setIndex(-1);
|
|
|
|
|
}, [hits]);
|
|
|
|
|
|
2021-07-28 11:19:00 +02:00
|
|
|
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
2021-07-14 11:49:38 +02:00
|
|
|
// 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();
|
2021-07-28 11:19:00 +02:00
|
|
|
} else {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
gotoDetailSearch();
|
2021-07-14 11:49:38 +02:00
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case 27: // e.code: Escape
|
2021-07-28 11:19:00 +02:00
|
|
|
if (index >= 0) {
|
|
|
|
|
setIndex(-1);
|
|
|
|
|
} else {
|
|
|
|
|
clear();
|
|
|
|
|
}
|
2021-07-14 11:49:38 +02:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2021-07-28 11:19:00 +02:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2021-07-14 11:49:38 +02:00
|
|
|
const useShowResultsOnFocus = () => {
|
|
|
|
|
const [showResults, setShowResults] = useState(false);
|
2021-07-28 11:19:00 +02:00
|
|
|
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]);
|
2021-07-14 11:49:38 +02:00
|
|
|
return {
|
|
|
|
|
showResults,
|
2021-07-28 11:19:00 +02:00
|
|
|
onClick: (e: MouseEvent<HTMLInputElement>) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setShowResults(true);
|
|
|
|
|
},
|
|
|
|
|
onKeyPress: () => setShowResults(true),
|
2021-07-14 11:49:38 +02:00
|
|
|
onFocus: () => setShowResults(true),
|
2021-07-28 11:19:00 +02:00
|
|
|
hideResults: () => setShowResults(false),
|
2021-07-14 11:49:38 +02:00
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const OmniSearch: FC = () => {
|
|
|
|
|
const [query, setQuery] = useState("");
|
|
|
|
|
const debouncedQuery = useDebounce(query, 250);
|
2021-07-28 11:19:00 +02:00
|
|
|
const { data, isLoading, error } = useSearch(debouncedQuery, { type: "repository", pageSize: 5 });
|
|
|
|
|
const { showResults, hideResults, ...handlers } = useShowResultsOnFocus();
|
|
|
|
|
const history = useHistory();
|
2021-07-14 11:49:38 +02:00
|
|
|
|
|
|
|
|
const clearQuery = () => {
|
|
|
|
|
setQuery("");
|
|
|
|
|
};
|
2021-07-28 11:19:00 +02:00
|
|
|
|
|
|
|
|
const gotoDetailSearch = () => {
|
|
|
|
|
history.push(`/search/repository/?q=${query}`);
|
|
|
|
|
hideResults();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const { onKeyDown, index } = useKeyBoardNavigation(gotoDetailSearch, clearQuery, data?._embedded.hits);
|
2021-07-14 11:49:38 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Field className="navbar-item field">
|
|
|
|
|
<div
|
|
|
|
|
className={classNames("control", "has-icons-right", {
|
|
|
|
|
"is-loading": isLoading,
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
<div className={classNames("dropdown", { "is-active": (!!data || error) && showResults })}>
|
|
|
|
|
<div className="dropdown-trigger">
|
|
|
|
|
<Input
|
|
|
|
|
className="input is-small"
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="Search ..."
|
|
|
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
|
|
|
onKeyDown={onKeyDown}
|
|
|
|
|
value={query}
|
2021-07-28 11:19:00 +02:00
|
|
|
role="combobox"
|
|
|
|
|
aria-autocomplete="list"
|
|
|
|
|
data-omnisearch="true"
|
2021-07-14 11:49:38 +02:00
|
|
|
{...handlers}
|
|
|
|
|
/>
|
|
|
|
|
{isLoading ? null : (
|
|
|
|
|
<span className="icon is-right">
|
|
|
|
|
<i className="fas fa-search" />
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2021-07-28 11:19:00 +02:00
|
|
|
<DropdownMenu className="dropdown-menu" onMouseDown={(e) => e.preventDefault()}>
|
2021-07-14 11:49:38 +02:00
|
|
|
{error ? <SearchErrorNotification error={error} /> : null}
|
2021-07-28 11:19:00 +02:00
|
|
|
{!error && data ? (
|
|
|
|
|
<Hits gotoDetailSearch={gotoDetailSearch} clear={clearQuery} index={index} hits={data._embedded.hits} />
|
|
|
|
|
) : null}
|
2021-07-26 13:50:24 +02:00
|
|
|
</DropdownMenu>
|
2021-07-14 11:49:38 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Field>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const OmniSearchGuard: FC<Props> = ({ links }) => {
|
|
|
|
|
if (!links.search) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return <OmniSearch />;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default OmniSearchGuard;
|