mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-01-06 23:52:12 +01:00
Context sensitive search (#2102)
Extend global search to search context-sensitive in repositories and namespaces.
This commit is contained in:
2
gradle/changelog/context_sensitive_search.yaml
Normal file
2
gradle/changelog/context_sensitive_search.yaml
Normal 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))
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ import java.util.Set;
|
||||
*
|
||||
* @author Sebastian Sdorra
|
||||
*/
|
||||
@IndexedType
|
||||
@IndexedType(namespaceScoped = true)
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
@XmlRootElement(name = "repositories")
|
||||
@StaticPermissions(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
48
scm-ui/ui-api/src/NamespaceAndNameContext.tsx
Normal file
48
scm-ui/ui-api/src/NamespaceAndNameContext.tsx
Normal 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>;
|
||||
};
|
||||
@@ -70,3 +70,4 @@ export { default as ApiProvider } from "./ApiProvider";
|
||||
export * from "./ApiProvider";
|
||||
|
||||
export * from "./LegacyContext";
|
||||
export * from "./NamespaceAndNameContext";
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user