Context sensitive search (#2102)

Extend global search to search context-sensitive in repositories and namespaces.
This commit is contained in:
Eduard Heimbuch
2022-08-04 11:29:05 +02:00
parent 6c82142643
commit 550ebefd93
34 changed files with 1061 additions and 308 deletions

View File

@@ -0,0 +1,2 @@
- type: changed
description: Extend global search to enable context-sensitive search queries. ([#2102](https://github.com/scm-manager/scm-manager/pull/2102))

View File

@@ -60,4 +60,32 @@ public @interface IndexedType {
* @return required permission for searching this type.
*/
String permission() default "";
/**
* If this is <code>true</code>, objects of this type will be available to be searched for in
* the scope of a single repository or a namespace. This implies, that the id for this type
* has to have a repository set that can be queried. This implicitly enables the search in
* the scope of a namespace, too (so implicitly sets {@link #namespaceScoped()}
* <code>true</code>).
*
* @return <code>true</code>, if this object shall be available to be searched for in the
* scope of a repository.
*
* @since 2.38.0
*/
boolean repositoryScoped() default false;
/**
* If this is <code>true</code>, objects of this type will be available to be searched for in
* the scope of a single namespace. This implies, that the id for this type has a repository
* set that can be queried. If {@link #repositoryScoped()} is set to <code>true</code>, this
* will be assumed to be <code>true</code>, too, so this does not have to be set explicitly
* in this case.
*
* @return <code>true</code>, if this object shall be available to be searched for in the
* scope of a namespace.
*
* @since 2.38.0
*/
boolean namespaceScoped() default false;
}

View File

@@ -53,7 +53,7 @@ import java.util.Set;
*
* @author Sebastian Sdorra
*/
@IndexedType
@IndexedType(namespaceScoped = true)
@XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "repositories")
@StaticPermissions(

View File

@@ -29,9 +29,13 @@ import lombok.Value;
import sonia.scm.ModelObject;
import sonia.scm.repository.Repository;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import static java.util.Arrays.asList;
/**
* Build and execute queries against an index.
*
@@ -41,7 +45,7 @@ import java.util.Map;
@Beta
public abstract class QueryBuilder<T> {
private final Map<Class<?>, String> filters = new HashMap<>();
private final Map<Class<?>, Collection<String>> filters = new HashMap<>();
private int start = 0;
private int limit = 10;
@@ -55,7 +59,7 @@ public abstract class QueryBuilder<T> {
* @see Id#and(Class, String)
*/
public QueryBuilder<T> filter(Class<?> type, String id) {
filters.put(type, id);
addToFilter(type, id);
return this;
}
@@ -67,10 +71,18 @@ public abstract class QueryBuilder<T> {
* @see Id#and(Class, String)
*/
public QueryBuilder<T> filter(Repository repository) {
filters.put(Repository.class, repository.getId());
addToFilter(Repository.class, repository.getId());
return this;
}
private void addToFilter(Class<?> type, String id) {
if (filters.containsKey(type)) {
filters.get(type).add(id);
} else {
filters.put(type, new ArrayList<>(asList(id)));
}
}
/**
* Return only results which are related to the given part of the id.
* Note: this function can be called multiple times.
@@ -152,7 +164,7 @@ public abstract class QueryBuilder<T> {
@Value
static class QueryParams {
String queryString;
Map<Class<?>, String> filters;
Map<Class<?>, Collection<String>> filters;
int start;
int limit;

View File

@@ -57,4 +57,12 @@ public interface SearchableType {
* @since 2.23.0
*/
Collection<? extends SearchableField> getFields();
default boolean limitableToRepository() {
return false;
}
default boolean limitableToNamespace() {
return false;
}
}

View File

@@ -29,6 +29,8 @@ import sonia.scm.group.Group;
import sonia.scm.repository.Repository;
import sonia.scm.user.User;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class QueryBuilderTest {
@@ -44,7 +46,7 @@ class QueryBuilderTest {
queryBuilder.filter(repository).execute("awesome");
assertThat(params.getFilters()).containsEntry(Repository.class, "hog");
assertThat(params.getFilters()).containsEntry(Repository.class, List.of("hog"));
}
@Test
@@ -54,8 +56,8 @@ class QueryBuilderTest {
.count("awesome");
assertThat(params.getFilters())
.containsEntry(User.class, "one")
.containsEntry(Group.class, "crew");
.containsEntry(User.class, List.of("one"))
.containsEntry(Group.class, List.of("crew"));
}
@Test

View File

@@ -0,0 +1,48 @@
/*
* 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, { createContext, FC, useContext, useState } from "react";
export type NamespaceAndNameContext = {
namespace?: string;
setNamespace: (namespace: string) => void;
name?: string;
setName: (name: string) => void;
};
const Context = createContext<NamespaceAndNameContext | undefined>(undefined);
export const useNamespaceAndNameContext = () => {
const context = useContext(Context);
if (!context) {
throw new Error("useNamespaceAndNameContext can't be used outside of ApiProvider");
}
return context;
};
export const NamespaceAndNameContextProvider: FC = ({ children }) => {
const [namespace, setNamespace] = useState("");
const [name, setName] = useState("");
return <Context.Provider value={{ namespace, setNamespace, name, setName }}>{children}</Context.Provider>;
};

View File

@@ -70,3 +70,4 @@ export { default as ApiProvider } from "./ApiProvider";
export * from "./ApiProvider";
export * from "./LegacyContext";
export * from "./NamespaceAndNameContext";

View File

@@ -30,11 +30,20 @@ import { useQueries, useQuery } from "react-query";
import { useEffect, useState } from "react";
import SYNTAX from "./help/search/syntax";
import MODAL from "./help/search/modal";
import { useRepository } from "./repositories";
import { useNamespace } from "./namespaces";
export type SearchOptions = {
type: string;
page?: number;
pageSize?: number;
namespaceContext?: string;
repositoryNameContext?: string;
};
type SearchLinks = {
links: Link[];
isLoading: boolean;
};
const defaultSearchOptions: SearchOptions = {
@@ -43,24 +52,29 @@ const defaultSearchOptions: SearchOptions = {
const isString = (str: string | undefined): str is string => !!str;
export const useSearchTypes = () => {
return useSearchLinks()
.map((link) => link.name)
.filter(isString);
export const useSearchTypes = (options?: SearchOptions) => {
const searchLinks = useSearchLinks(options);
if (searchLinks?.isLoading) {
return [];
}
return searchLinks?.links?.map((link) => link.name).filter(isString) || [];
};
export const useSearchableTypes = () => useIndexJsonResource<SearchableType[]>("searchableTypes");
export const useSearchCounts = (types: string[], query: string) => {
const searchLinks = useSearchLinks();
export const useSearchCounts = (types: string[], query: string, options?: SearchOptions) => {
const { links, isLoading } = useSearchLinks(options);
const result: { [type: string]: ApiResultWithFetching<number> } = {};
const queries = useQueries(
types.map((type) => ({
queryKey: ["search", type, query, "count"],
queryFn: () =>
apiClient.get(`${findLink(searchLinks, type)}?q=${query}&countOnly=true`).then((response) => response.json()),
apiClient.get(`${findLink(links, type)}?q=${query}&countOnly=true`).then((response) => response.json()),
enabled: !isLoading,
}))
);
const result: { [type: string]: ApiResultWithFetching<number> } = {};
queries.forEach((q, i) => {
result[types[i]] = {
isLoading: q.isLoading,
@@ -69,6 +83,7 @@ export const useSearchCounts = (types: string[], query: string) => {
data: (q.data as QueryResult)?.totalHits,
};
});
return result;
};
@@ -81,28 +96,55 @@ const findLink = (links: Link[], name: string) => {
throw new Error(`could not find search link for ${name}`);
};
const useSearchLinks = () => {
const useSearchLinks = (options?: SearchOptions): SearchLinks => {
const links = useIndexLinks();
const { data: namespace, isLoading: namespaceLoading } = useNamespace(options?.namespaceContext || "");
const { data: repo, isLoading: repoLoading } = useRepository(
options?.namespaceContext || "",
options?.repositoryNameContext || ""
);
if (options?.repositoryNameContext) {
return { links: repo?._links["search"] as Link[], isLoading: repoLoading };
}
if (options?.namespaceContext) {
return { links: namespace?._links["search"] as Link[], isLoading: namespaceLoading };
}
const searchLinks = links["search"];
if (!searchLinks) {
throw new Error("could not find search links in index");
throw new Error("could not find useInternalSearch links in index");
}
if (!Array.isArray(searchLinks)) {
throw new Error("search links returned in wrong format, array is expected");
throw new Error("useInternalSearch links returned in wrong format, array is expected");
}
return searchLinks as Link[];
return { links: searchLinks, isLoading: false };
};
const useSearchLink = (name: string) => {
const searchLinks = useSearchLinks();
return findLink(searchLinks, name);
const useSearchLink = (options: SearchOptions) => {
const { links, isLoading } = useSearchLinks(options);
if (isLoading) {
return undefined;
}
return findLink(links, options.type);
};
export const useOmniSearch = (query: string, optionParam = defaultSearchOptions): ApiResult<QueryResult> => {
const options = { ...defaultSearchOptions, ...optionParam };
const link = useSearchLink({ ...options, repositoryNameContext: "", namespaceContext: "" });
return useInternalSearch(query, options, link);
};
export const useSearch = (query: string, optionParam = defaultSearchOptions): ApiResult<QueryResult> => {
const options = { ...defaultSearchOptions, ...optionParam };
const link = useSearchLink(options.type);
const link = useSearchLink(options);
return useInternalSearch(query, options, link);
};
const useInternalSearch = (query: string, options: SearchOptions, link?: string) => {
const queryParams: Record<string, string> = {};
queryParams.q = query;
if (options.page) {
@@ -115,7 +157,7 @@ export const useSearch = (query: string, optionParam = defaultSearchOptions): Ap
["search", options.type, queryParams],
() => apiClient.get(`${link}?${createQueryString(queryParams)}`).then((response) => response.json()),
{
enabled: query?.length > 1,
enabled: query?.length > 1 && !!link,
}
);
};

View File

@@ -22,7 +22,13 @@
* SOFTWARE.
*/
import { concat, getNamespaceAndPageFromMatch, getQueryStringFromLocation, withEndingSlash } from "./urls";
import {
concat,
getNamespaceAndPageFromMatch,
getQueryStringFromLocation,
getValueStringFromLocationByKey,
withEndingSlash,
} from "./urls";
describe("tests for withEndingSlash", () => {
it("should append missing slash", () => {
@@ -102,3 +108,28 @@ describe("tests for getQueryStringFromLocation", () => {
expect(getQueryStringFromLocation(location)).toBeUndefined();
});
});
describe("tests for getValueStringFromLocationByKey", () => {
function createLocation(search: string) {
return {
search,
};
}
it("should return the value string", () => {
const location = createLocation("?name=abc");
expect(getValueStringFromLocationByKey(location, "name")).toBe("abc");
});
it("should return value string from multiple parameters", () => {
const location = createLocation("?x=a&y=b&q=abc&z=c");
expect(getValueStringFromLocationByKey(location, "x")).toBe("a");
expect(getValueStringFromLocationByKey(location, "y")).toBe("b");
expect(getValueStringFromLocationByKey(location, "z")).toBe("c");
});
it("should return undefined if q is not available", () => {
const location = createLocation("?x=a&y=b&z=c");
expect(getValueStringFromLocationByKey(location, "namespace")).toBeUndefined();
});
});

View File

@@ -93,6 +93,15 @@ export function getQueryStringFromLocation(location: { search?: string }): strin
}
}
export function getValueStringFromLocationByKey(location: { search?: string }, key: string): string | undefined {
if (location.search) {
const value = queryString.parse(location.search)[key];
if (value && !Array.isArray(value)) {
return value;
}
}
}
export function stripEndingSlash(url: string) {
if (url.endsWith("/")) {
return url.substring(0, url.length - 1);

View File

@@ -205,16 +205,17 @@
"noHits": "Die Suche ergab keine Treffer",
"syntaxHelp": "Finden Sie bessere Ergebnisse durch die Nutzung der vollen <0>Such-Syntax</0>",
"quickSearch": {
"resultHeading": "Top-Ergebnisse Repositories",
"resultHeading": "Top Ergebnisse",
"parseError": "Der Suchstring is ungültig.",
"parseErrorHelp": "Hinweise zu ihrer Suche",
"moreResults": "Mehr Ergebnisse",
"noResults": "Es konnten keine Repositories gefunden werden",
"hintsIcon": "Suchtipps",
"hints": "Hinweise zu ihrer Suche",
"screenReaderHintNoResult": "Keine Repositories gefunden. Mögliche weitere Ergebnistypen mit Enter anzeigen.",
"screenReaderHint": "Ein Repository gefunden. Mögliche weitere Ergebnistypen mit Enter anzeigen oder Pfeiltasten verwenden um zu den gefunden Repositories zu navigieren und mit Enter bestätigen.",
"screenReaderHint_plural": "Mindestens {{ count }} Repositories gefunden. Mögliche weitere Ergebnistypen mit Enter anzeigen oder Pfeiltasten verwenden um zu den gefunden Repositories zu navigieren und mit Enter bestätigen."
"screenReaderHint_plural": "Mindestens {{ count }} Repositories gefunden. Mögliche weitere Ergebnistypen mit Enter anzeigen oder Pfeiltasten verwenden um zu den gefunden Repositories zu navigieren und mit Enter bestätigen.",
"searchRepo": "In Repository suchen",
"searchNamespace": "In Namespace suchen",
"searchEverywhere": "Überall suchen"
},
"syntax": {
"title": "Experten-Suche",

View File

@@ -178,7 +178,7 @@
"feedback": {
"button": "Feedback",
"modalTitle": "Share your feedback"
},
},
"cardColumnGroup": {
"showContent": "Show content",
"hideContent": "Hide content"
@@ -206,16 +206,17 @@
"noHits": "No results found",
"syntaxHelp": "Find better results by using the full <0>search syntax</0>",
"quickSearch": {
"resultHeading": "Top repository results",
"resultHeading": "Quick results",
"parseError": "Failed to parse query.",
"parseErrorHelp": "Hints for your Search",
"noResults": "Could not find matching repository",
"moreResults": "More Results",
"hintsIcon": "Search Hints",
"hints": "Hints for your Search",
"screenReaderHintNoResult": "No repositories found. Other result types may be available, hit enter to navigate to complete search result.",
"screenReaderHint": "Found one repository. Hit enter to see search results of all types or use arrow keys to navigate to repository quick results and hit enter to select one of them.",
"screenReaderHint_plural": "Found at least {{ count }} repositories. Hit enter to see search results of all types or use arrow keys to navigate to repository quick results and hit enter to select one of them."
"screenReaderHint_plural": "Found at least {{ count }} repositories. Hit enter to see search results of all types or use arrow keys to navigate to repository quick results and hit enter to select one of them.",
"searchRepo": "Search in repository",
"searchNamespace": "Search in namespace",
"searchEverywhere": "Search everywhere"
},
"syntax": {
"title": "Expert Search",

View File

@@ -27,7 +27,7 @@ import { ErrorBoundary, Header, Loading } from "@scm-manager/ui-components";
import PluginLoader from "./PluginLoader";
import ScrollToTop from "./ScrollToTop";
import IndexErrorPage from "./IndexErrorPage";
import { useIndex } from "@scm-manager/ui-api";
import { useIndex, NamespaceAndNameContextProvider } from "@scm-manager/ui-api";
import { Link } from "@scm-manager/ui-types";
import i18next from "i18next";
import { binder, extensionPoints } from "@scm-manager/ui-extensions";
@@ -60,9 +60,11 @@ const Index: FC = () => {
return (
<ErrorBoundary fallback={IndexErrorPage}>
<ScrollToTop>
<PluginLoader link={link} loaded={pluginsLoaded} callback={() => setPluginsLoaded(true)}>
<App />
</PluginLoader>
<NamespaceAndNameContextProvider>
<PluginLoader link={link} loaded={pluginsLoaded} callback={() => setPluginsLoaded(true)}>
<App />
</PluginLoader>
</NamespaceAndNameContextProvider>
</ScrollToTop>
</ErrorBoundary>
);

View File

@@ -21,25 +21,30 @@
* 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, useEffect, useState } from "react";
import { Hit, Links, ValueHitField } from "@scm-manager/ui-types";
import React, {
Dispatch,
FC,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent,
ReactElement,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Hit, Links, Repository, ValueHitField } from "@scm-manager/ui-types";
import styled from "styled-components";
import { useSearch } from "@scm-manager/ui-api";
import { useNamespaceAndNameContext, useOmniSearch, useSearchTypes } from "@scm-manager/ui-api";
import classNames from "classnames";
import { Link, useHistory, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Button,
devices,
HitProps,
Notification,
RepositoryAvatar,
useStringHitFieldValue
} from "@scm-manager/ui-components";
import { devices, Icon, RepositoryAvatar } from "@scm-manager/ui-components";
import SyntaxHelp from "../search/SyntaxHelp";
import SyntaxModal from "../search/SyntaxModal";
import SearchErrorNotification from "../search/SearchErrorNotification";
import queryString from "query-string";
import { orderTypes } from "../search/Search";
const Input = styled.input`
border-radius: 4px !important;
@@ -54,30 +59,16 @@ const namespaceAndName = (hit: Hit) => {
const name = (hit.fields["name"] as ValueHitField).value as string;
return `${namespace}/${name}`;
};
type ExtractProps<T> = T extends React.ComponentType<infer U> ? U : never;
type HitsProps = {
entries: ReactElement<ExtractProps<typeof HitEntry>>[];
hits: Hit[];
index: number;
showHelp: () => void;
gotoDetailSearch: () => void;
clear: () => void;
};
const QuickSearchNotification: FC = ({ children }) => <div className="dropdown-content p-4">{children}</div>;
type GotoProps = {
gotoDetailSearch: () => void;
};
const EmptyHits: FC = () => {
const [t] = useTranslation("commons");
return (
<Notification className="m-4" type="info">
{t("search.quickSearch.noResults")}
</Notification>
);
};
const ResultHeading = styled.h3`
border-bottom: 1px solid lightgray;
`;
@@ -86,23 +77,14 @@ const DropdownMenu = styled.div`
max-width: 20rem;
`;
const ResultFooter = styled.div`
border-top: 1px solid lightgray;
`;
const SearchInput = styled(Input)`
@media screen and (max-width: ${devices.mobile.width}px) {
width: 9rem;
}
`;
const AvatarSection: FC<HitProps> = ({ hit }) => {
const namespace = useStringHitFieldValue(hit, "namespace");
const name = useStringHitFieldValue(hit, "name");
const type = useStringHitFieldValue(hit, "type");
const repository = hit._embedded?.repository;
if (!namespace || !name || !type || !repository) {
const AvatarSection: FC<{ repository: Repository }> = ({ repository }) => {
if (!repository) {
return null;
}
@@ -113,47 +95,42 @@ const AvatarSection: FC<HitProps> = ({ hit }) => {
);
};
const MoreResults: FC<GotoProps> = ({ gotoDetailSearch }) => {
const [t] = useTranslation("commons");
const HitsList: FC<Omit<HitsProps, "showHelp" | "hits">> = ({ entries }) => {
return (
<ResultFooter className={classNames("dropdown-item", "has-text-centered", "mx-2", "px-2", "py-1")}>
<Button action={gotoDetailSearch} color="primary" data-omnisearch="true">
{t("search.quickSearch.moreResults")}
</Button>
</ResultFooter>
<ul id="omni-search-results" aria-expanded="true" role="listbox">
{entries}
</ul>
);
};
const HitsList: FC<HitsProps> = ({ hits, index, clear, gotoDetailSearch }) => {
const id = useCallback(namespaceAndName, [hits]);
if (hits.length === 0) {
return <EmptyHits />;
}
const HitEntry: FC<{ selected: boolean; link: string; label: string; clear: () => void; repository?: Repository }> = ({
selected,
link,
label,
clear,
repository,
}) => {
return (
<ul id="omni-search-results" aria-expanded="true" role="listbox">
{hits.map((hit, idx) => (
<li
key={id(hit)}
onMouseDown={e => e.preventDefault()}
onClick={clear}
role="option"
aria-selected={idx === index}
id={idx === index ? "omni-search-selected-option" : undefined}
>
<Link
className={classNames("is-flex", "dropdown-item", "has-text-weight-medium", "is-ellipsis-overflow", {
"is-active": idx === index
})}
title={id(hit)}
to={`/repo/${id(hit)}`}
data-omnisearch="true"
>
<AvatarSection hit={hit} />
{id(hit)}
</Link>
</li>
))}
</ul>
<li
key={label}
onMouseDown={(e) => e.preventDefault()}
onClick={clear}
role="option"
aria-selected={selected}
id={selected ? "omni-search-selected-option" : undefined}
>
<Link
className={classNames("is-flex", "dropdown-item", "has-text-weight-medium", "is-ellipsis-overflow", {
"is-active": selected,
})}
title={label}
to={link}
data-omnisearch="true"
>
{repository ? <AvatarSection repository={repository} /> : <Icon name="search" className="mr-3 ml-1 mt-1" />}
{label}
</Link>
</li>
);
};
@@ -171,7 +148,7 @@ const ScreenReaderHitSummary: FC<ScreenReaderHitSummaryProps> = ({ hits }) => {
);
};
const Hits: FC<HitsProps> = ({ showHelp, gotoDetailSearch, hits, ...rest }) => {
const Hits: FC<HitsProps> = ({ entries, hits, showHelp, ...rest }) => {
const [t] = useTranslation("commons");
return (
@@ -193,53 +170,54 @@ const Hits: FC<HitsProps> = ({ showHelp, gotoDetailSearch, hits, ...rest }) => {
<span>{t("search.quickSearch.resultHeading")}</span>
<SyntaxHelp onClick={showHelp} />
</ResultHeading>
<HitsList showHelp={showHelp} gotoDetailSearch={gotoDetailSearch} hits={hits} {...rest} />
<MoreResults gotoDetailSearch={gotoDetailSearch} />
<HitsList entries={entries} {...rest} />
</div>
</>
);
};
const useKeyBoardNavigation = (gotoDetailSearch: () => void, clear: () => void, hits?: Array<Hit>) => {
const [index, setIndex] = useState(-1);
const useKeyBoardNavigation = (
entries: HitsProps["entries"],
clear: () => void,
hideResults: () => void,
index: number,
setIndex: Dispatch<SetStateAction<number>>,
defaultLink: string
) => {
const history = useHistory();
useEffect(() => {
setIndex(-1);
}, [hits]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
// 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;
});
}
setIndex((idx) => {
if (idx < entries.length) {
return idx + 1;
}
return idx;
});
break;
case 38: // e.code: ArrowUp
if (hits) {
setIndex(idx => {
if (idx > 0) {
return idx - 1;
}
return idx;
});
}
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();
if (index < 0) {
history.push(defaultLink);
} else {
e.preventDefault();
gotoDetailSearch();
const entry = entries[index];
if (entry.props.link) {
history.push(entry.props.link);
}
}
clear();
hideResults();
break;
case 27: // e.code: Escape
if (index >= 0) {
@@ -253,7 +231,7 @@ const useKeyBoardNavigation = (gotoDetailSearch: () => void, clear: () => void,
return {
onKeyDown,
index
index,
};
};
@@ -270,12 +248,8 @@ const useDebounce = (value: string, delay: number) => {
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);
return element.getAttribute("data-omnisearch");
};
const useShowResultsOnFocus = () => {
@@ -312,7 +286,7 @@ const useShowResultsOnFocus = () => {
},
onKeyPress: () => setShowResults(true),
onFocus: () => setShowResults(true),
hideResults: () => setShowResults(false)
hideResults: () => setShowResults(false),
};
};
@@ -342,7 +316,7 @@ const useSearchParams = () => {
return {
searchType,
initialQuery
initialQuery,
};
};
@@ -351,30 +325,88 @@ const OmniSearch: FC = () => {
const { searchType, initialQuery } = useSearchParams();
const [query, setQuery] = useState(initialQuery);
const debouncedQuery = useDebounce(query, 250);
const { data, isLoading, error } = useSearch(debouncedQuery, { type: "repository", pageSize: 5 });
const context = useNamespaceAndNameContext();
const { data, isLoading, error } = useOmniSearch(debouncedQuery, {
type: "repository",
pageSize: 5,
});
const { showResults, hideResults, ...handlers } = useShowResultsOnFocus();
const [showHelp, setShowHelp] = useState(false);
const history = useHistory();
const [index, setIndex] = useState(-1);
useEffect(() => {
setIndex(-1);
}, []);
const openHelp = () => setShowHelp(true);
const closeHelp = () => setShowHelp(false);
const clearQuery = () => setQuery("");
const clearQuery = useCallback(() => setQuery(""), []);
const gotoDetailSearch = () => {
if (query.length > 1) {
history.push(`/search/${searchType}/?q=${query}`);
hideResults();
const hits = data?._embedded?.hits || [];
const searchTypes = useSearchTypes({
type: "",
namespaceContext: context.namespace || "",
repositoryNameContext: context.name || "",
});
searchTypes.sort(orderTypes);
const id = useCallback(namespaceAndName, []);
const entries = useMemo(() => {
const newEntries = [];
if (context.namespace && context.name && searchTypes.length > 0) {
newEntries.push(
<HitEntry
selected={newEntries.length === index}
clear={clearQuery}
label={t("search.quickSearch.searchRepo")}
link={`/search/${searchTypes[0]}/?q=${query}&namespace=${context.namespace}&name=${context.name}`}
/>
);
}
};
if (context.namespace) {
newEntries.push(
<HitEntry
selected={newEntries.length === index}
clear={clearQuery}
label={t("search.quickSearch.searchNamespace")}
link={`/search/repository/?q=${query}&namespace=${context.namespace}`}
/>
);
}
newEntries.push(
<HitEntry
selected={newEntries.length === index}
clear={clearQuery}
label={t("search.quickSearch.searchEverywhere")}
link={`/search/repository/?q=${query}`}
/>
);
const length = newEntries.length;
hits?.forEach((hit, idx) => {
newEntries.push(
<HitEntry
key={idx}
selected={length + idx === index}
clear={clearQuery}
label={id(hit)}
link={`/repo/${id(hit)}`}
repository={hit._embedded?.repository}
/>
);
});
return newEntries;
}, [clearQuery, context.name, context.namespace, hits, id, index, query, searchTypes, t]);
const { onKeyDown, index } = useKeyBoardNavigation(gotoDetailSearch, clearQuery, data?._embedded?.hits);
const defaultLink = `/search/${searchType}/?q=${query}`;
const { onKeyDown } = useKeyBoardNavigation(entries, clearQuery, hideResults, index, setIndex, defaultLink);
return (
<div className={classNames("navbar-item", "field", "mb-0")}>
{showHelp ? <SyntaxModal close={closeHelp} /> : null}
<div
className={classNames("control", "has-icons-right", {
"is-loading": isLoading
"is-loading": isLoading,
})}
>
<div className={classNames("dropdown", { "is-active": (!!data || error) && showResults })}>
@@ -383,7 +415,7 @@ const OmniSearch: FC = () => {
className="input is-small"
type="text"
placeholder={t("search.placeholder")}
onChange={e => setQuery(e.target.value)}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
value={query}
role="combobox"
@@ -401,21 +433,13 @@ const OmniSearch: FC = () => {
</span>
)}
</div>
<DropdownMenu className="dropdown-menu" onMouseDown={e => e.preventDefault()}>
<DropdownMenu className="dropdown-menu" onMouseDown={(e) => e.preventDefault()}>
{error ? (
<QuickSearchNotification>
<SearchErrorNotification error={error} showHelp={openHelp} />
</QuickSearchNotification>
) : null}
{!error && data ? (
<Hits
showHelp={openHelp}
gotoDetailSearch={gotoDetailSearch}
clear={clearQuery}
index={index}
hits={data._embedded?.hits || []}
/>
) : null}
{!error && data ? <Hits showHelp={openHelp} hits={hits} entries={entries} /> : null}
</DropdownMenu>
</div>
</div>

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useState } from "react";
import React, { FC, useEffect, useState } from "react";
import { useHistory, useLocation, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
@@ -32,10 +32,10 @@ import {
OverviewPageActions,
Page,
PageActions,
urls
urls,
} from "@scm-manager/ui-components";
import RepositoryList from "../components/list";
import { useNamespaces, useRepositories } from "@scm-manager/ui-api";
import { useNamespaceAndNameContext, useNamespaces, useRepositories } from "@scm-manager/ui-api";
import { NamespaceCollection, RepositoryCollection } from "@scm-manager/ui-types";
import { ExtensionPoint, extensionPoints, useBinder } from "@scm-manager/ui-extensions";
import styled from "styled-components";
@@ -65,7 +65,7 @@ const useOverviewData = () => {
const search = urls.getQueryStringFromLocation(location);
const request = {
namespace: namespaces?._embedded.namespaces.find(n => n.namespace === namespace),
namespace: namespaces?._embedded.namespaces.find((n) => n.namespace === namespace),
// ui starts counting by 1,
// but backend starts counting by 0
page: page - 1,
@@ -75,7 +75,7 @@ const useOverviewData = () => {
// also do not fetch repositories if an invalid namespace is selected
disabled:
(!!namespace && !namespaces) ||
(!!namespace && !namespaces?._embedded.namespaces.some(n => n.namespace === namespace))
(!!namespace && !namespaces?._embedded.namespaces.some((n) => n.namespace === namespace)),
};
const { isLoading: isLoadingRepositories, error: errorRepositories, data: repositories } = useRepositories(request);
@@ -86,7 +86,7 @@ const useOverviewData = () => {
namespace,
repositories,
search,
page
page,
};
};
@@ -122,11 +122,22 @@ const Repositories: FC<RepositoriesProps> = ({ namespaces, namespace, repositori
}
};
function getCurrentGroup(namespace?: string, namespaces?: NamespaceCollection) {
return namespace && namespaces?._embedded.namespaces.some((n) => n.namespace === namespace) ? namespace : "";
}
const Overview: FC = () => {
const { isLoading, error, namespace, namespaces, repositories, search, page } = useOverviewData();
const history = useHistory();
const [t] = useTranslation("repos");
const binder = useBinder();
const context = useNamespaceAndNameContext();
useEffect(() => {
context.setNamespace(namespace || "");
return () => {
context.setNamespace("");
};
}, [namespace, context]);
const extensions = binder.getExtensions<extensionPoints.RepositoryOverviewLeft>("repository.overview.left");
@@ -152,7 +163,7 @@ const Overview: FC = () => {
const allNamespacesPlaceholder = t("overview.allNamespaces");
let namespacesToRender: string[] = [];
if (namespaces) {
namespacesToRender = [allNamespacesPlaceholder, ...namespaces._embedded.namespaces.map(n => n.namespace).sort()];
namespacesToRender = [allNamespacesPlaceholder, ...namespaces._embedded.namespaces.map((n) => n.namespace).sort()];
}
const namespaceSelected = (newNamespace: string) => {
if (newNamespace === allNamespacesPlaceholder) {
@@ -183,7 +194,7 @@ const Overview: FC = () => {
<div className="columns">
{hasExtensions ? (
<StickyColumn className="column is-one-third">
{extensions.map(extension => React.createElement(extension))}
{extensions.map((extension) => React.createElement(extension))}
</StickyColumn>
) : null}
<div className="column is-clipped">
@@ -205,9 +216,7 @@ const Overview: FC = () => {
</label>
<OverviewPageActions
showCreateButton={showCreateButton}
currentGroup={
namespace && namespaces?._embedded.namespaces.some(n => n.namespace === namespace) ? namespace : ""
}
currentGroup={getCurrentGroup(namespace, namespaces)}
groups={namespacesToRender}
groupSelected={namespaceSelected}
groupAriaLabelledby="select-namespace"

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { match as Match } from "react-router";
import { Link as RouteLink, Redirect, Route, RouteProps, Switch, useRouteMatch } from "react-router-dom";
import { useTranslation } from "react-i18next";
@@ -43,7 +43,7 @@ import {
SecondaryNavigationColumn,
StateMenuContextProvider,
SubNavigation,
urls
urls,
} from "@scm-manager/ui-components";
import RepositoryDetails from "../components/RepositoryDetails";
import EditRepo from "./EditRepo";
@@ -60,7 +60,7 @@ import SourceExtensions from "../sources/containers/SourceExtensions";
import TagsOverview from "../tags/container/TagsOverview";
import CompareRoot from "../compare/CompareRoot";
import TagRoot from "../tags/container/TagRoot";
import { useIndexLinks, useRepository } from "@scm-manager/ui-api";
import { useIndexLinks, useNamespaceAndNameContext, useRepository } from "@scm-manager/ui-api";
import styled from "styled-components";
const TagGroup = styled.span`
@@ -85,7 +85,7 @@ const useRepositoryFromUrl = (match: Match<UrlParams>) => {
const { data: repository, ...rest } = useRepository(namespace, name);
return {
repository,
...rest
...rest,
};
};
@@ -94,8 +94,19 @@ const RepositoryRoot = () => {
const { isLoading, error, repository } = useRepositoryFromUrl(match);
const indexLinks = useIndexLinks();
const [showHealthCheck, setShowHealthCheck] = useState(false);
const [t] = useTranslation("repos");
const context = useNamespaceAndNameContext();
useEffect(() => {
if (repository) {
context.setNamespace(repository.namespace);
context.setName(repository.name);
}
return () => {
context.setNamespace("");
context.setName("");
};
}, [repository, context]);
if (error) {
return (
@@ -119,7 +130,7 @@ const RepositoryRoot = () => {
error,
repoLink: (indexLinks.repositories as Link)?.href,
indexLinks,
match
match,
};
const redirectUrlFactory = binder.getExtension<extensionPoints.RepositoryRedirect>("repository.redirect", props);
@@ -130,16 +141,16 @@ const RepositoryRoot = () => {
redirectedUrl = url + "/code/sources/";
}
const fileControlFactoryFactory: (changeset: Changeset) => FileControlFactory = changeset => file => {
const fileControlFactoryFactory: (changeset: Changeset) => FileControlFactory = (changeset) => (file) => {
const baseUrl = `${url}/code/sources`;
const sourceLink = file.newPath && {
url: `${baseUrl}/${changeset.id}/${file.newPath}/`,
label: t("diff.jumpToSource")
label: t("diff.jumpToSource"),
};
const targetLink = file.oldPath &&
changeset._embedded?.parents?.length === 1 && {
url: `${baseUrl}/${changeset._embedded.parents[0].id}/${file.oldPath}`,
label: t("diff.jumpToTarget")
label: t("diff.jumpToTarget"),
};
const links = [];
@@ -177,7 +188,7 @@ const RepositoryRoot = () => {
const extensionProps = {
repository,
url,
indexLinks
indexLinks,
};
const matchesBranches = (route: RouteProps) => {
@@ -305,7 +316,7 @@ const RepositoryRoot = () => {
props={{
repository,
url: urls.escapeUrlForRoute(url),
indexLinks
indexLinks,
}}
renderAll={true}
/>

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import React, { FC, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Redirect, Route, Switch, useRouteMatch } from "react-router-dom";
import {
@@ -40,7 +40,7 @@ import {
import Permissions from "../../permissions/containers/Permissions";
import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions";
import PermissionsNavLink from "./PermissionsNavLink";
import { useNamespace } from "@scm-manager/ui-api";
import { useNamespace, useNamespaceAndNameContext } from "@scm-manager/ui-api";
type Params = {
namespaceName: string;
@@ -51,6 +51,16 @@ const NamespaceRoot: FC = () => {
const { isLoading, error, data: namespace } = useNamespace(match.params.namespaceName);
const [t] = useTranslation("namespaces");
const url = urls.matchedUrlFromMatch(match);
const context = useNamespaceAndNameContext();
useEffect(() => {
if (namespace) {
context.setNamespace(namespace.namespace);
}
return () => {
context.setNamespace("");
};
}, [namespace, context]);
if (error) {
return (
@@ -64,7 +74,7 @@ const NamespaceRoot: FC = () => {
const extensionProps = {
namespace,
url
url,
};
return (

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import React, { FC, useState } from "react";
import React, { FC, useEffect, useState } from "react";
import {
CustomQueryFlexWrappedColumns,
Level,
@@ -31,10 +31,10 @@ import {
PrimaryContentColumn,
SecondaryNavigation,
Tag,
urls
urls,
} from "@scm-manager/ui-components";
import { Link, useLocation, useParams } from "react-router-dom";
import { useSearch, useSearchCounts, useSearchTypes } from "@scm-manager/ui-api";
import { useSearch, useSearchCounts, useSearchTypes, useNamespaceAndNameContext } from "@scm-manager/ui-api";
import Results from "./Results";
import { Trans, useTranslation } from "react-i18next";
import SearchErrorNotification from "./SearchErrorNotification";
@@ -43,6 +43,8 @@ import SyntaxModal from "./SyntaxModal";
type PathParams = {
type: string;
page: string;
namespace: string;
name: string;
};
type CountProps = {
@@ -67,14 +69,18 @@ const usePageParams = () => {
const { type: selectedType, ...params } = useParams<PathParams>();
const page = urls.getPageFromMatch({ params });
const query = urls.getQueryStringFromLocation(location);
const namespace = urls.getValueStringFromLocationByKey(location, "namespace");
const name = urls.getValueStringFromLocationByKey(location, "name");
return {
page,
selectedType,
query
query,
namespace,
name,
};
};
const orderTypes = (left: string, right: string) => {
export const orderTypes = (left: string, right: string) => {
if (left === "repository" && right !== "repository") {
return -1;
} else if (left !== "repository" && right === "repository") {
@@ -100,7 +106,7 @@ const SearchSubTitle: FC<Props> = ({ selectedType, query }) => {
<>
{t("search.subtitle", {
query,
type: t(`plugins:search.types.${selectedType}.subtitle`, selectedType)
type: t(`plugins:search.types.${selectedType}.subtitle`, selectedType),
})}
<br />
<Trans i18nKey="search.syntaxHelp" components={[<SyntaxHelpLink />]} />
@@ -111,26 +117,40 @@ const SearchSubTitle: FC<Props> = ({ selectedType, query }) => {
const Search: FC = () => {
const [t] = useTranslation(["commons", "plugins"]);
const [showHelp, setShowHelp] = useState(false);
const { query, selectedType, page } = usePageParams();
const { data, isLoading, error } = useSearch(query, {
const { query, selectedType, page, namespace, name } = usePageParams();
const context = useNamespaceAndNameContext();
useEffect(() => {
context.setNamespace(namespace || "");
context.setName(name || "");
return () => {
context.setNamespace("");
context.setName("");
};
}, [namespace, name, context]);
const searchOptions = {
type: selectedType,
page: page - 1,
pageSize: 25
});
const types = useSearchTypes();
pageSize: 25,
namespaceContext: namespace,
repositoryNameContext: name,
};
const { data, isLoading, error } = useSearch(query, searchOptions);
const types = useSearchTypes(searchOptions);
types.sort(orderTypes);
const searchCounts = useSearchCounts(
types.filter(t => t !== selectedType),
query
types.filter((type) => type !== selectedType),
query,
searchOptions
);
const counts = {
[selectedType]: {
isLoading,
error,
data: data?.totalHits
data: data?.totalHits,
},
...searchCounts
...searchCounts,
};
return (
@@ -147,8 +167,15 @@ const Search: FC = () => {
<Results result={data} query={query} page={page} type={selectedType} />
</PrimaryContentColumn>
<SecondaryNavigation label={t("search.types")} collapsible={false}>
{types.map(type => (
<NavLink key={type} to={`/search/${type}/?q=${query}`} label={type} activeOnlyWhenExact={false}>
{types.map((type) => (
<NavLink
key={type}
to={`/search/${type}/?q=${query}${namespace ? "&namespace=" + namespace : ""}${
name ? "&name=" + name : ""
}`}
label={type}
activeOnlyWhenExact={false}
>
<Level
left={t(`plugins:search.types.${type}.navItem`, type)}
right={

View File

@@ -150,7 +150,7 @@ public class IndexDtoGenerator extends HalAppenderMapper {
builder.single(link("importLog", resourceLinks.repository().importLog("IMPORT_LOG_ID").replace("IMPORT_LOG_ID", "{logId}")));
builder.array(searchLinks());
builder.single(link("searchableTypes", resourceLinks.search().searchableTypes()));
builder.single(link("searchableTypes", resourceLinks.searchableTypes().searchableTypes()));
if (!Strings.isNullOrEmpty(configuration.getAlertsUrl())) {
builder.single(link("alerts", resourceLinks.alerts().get()));

View File

@@ -24,21 +24,29 @@
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Link;
import de.otto.edison.hal.Links;
import sonia.scm.repository.NamespacePermissions;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SearchableType;
import javax.inject.Inject;
import java.util.List;
import java.util.stream.Collectors;
import static de.otto.edison.hal.Link.link;
import static de.otto.edison.hal.Link.linkBuilder;
import static de.otto.edison.hal.Links.linkingTo;
class NamespaceToNamespaceDtoMapper {
private final ResourceLinks links;
private final SearchEngine searchEngine;
@Inject
NamespaceToNamespaceDtoMapper(ResourceLinks links) {
NamespaceToNamespaceDtoMapper(ResourceLinks links, SearchEngine searchEngine) {
this.links = links;
this.searchEngine = searchEngine;
}
NamespaceDto map(String namespace) {
@@ -51,6 +59,18 @@ class NamespaceToNamespaceDtoMapper {
linkingTo
.single(link("permissions", links.namespacePermission().all(namespace)));
}
linkingTo.array(searchLinks(namespace));
linkingTo.single(link("searchableTypes", links.searchableTypes().searchableTypesForNamespace(namespace)));
return new NamespaceDto(namespace, linkingTo.build());
}
private List<Link> searchLinks(String namespace) {
return searchEngine.getSearchableTypes().stream()
.filter(SearchableType::limitableToNamespace)
.map(SearchableType::getName)
.map(typeName ->
linkBuilder("search", links.search().queryForNamespace(typeName, namespace)).withName(typeName).build()
)
.collect(Collectors.toList());
}
}

View File

@@ -45,15 +45,19 @@ import sonia.scm.repository.api.Command;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.repository.api.ScmProtocol;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SearchableType;
import sonia.scm.web.EdisonHalAppender;
import sonia.scm.web.api.RepositoryToHalMapper;
import javax.inject.Inject;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static de.otto.edison.hal.Embedded.embeddedBuilder;
import static de.otto.edison.hal.Link.link;
import static de.otto.edison.hal.Link.linkBuilder;
import static de.otto.edison.hal.Links.linkingTo;
import static java.util.stream.Collectors.toList;
@@ -74,6 +78,8 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit
private HealthCheckService healthCheckService;
@Inject
private SCMContextProvider contextProvider;
@Inject
private SearchEngine searchEngine;
abstract HealthCheckFailureDto toDto(HealthCheckFailure failure);
@@ -165,6 +171,8 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit
if (RepositoryPermissions.healthCheck(repository).isPermitted() && !healthCheckService.checkRunning(repository)) {
linksBuilder.single(link("runHealthCheck", resourceLinks.repository().runHealthCheck(repository.getNamespace(), repository.getName())));
}
linksBuilder.single(link("searchableTypes", resourceLinks.searchableTypes().searchableTypesForRepository(repository.getNamespace(), repository.getName())));
linksBuilder.array(searchLinks(repository.getNamespace(), repository.getName()));
Embedded.Builder embeddedBuilder = embeddedBuilder();
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), repository);
@@ -174,6 +182,16 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit
return repositoryDto;
}
private List<Link> searchLinks(String namespace, String name) {
return searchEngine.getSearchableTypes().stream()
.filter(SearchableType::limitableToRepository)
.map(SearchableType::getName)
.map(typeName ->
linkBuilder("search", resourceLinks.search().queryForRepository(namespace, name, typeName)).withName(typeName).build()
)
.collect(Collectors.toList());
}
private boolean isRenameNamespacePossible() {
for (NamespaceStrategy strategy : strategies) {
if (strategy.getClass().getSimpleName().equals(scmConfiguration.getNamespaceStrategy())) {

View File

@@ -1176,15 +1176,44 @@ class ResourceLinks {
private final LinkBuilder searchLinkBuilder;
SearchLinks(ScmPathInfo pathInfo) {
this.searchLinkBuilder = new LinkBuilder(pathInfo, SearchResource.class);
this.searchLinkBuilder = new LinkBuilder(pathInfo, SearchResource.class, SearchResource.SearchEndpoints.class);
}
public String query(String type) {
return searchLinkBuilder.method("query").parameters(type).href();
return searchLinkBuilder.method("query").parameters().method("globally").parameters(type).href();
}
public String queryForNamespace(String namespace, String type) {
return searchLinkBuilder.method("query").parameters().method("forNamespace").parameters(type, namespace).href();
}
public String queryForRepository(String namespace, String name, String type) {
return searchLinkBuilder.method("query").parameters().method("forRepository").parameters(namespace, name, type).href();
}
}
public SearchableTypesLinks searchableTypes() {
return new SearchableTypesLinks(accessScmPathInfoStore().get());
}
public static class SearchableTypesLinks {
private final LinkBuilder searchLinkBuilder;
SearchableTypesLinks(ScmPathInfo pathInfo) {
this.searchLinkBuilder = new LinkBuilder(pathInfo, SearchResource.class, SearchResource.SearchableTypesEndpoints.class);
}
public String searchableTypes() {
return searchLinkBuilder.method("searchableTypes").parameters().href();
return searchLinkBuilder.method("searchableTypes").parameters().method("globally").parameters().href();
}
public String searchableTypesForNamespace(String namespace) {
return searchLinkBuilder.method("searchableTypes").parameters().method("forNamespace").parameters(namespace).href();
}
public String searchableTypesForRepository(String namespace, String name) {
return searchLinkBuilder.method("searchableTypes").parameters().method("forRepository").parameters(namespace, name).href();
}
}

View File

@@ -24,6 +24,7 @@
package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.Parameter;
import lombok.Getter;
import javax.validation.constraints.Max;
@@ -37,7 +38,7 @@ import javax.ws.rs.core.Context;
import javax.ws.rs.core.UriInfo;
@Getter
public class SearchParameters {
class SearchParameters {
@Context
private UriInfo uriInfo;
@@ -45,26 +46,57 @@ public class SearchParameters {
@NotNull
@Size(min = 2)
@QueryParam("q")
@Parameter(
name = "q",
description = "The search expression",
required = true,
example = "query"
)
private String query;
@Min(0)
@QueryParam("page")
@DefaultValue("0")
@Parameter(
name = "page",
description = "The requested page number of the search results (zero based, defaults to 0)"
)
private int page = 0;
@Min(1)
@Max(100)
@QueryParam("pageSize")
@DefaultValue("10")
@Parameter(
name = "pageSize",
description = "The maximum number of results per page (defaults to 10)"
)
private int pageSize = 10;
@PathParam("type")
@Parameter(
name = "type",
description = "The type to search for",
example = "repository"
)
private String type;
@QueryParam("countOnly")
@Parameter(
name = "countOnly",
description = "If set to 'true', no results will be returned, only the count of hits and the page count"
)
private boolean countOnly = false;
String getSelfLink() {
return uriInfo.getAbsolutePath().toASCIIString();
}
String getNamespace() {
return null;
}
String getRepositoryName() {
return null;
}
}

View File

@@ -0,0 +1,41 @@
/*
* 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.
*/
package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.Parameter;
import lombok.Getter;
import javax.ws.rs.PathParam;
@Getter
class SearchParametersLimitedToNamespace extends SearchParameters {
@PathParam("namespace")
@Parameter(
name = "namespace",
description = "The namespace the search will be limited to"
)
private String namespace;
}

View File

@@ -0,0 +1,48 @@
/*
* 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.
*/
package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.Parameter;
import lombok.Getter;
import javax.ws.rs.PathParam;
@Getter
class SearchParametersLimitedToRepository extends SearchParameters {
@PathParam("namespace")
@Parameter(
name = "namespace",
description = "The namespace of the repository the search will be limited to"
)
private String namespace;
@PathParam("name")
@Parameter(
name = "name",
description = "The name of the repository the search will be limited to"
)
private String repositoryName;
}

View File

@@ -24,6 +24,7 @@
package sonia.scm.api.v2.resources;
import com.google.common.base.Strings;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -31,9 +32,14 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.search.QueryBuilder;
import sonia.scm.search.QueryCountResult;
import sonia.scm.search.QueryResult;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SearchableType;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
@@ -41,9 +47,12 @@ import javax.validation.Valid;
import javax.ws.rs.BeanParam;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@Path(SearchResource.PATH)
@@ -57,23 +66,22 @@ public class SearchResource {
private final SearchEngine engine;
private final QueryResultMapper queryResultMapper;
private final SearchableTypeMapper searchableTypeMapper;
private final RepositoryManager repositoryManager;
@Inject
public SearchResource(SearchEngine engine, QueryResultMapper mapper, SearchableTypeMapper searchableTypeMapper) {
public SearchResource(SearchEngine engine, QueryResultMapper mapper, SearchableTypeMapper searchableTypeMapper, RepositoryManager repositoryManager) {
this.engine = engine;
this.queryResultMapper = mapper;
this.searchableTypeMapper = searchableTypeMapper;
this.repositoryManager = repositoryManager;
}
@Path("query")
public SearchEndpoints query() {
return new SearchEndpoints();
}
@GET
@Path("query/{type}")
@Produces(VndMediaType.QUERY_RESULT)
@Operation(
summary = "Query result",
description = "Returns a collection of matched hits.",
tags = "Search",
operationId = "search_query"
)
@ApiResponse(
responseCode = "200",
description = "success",
@@ -90,39 +98,95 @@ public class SearchResource {
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@Parameter(
name = "query",
description = "The search expression",
required = true
)
@Parameter(
name = "page",
description = "The requested page number of the search results (zero based, defaults to 0)"
)
@Parameter(
name = "pageSize",
description = "The maximum number of results per page (defaults to 10)"
)
@Parameter(
name = "countOnly",
description = "If set to 'true', no results will be returned, only the count of hits and the page count"
)
public QueryResultDto query(@Valid @BeanParam SearchParameters params) {
if (params.isCountOnly()) {
return count(params);
public class SearchEndpoints {
@GET
@Path("{type}")
@Operation(
summary = "Global query result",
description = "Returns a collection of matched hits.",
tags = "Search",
operationId = "search_query"
)
public QueryResultDto globally(@Valid @BeanParam SearchParameters params) {
if (params.isCountOnly()) {
return count(params);
}
return search(params);
}
@GET
@Path("{namespace}/{type}")
@Operation(
summary = "Query result for a namespace",
description = "Returns a collection of matched hits limited to the namespace.",
tags = "Search",
operationId = "search_query_for_namespace"
)
public QueryResultDto forNamespace(@Valid @BeanParam SearchParametersLimitedToNamespace params) {
if (params.isCountOnly()) {
return count(params);
}
return search(params);
}
@GET
@Path("{namespace}/{name}/{type}")
@Operation(
summary = "Query result for a repository",
description = "Returns a collection of matched hits limited to the repository specified by namespace and name.",
tags = "Search",
operationId = "search_query_for_repository"
)
public QueryResultDto forRepository(@Valid @BeanParam SearchParametersLimitedToRepository params) {
if (params.isCountOnly()) {
return count(params);
}
return search(params);
}
private QueryResultDto search(SearchParameters params) {
QueryBuilder<Object> queryBuilder = engine.forType(params.getType())
.search()
.start(params.getPage() * params.getPageSize())
.limit(params.getPageSize());
filterByContext(params, queryBuilder);
return queryResultMapper.map(params, queryBuilder.execute(params.getQuery()));
}
private QueryResultDto count(SearchParameters params) {
QueryBuilder<Object> queryBuilder = engine.forType(params.getType())
.search();
filterByContext(params, queryBuilder);
QueryCountResult result = queryBuilder.count(params.getQuery());
return queryResultMapper.map(params, new QueryResult(result.getTotalHits(), result.getType(), Collections.emptyList()));
}
private void filterByContext(SearchParameters params, QueryBuilder<Object> queryBuilder) {
if (!Strings.isNullOrEmpty(params.getNamespace())) {
if (!Strings.isNullOrEmpty(params.getRepositoryName())) {
Repository repository = repositoryManager.get(new NamespaceAndName(params.getNamespace(), params.getRepositoryName()));
queryBuilder.filter(repository);
} else {
repositoryManager.getAll().stream()
.filter(r -> r.getNamespace().equals(params.getNamespace()))
.forEach(queryBuilder::filter);
}
}
}
return search(params);
}
@GET
@Path("searchableTypes")
public SearchableTypesEndpoints searchableTypes() {
return new SearchableTypesEndpoints();
}
@Produces(VndMediaType.SEARCHABLE_TYPE_COLLECTION)
@Operation(
summary = "Searchable types",
description = "Returns a collection of all searchable types.",
tags = "Search",
operationId = "searchable_types"
)
@ApiResponse(
responseCode = "200",
description = "success",
@@ -138,26 +202,67 @@ public class SearchResource {
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Collection<SearchableTypeDto> searchableTypes() {
return engine.getSearchableTypes().stream().map(searchableTypeMapper::map).collect(Collectors.toList());
public class SearchableTypesEndpoints {
@GET
@Path("")
@Operation(
summary = "Globally searchable types",
description = "Returns a collection of all searchable types.",
tags = "Search",
operationId = "searchable_types"
)
public Collection<SearchableTypeDto> globally() {
return getTypes(t -> true);
}
@GET
@Path("{namespace}")
@Operation(
summary = "Searchable types in a namespace",
description = "Returns a collection of all searchable types when scoped to a namespace.",
tags = "Search",
operationId = "searchable_types_for_namespace"
)
public Collection<SearchableTypeDto> forNamespace(
@Parameter(
name = "namespace",
description = "The namespace to get the types for"
)
@PathParam("namespace") String namespace) {
return getTypes(SearchableType::limitableToNamespace);
}
@GET
@Path("{namespace}/{name}")
@Operation(
summary = "Searchable types in a repository",
description = "Returns a collection of all searchable types when scoped to a repository.",
tags = "Search",
operationId = "searchable_types_for_repository"
)
public Collection<SearchableTypeDto> forRepository(
@Parameter(
name = "namespace",
description = "The namespace of the repository to get the types for"
)
@PathParam("namespace")
String namespace,
@Parameter(
name = "name",
description = "The name of the repository to get the types for"
)
@PathParam("name")
String name
) {
return getTypes(SearchableType::limitableToRepository);
}
private List<SearchableTypeDto> getTypes(Predicate<SearchableType> predicate) {
return engine.getSearchableTypes().stream()
.filter(predicate)
.map(searchableTypeMapper::map)
.collect(Collectors.toList());
}
}
private QueryResultDto search(SearchParameters params) {
QueryResult result = engine.forType(params.getType())
.search()
.start(params.getPage() * params.getPageSize())
.limit(params.getPageSize())
.execute(params.getQuery());
return queryResultMapper.map(params, result);
}
private QueryResultDto count(SearchParameters params) {
QueryCountResult result = engine.forType(params.getType())
.search()
.count(params.getQuery());
return queryResultMapper.map(params, new QueryResult(result.getTotalHits(), result.getType(), Collections.emptyList()));
}
}

View File

@@ -37,10 +37,12 @@ import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
import static java.util.Collections.singleton;
import static sonia.scm.search.FieldNames.ID;
import static sonia.scm.search.FieldNames.PERMISSION;
@@ -153,15 +155,15 @@ class LuceneIndex<T> implements Index<T>, AutoCloseable {
private class LuceneDeleteBy implements DeleteBy {
private final Map<Class<?>, String> map = new HashMap<>();
private final Map<Class<?>, Collection<String>> map = new HashMap<>();
private LuceneDeleteBy(Class<?> type, String id) {
map.put(type, id);
map.put(type, singleton(id));
}
@Override
public DeleteBy and(Class<?> type, String id) {
map.put(type, id);
map.put(type, singleton(id));
return this;
}

View File

@@ -50,6 +50,8 @@ public class LuceneSearchableType implements SearchableType {
Map<String, Float> boosts;
Map<String, PointsConfig> pointsConfig;
TypeConverter typeConverter;
boolean repositoryScoped;
boolean namespaceScoped;
public LuceneSearchableType(Class<?> type, @Nonnull IndexedType annotation, List<LuceneSearchableField> fields) {
this.type = type;
@@ -60,6 +62,8 @@ public class LuceneSearchableType implements SearchableType {
this.boosts = boosts(fields);
this.pointsConfig = pointsConfig(fields);
this.typeConverter = TypeConverters.create(type);
this.repositoryScoped = annotation.repositoryScoped();
this.namespaceScoped = annotation.namespaceScoped();
}
public Optional<String> getPermission() {
@@ -106,4 +110,14 @@ public class LuceneSearchableType implements SearchableType {
public Collection<LuceneSearchableField> getAllFields() {
return Collections.unmodifiableCollection(fields);
}
@Override
public boolean limitableToRepository() {
return repositoryScoped;
}
@Override
public boolean limitableToNamespace() {
return repositoryScoped || namespaceScoped;
}
}

View File

@@ -25,13 +25,16 @@
package sonia.scm.search;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import java.util.Collection;
import java.util.Map;
import static org.apache.lucene.search.BooleanClause.Occur.MUST;
import static org.apache.lucene.search.BooleanClause.Occur.SHOULD;
final class Queries {
@@ -39,7 +42,7 @@ final class Queries {
}
static Query filter(Query query, QueryBuilder.QueryParams params) {
Map<Class<?>, String> filters = params.getFilters();
Map<Class<?>, Collection<String>> filters = params.getFilters();
if (!filters.isEmpty()) {
BooleanQuery.Builder builder = builder(filters);
builder.add(query, MUST);
@@ -48,15 +51,21 @@ final class Queries {
return query;
}
static Query filterQuery(Map<Class<?>, String> filters) {
static Query filterQuery(Map<Class<?>, Collection<String>> filters) {
return builder(filters).build();
}
private static BooleanQuery.Builder builder(Map<Class<?>, String> filters) {
private static BooleanQuery.Builder builder(Map<Class<?>, Collection<String>> filters) {
BooleanQuery.Builder builder = new BooleanQuery.Builder();
for (Map.Entry<Class<?>, String> e : filters.entrySet()) {
Term term = createTerm(e.getKey(), e.getValue());
builder.add(new TermQuery(term), MUST);
for (Map.Entry<Class<?>, Collection<String>> e : filters.entrySet()) {
BooleanQuery.Builder filterBuilder = new BooleanQuery.Builder();
e.getValue().forEach(
value -> {
Term term = createTerm(e.getKey(), value);
filterBuilder.add(new TermQuery(term), SHOULD);
}
);
builder.add(new BooleanClause(filterBuilder.build(), MUST));
}
return builder;
}

View File

@@ -41,11 +41,14 @@ import sonia.scm.repository.Namespace;
import sonia.scm.repository.NamespaceManager;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryPermission;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SearchableType;
import sonia.scm.web.RestDispatcher;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import java.util.Optional;
import static com.google.inject.util.Providers.of;
@@ -58,6 +61,7 @@ import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class NamespaceRootResourceTest {
@@ -68,6 +72,11 @@ class NamespaceRootResourceTest {
NamespaceManager namespaceManager;
@Mock
Subject subject;
@Mock
SearchEngine searchEngine;
@Mock
SearchableType searchableType;
RestDispatcher dispatcher = new RestDispatcher();
MockHttpResponse response = new MockHttpResponse();
@@ -89,7 +98,7 @@ class NamespaceRootResourceTest {
@BeforeEach
void setUpResources() {
NamespaceToNamespaceDtoMapper namespaceMapper = new NamespaceToNamespaceDtoMapper(links);
NamespaceToNamespaceDtoMapper namespaceMapper = new NamespaceToNamespaceDtoMapper(links, searchEngine);
NamespaceCollectionToDtoMapper namespaceCollectionToDtoMapper = new NamespaceCollectionToDtoMapper(namespaceMapper, links);
RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper = new RepositoryPermissionCollectionToDtoMapper(repositoryPermissionToRepositoryPermissionDtoMapper, links);
RepositoryPermissionDtoToRepositoryPermissionMapperImpl dtoToModelMapper = new RepositoryPermissionDtoToRepositoryPermissionMapperImpl();
@@ -111,6 +120,12 @@ class NamespaceRootResourceTest {
lenient().when(namespaceManager.get("space")).thenReturn(Optional.of(spaceNamespace));
}
@BeforeEach
void mockEmptySearchableTypes() {
lenient().when(searchEngine.getSearchableTypes())
.thenReturn(List.of(searchableType));
}
@Nested
class WithoutSpecialPermission {
@@ -165,6 +180,21 @@ class NamespaceRootResourceTest {
assertThat(response.getStatus()).isEqualTo(403);
}
@Test
void shouldReturnSearchLinks() throws URISyntaxException, UnsupportedEncodingException {
when(searchableType.limitableToNamespace()).thenReturn(true);
when(searchableType.getName()).thenReturn("crew");
MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getContentAsString())
.contains("\"search\":[{\"href\":\"/v2/search/query/space/crew\",\"name\":\"crew\"}]")
.contains("\"searchableTypes\":{\"href\":\"/v2/search/searchableTypes/space\"}");
}
}
@Nested

View File

@@ -68,6 +68,7 @@ import sonia.scm.repository.api.BundleCommandBuilder;
import sonia.scm.repository.api.Command;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.search.SearchEngine;
import sonia.scm.user.User;
import sonia.scm.web.RestDispatcher;
import sonia.scm.web.VndMediaType;
@@ -163,6 +164,8 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
private HealthCheckService healthCheckService;
@Mock
private ExportNotificationHandler notificationHandler;
@Mock
private SearchEngine searchEngine;
@Captor
private ArgumentCaptor<Predicate<Repository>> filterCaptor;

View File

@@ -45,8 +45,11 @@ import sonia.scm.repository.api.Command;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.repository.api.ScmProtocol;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SearchableType;
import java.net.URI;
import java.util.List;
import java.util.Set;
import static java.util.Collections.singletonList;
@@ -57,6 +60,7 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks;
import static sonia.scm.repository.HealthCheckFailure.templated;
@@ -90,6 +94,8 @@ public class RepositoryToRepositoryDtoMapperTest {
private HealthCheckService healthCheckService;
@Mock
private SCMContextProvider scmContextProvider;
@Mock
private SearchEngine searchEngine;
@InjectMocks
private RepositoryToRepositoryDtoMapperImpl mapper;
@@ -382,6 +388,26 @@ public class RepositoryToRepositoryDtoMapperTest {
.isNotPresent();
}
@Test
public void shouldCreateSearchLink() {
SearchableType searchableType = mock(SearchableType.class);
when(searchableType.getName()).thenReturn("crew");
when(searchableType.limitableToRepository()).thenReturn(true);
when(searchEngine.getSearchableTypes()).thenReturn(List.of(searchableType));
Repository testRepository = createTestRepository();
RepositoryDto dto = mapper.map(testRepository);
assertThat(dto.getLinks().getLinkBy("search"))
.get()
.extracting("name", "href")
.containsExactly("crew", "http://example.com/base/v2/search/query/testspace/test/crew");
assertThat(dto.getLinks().getLinkBy("searchableTypes"))
.get()
.extracting("href")
.isEqualTo("http://example.com/base/v2/search/searchableTypes/testspace/test");
}
private ScmProtocol mockProtocol(String type, String protocol) {
return new MockScmProtocol(type, protocol);
}

View File

@@ -39,11 +39,13 @@ import org.mapstruct.factory.Mappers;
import org.mockito.Answers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryCoordinates;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.search.Hit;
import sonia.scm.search.QueryBuilder;
import sonia.scm.search.QueryCountResult;
import sonia.scm.search.QueryResult;
import sonia.scm.search.SearchEngine;
@@ -66,7 +68,10 @@ import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -83,12 +88,6 @@ class SearchResourceTest {
@Mock
private HalEnricherRegistry enricherRegistry;
@Mock
private SearchableType searchableTypeOne;
@Mock
private SearchableType searchableTypeTwo;
@BeforeEach
void setUpDispatcher() {
ScmPathInfoStore scmPathInfoStore = new ScmPathInfoStore();
@@ -101,27 +100,70 @@ class SearchResourceTest {
SearchableTypeMapper searchableTypeMapper = Mappers.getMapper(SearchableTypeMapper.class);
queryResultMapper.setRegistry(enricherRegistry);
SearchResource resource = new SearchResource(
searchEngine, queryResultMapper, searchableTypeMapper
searchEngine, queryResultMapper, searchableTypeMapper, repositoryManager
);
dispatcher = new RestDispatcher();
dispatcher.addSingletonResource(resource);
}
@Test
void shouldReturnSearchableTypes() throws URISyntaxException {
when(searchEngine.getSearchableTypes()).thenReturn(Lists.list(searchableTypeOne, searchableTypeTwo));
when(searchableTypeOne.getName()).thenReturn("Type One");
when(searchableTypeTwo.getName()).thenReturn("Type Two");
@Nested
class SearchableTypes {
MockHttpRequest request = MockHttpRequest.get("/v2/search/searchableTypes");
JsonMockHttpResponse response = new JsonMockHttpResponse();
dispatcher.invoke(request, response);
@Mock
private SearchableType searchableTypeOne;
@Mock
private SearchableType searchableTypeTwo;
JsonNode contentAsJson = response.getContentAsJson();
assertThat(response.getStatus()).isEqualTo(200);
assertThat(contentAsJson.isArray()).isTrue();
assertThat(contentAsJson.get(0).get("name").asText()).isEqualTo("Type One");
assertThat(contentAsJson.get(1).get("name").asText()).isEqualTo("Type Two");
@Test
void shouldReturnGlobalSearchableTypes() throws URISyntaxException {
when(searchEngine.getSearchableTypes()).thenReturn(Lists.list(searchableTypeOne, searchableTypeTwo));
when(searchableTypeOne.getName()).thenReturn("Type One");
when(searchableTypeTwo.getName()).thenReturn("Type Two");
MockHttpRequest request = MockHttpRequest.get("/v2/search/searchableTypes");
JsonMockHttpResponse response = new JsonMockHttpResponse();
dispatcher.invoke(request, response);
JsonNode contentAsJson = response.getContentAsJson();
assertThat(response.getStatus()).isEqualTo(200);
assertThat(contentAsJson.isArray()).isTrue();
assertThat(contentAsJson.get(0).get("name").asText()).isEqualTo("Type One");
assertThat(contentAsJson.get(1).get("name").asText()).isEqualTo("Type Two");
}
@Test
void shouldReturnSearchableTypesForNamespace() throws URISyntaxException {
when(searchEngine.getSearchableTypes()).thenReturn(Lists.list(searchableTypeOne, searchableTypeTwo));
when(searchableTypeOne.getName()).thenReturn("Type One");
when(searchableTypeOne.limitableToNamespace()).thenReturn(true);
MockHttpRequest request = MockHttpRequest.get("/v2/search/searchableTypes/space");
JsonMockHttpResponse response = new JsonMockHttpResponse();
dispatcher.invoke(request, response);
JsonNode contentAsJson = response.getContentAsJson();
assertThat(response.getStatus()).isEqualTo(200);
assertThat(contentAsJson.isArray()).isTrue();
assertThat(contentAsJson.get(0).get("name").asText()).isEqualTo("Type One");
assertThat(contentAsJson.get(1)).isNull();
}
@Test
void shouldReturnSearchableTypesForRepository() throws URISyntaxException {
when(searchEngine.getSearchableTypes()).thenReturn(Lists.list(searchableTypeOne, searchableTypeTwo));
when(searchableTypeOne.getName()).thenReturn("Type One");
when(searchableTypeOne.limitableToRepository()).thenReturn(true);
MockHttpRequest request = MockHttpRequest.get("/v2/search/searchableTypes/hitchhiker/hog");
JsonMockHttpResponse response = new JsonMockHttpResponse();
dispatcher.invoke(request, response);
JsonNode contentAsJson = response.getContentAsJson();
assertThat(response.getStatus()).isEqualTo(200);
assertThat(contentAsJson.isArray()).isTrue();
assertThat(contentAsJson.get(0).get("name").asText()).isEqualTo("Type One");
assertThat(contentAsJson.get(1)).isNull();
}
}
@Test
@@ -292,7 +334,73 @@ class SearchResourceTest {
assertThat(repositoryNode.get("type").asText()).isEqualTo(heartOfGold.getType());
assertThat(repositoryNode.get("_links").get("self").get("href").asText()).isEqualTo("/v2/repositories/hitchhiker/HeartOfGold");
}
}
@Nested
class WithScope {
@Mock
private QueryBuilder<Object> internalQueryBuilder;
private final Repository repository1 = new Repository("1", "git", "space", "hog");
private final Repository repository2 = new Repository("2", "git", "space", "hitchhiker");
private final Repository repository3 = new Repository("3", "git", "earth", "42");
@BeforeEach
void mockRepositories() {
lenient().when(repositoryManager.getAll())
.thenReturn(
List.of(
repository1,
repository2,
repository3
)
);
lenient().when(repositoryManager.get(new NamespaceAndName("space", "hog")))
.thenReturn(repository1);
}
@BeforeEach
void mockSearchResult() {
when(
searchEngine.forType("string")
.search()
.start(0)
.limit(10)
).thenReturn(internalQueryBuilder);
when(internalQueryBuilder.filter(any())).thenReturn(internalQueryBuilder);
when(
internalQueryBuilder.execute("Hello")
).thenReturn(result(2L, "Hello", "Hello Again"));
}
@Test
void shouldReturnResultsScopedToNamespace() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.get("/v2/search/query/space/string?q=Hello");
JsonMockHttpResponse response = new JsonMockHttpResponse();
dispatcher.invoke(request, response);
JsonNode hits = response.getContentAsJson().get("_embedded").get("hits");
assertThat(hits.size()).isEqualTo(2);
verify(internalQueryBuilder).filter(repository1);
verify(internalQueryBuilder).filter(repository2);
verify(internalQueryBuilder, never()).filter(repository3);
}
@Test
void shouldReturnResultsScopedToRepository() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.get("/v2/search/query/space/hog/string?q=Hello");
JsonMockHttpResponse response = new JsonMockHttpResponse();
dispatcher.invoke(request, response);
JsonNode hits = response.getContentAsJson().get("_embedded").get("hits");
assertThat(hits.size()).isEqualTo(2);
verify(internalQueryBuilder).filter(repository1);
verify(internalQueryBuilder, never()).filter(repository2);
verify(internalQueryBuilder, never()).filter(repository3);
}
}
private void assertLink(JsonNode links, String self, String s) {