From 550ebefd9393350127ea34c4e35354385d5edb71 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 4 Aug 2022 11:29:05 +0200 Subject: [PATCH] Context sensitive search (#2102) Extend global search to search context-sensitive in repositories and namespaces. --- .../changelog/context_sensitive_search.yaml | 2 + .../java/sonia/scm/search/IndexedType.java | 28 ++ .../java/sonia/scm/repository/Repository.java | 2 +- .../java/sonia/scm/search/QueryBuilder.java | 20 +- .../java/sonia/scm/search/SearchableType.java | 8 + .../sonia/scm/search/QueryBuilderTest.java | 8 +- scm-ui/ui-api/src/NamespaceAndNameContext.tsx | 48 +++ scm-ui/ui-api/src/index.ts | 1 + scm-ui/ui-api/src/search.ts | 76 ++++- scm-ui/ui-api/src/urls.test.ts | 33 +- scm-ui/ui-api/src/urls.ts | 9 + .../ui-webapp/public/locales/de/commons.json | 9 +- .../ui-webapp/public/locales/en/commons.json | 11 +- scm-ui/ui-webapp/src/containers/Index.tsx | 10 +- .../ui-webapp/src/containers/OmniSearch.tsx | 290 ++++++++++-------- .../src/repos/containers/Overview.tsx | 31 +- .../src/repos/containers/RepositoryRoot.tsx | 33 +- .../namespaces/containers/NamespaceRoot.tsx | 16 +- scm-ui/ui-webapp/src/search/Search.tsx | 61 +++- .../api/v2/resources/IndexDtoGenerator.java | 2 +- .../NamespaceToNamespaceDtoMapper.java | 22 +- .../RepositoryToRepositoryDtoMapper.java | 18 ++ .../scm/api/v2/resources/ResourceLinks.java | 35 ++- .../api/v2/resources/SearchParameters.java | 34 +- .../SearchParametersLimitedToNamespace.java | 41 +++ .../SearchParametersLimitedToRepository.java | 48 +++ .../scm/api/v2/resources/SearchResource.java | 221 +++++++++---- .../java/sonia/scm/search/LuceneIndex.java | 8 +- .../scm/search/LuceneSearchableType.java | 14 + .../main/java/sonia/scm/search/Queries.java | 21 +- .../resources/NamespaceRootResourceTest.java | 32 +- .../resources/RepositoryRootResourceTest.java | 3 + .../RepositoryToRepositoryDtoMapperTest.java | 26 ++ .../api/v2/resources/SearchResourceTest.java | 148 +++++++-- 34 files changed, 1061 insertions(+), 308 deletions(-) create mode 100644 gradle/changelog/context_sensitive_search.yaml create mode 100644 scm-ui/ui-api/src/NamespaceAndNameContext.tsx create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParametersLimitedToNamespace.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParametersLimitedToRepository.java diff --git a/gradle/changelog/context_sensitive_search.yaml b/gradle/changelog/context_sensitive_search.yaml new file mode 100644 index 0000000000..e664c9e323 --- /dev/null +++ b/gradle/changelog/context_sensitive_search.yaml @@ -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)) diff --git a/scm-annotations/src/main/java/sonia/scm/search/IndexedType.java b/scm-annotations/src/main/java/sonia/scm/search/IndexedType.java index 8c24d5948e..2ae24bfeb5 100644 --- a/scm-annotations/src/main/java/sonia/scm/search/IndexedType.java +++ b/scm-annotations/src/main/java/sonia/scm/search/IndexedType.java @@ -60,4 +60,32 @@ public @interface IndexedType { * @return required permission for searching this type. */ String permission() default ""; + + /** + * If this is true, 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()} + * true). + * + * @return true, 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 true, 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 true, this + * will be assumed to be true, too, so this does not have to be set explicitly + * in this case. + * + * @return true, if this object shall be available to be searched for in the + * scope of a namespace. + * + * @since 2.38.0 + */ + boolean namespaceScoped() default false; } diff --git a/scm-core/src/main/java/sonia/scm/repository/Repository.java b/scm-core/src/main/java/sonia/scm/repository/Repository.java index 5af2086488..c5f51bef2c 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Repository.java +++ b/scm-core/src/main/java/sonia/scm/repository/Repository.java @@ -53,7 +53,7 @@ import java.util.Set; * * @author Sebastian Sdorra */ -@IndexedType +@IndexedType(namespaceScoped = true) @XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "repositories") @StaticPermissions( diff --git a/scm-core/src/main/java/sonia/scm/search/QueryBuilder.java b/scm-core/src/main/java/sonia/scm/search/QueryBuilder.java index e0a385aac8..24e0078929 100644 --- a/scm-core/src/main/java/sonia/scm/search/QueryBuilder.java +++ b/scm-core/src/main/java/sonia/scm/search/QueryBuilder.java @@ -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 { - private final Map, String> filters = new HashMap<>(); + private final Map, Collection> filters = new HashMap<>(); private int start = 0; private int limit = 10; @@ -55,7 +59,7 @@ public abstract class QueryBuilder { * @see Id#and(Class, String) */ public QueryBuilder filter(Class type, String id) { - filters.put(type, id); + addToFilter(type, id); return this; } @@ -67,10 +71,18 @@ public abstract class QueryBuilder { * @see Id#and(Class, String) */ public QueryBuilder 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 { @Value static class QueryParams { String queryString; - Map, String> filters; + Map, Collection> filters; int start; int limit; diff --git a/scm-core/src/main/java/sonia/scm/search/SearchableType.java b/scm-core/src/main/java/sonia/scm/search/SearchableType.java index bb08717029..2b43d4e33b 100644 --- a/scm-core/src/main/java/sonia/scm/search/SearchableType.java +++ b/scm-core/src/main/java/sonia/scm/search/SearchableType.java @@ -57,4 +57,12 @@ public interface SearchableType { * @since 2.23.0 */ Collection getFields(); + + default boolean limitableToRepository() { + return false; + } + + default boolean limitableToNamespace() { + return false; + } } diff --git a/scm-core/src/test/java/sonia/scm/search/QueryBuilderTest.java b/scm-core/src/test/java/sonia/scm/search/QueryBuilderTest.java index aeb902fc2d..a983af608f 100644 --- a/scm-core/src/test/java/sonia/scm/search/QueryBuilderTest.java +++ b/scm-core/src/test/java/sonia/scm/search/QueryBuilderTest.java @@ -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 diff --git a/scm-ui/ui-api/src/NamespaceAndNameContext.tsx b/scm-ui/ui-api/src/NamespaceAndNameContext.tsx new file mode 100644 index 0000000000..1204bf8d27 --- /dev/null +++ b/scm-ui/ui-api/src/NamespaceAndNameContext.tsx @@ -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(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 {children}; +}; diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index 855037195a..d113c5eb5f 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -70,3 +70,4 @@ export { default as ApiProvider } from "./ApiProvider"; export * from "./ApiProvider"; export * from "./LegacyContext"; +export * from "./NamespaceAndNameContext"; diff --git a/scm-ui/ui-api/src/search.ts b/scm-ui/ui-api/src/search.ts index 9b7165473c..529c1e8355 100644 --- a/scm-ui/ui-api/src/search.ts +++ b/scm-ui/ui-api/src/search.ts @@ -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("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 } = {}; + 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 } = {}; + 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 => { + const options = { ...defaultSearchOptions, ...optionParam }; + const link = useSearchLink({ ...options, repositoryNameContext: "", namespaceContext: "" }); + return useInternalSearch(query, options, link); }; export const useSearch = (query: string, optionParam = defaultSearchOptions): ApiResult => { 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 = {}; 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, } ); }; diff --git a/scm-ui/ui-api/src/urls.test.ts b/scm-ui/ui-api/src/urls.test.ts index ea088d184f..6300cdcde4 100644 --- a/scm-ui/ui-api/src/urls.test.ts +++ b/scm-ui/ui-api/src/urls.test.ts @@ -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(); + }); +}); diff --git a/scm-ui/ui-api/src/urls.ts b/scm-ui/ui-api/src/urls.ts index 869fdc7923..485d0a1aa9 100644 --- a/scm-ui/ui-api/src/urls.ts +++ b/scm-ui/ui-api/src/urls.ts @@ -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); diff --git a/scm-ui/ui-webapp/public/locales/de/commons.json b/scm-ui/ui-webapp/public/locales/de/commons.json index 113d84aa59..cb2efd17ce 100644 --- a/scm-ui/ui-webapp/public/locales/de/commons.json +++ b/scm-ui/ui-webapp/public/locales/de/commons.json @@ -205,16 +205,17 @@ "noHits": "Die Suche ergab keine Treffer", "syntaxHelp": "Finden Sie bessere Ergebnisse durch die Nutzung der vollen <0>Such-Syntax", "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", diff --git a/scm-ui/ui-webapp/public/locales/en/commons.json b/scm-ui/ui-webapp/public/locales/en/commons.json index c99b757adc..e0e7b84836 100644 --- a/scm-ui/ui-webapp/public/locales/en/commons.json +++ b/scm-ui/ui-webapp/public/locales/en/commons.json @@ -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", "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", diff --git a/scm-ui/ui-webapp/src/containers/Index.tsx b/scm-ui/ui-webapp/src/containers/Index.tsx index b11a54ed5d..245de67003 100644 --- a/scm-ui/ui-webapp/src/containers/Index.tsx +++ b/scm-ui/ui-webapp/src/containers/Index.tsx @@ -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 ( - setPluginsLoaded(true)}> - - + + setPluginsLoaded(true)}> + + + ); diff --git a/scm-ui/ui-webapp/src/containers/OmniSearch.tsx b/scm-ui/ui-webapp/src/containers/OmniSearch.tsx index a311dfad19..544ec2a46c 100644 --- a/scm-ui/ui-webapp/src/containers/OmniSearch.tsx +++ b/scm-ui/ui-webapp/src/containers/OmniSearch.tsx @@ -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 extends React.ComponentType ? U : never; type HitsProps = { + entries: ReactElement>[]; hits: Hit[]; - index: number; showHelp: () => void; - gotoDetailSearch: () => void; - clear: () => void; }; const QuickSearchNotification: FC = ({ children }) =>
{children}
; -type GotoProps = { - gotoDetailSearch: () => void; -}; - -const EmptyHits: FC = () => { - const [t] = useTranslation("commons"); - return ( - - {t("search.quickSearch.noResults")} - - ); -}; - 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 = ({ 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 = ({ hit }) => { ); }; -const MoreResults: FC = ({ gotoDetailSearch }) => { - const [t] = useTranslation("commons"); +const HitsList: FC> = ({ entries }) => { return ( - - - +
    + {entries} +
); }; -const HitsList: FC = ({ hits, index, clear, gotoDetailSearch }) => { - const id = useCallback(namespaceAndName, [hits]); - if (hits.length === 0) { - return ; - } +const HitEntry: FC<{ selected: boolean; link: string; label: string; clear: () => void; repository?: Repository }> = ({ + selected, + link, + label, + clear, + repository, +}) => { return ( -
    - {hits.map((hit, idx) => ( -
  • e.preventDefault()} - onClick={clear} - role="option" - aria-selected={idx === index} - id={idx === index ? "omni-search-selected-option" : undefined} - > - - - {id(hit)} - -
  • - ))} -
+
  • e.preventDefault()} + onClick={clear} + role="option" + aria-selected={selected} + id={selected ? "omni-search-selected-option" : undefined} + > + + {repository ? : } + {label} + +
  • ); }; @@ -171,7 +148,7 @@ const ScreenReaderHitSummary: FC = ({ hits }) => { ); }; -const Hits: FC = ({ showHelp, gotoDetailSearch, hits, ...rest }) => { +const Hits: FC = ({ entries, hits, showHelp, ...rest }) => { const [t] = useTranslation("commons"); return ( @@ -193,53 +170,54 @@ const Hits: FC = ({ showHelp, gotoDetailSearch, hits, ...rest }) => { {t("search.quickSearch.resultHeading")} - - + ); }; -const useKeyBoardNavigation = (gotoDetailSearch: () => void, clear: () => void, hits?: Array) => { - const [index, setIndex] = useState(-1); +const useKeyBoardNavigation = ( + entries: HitsProps["entries"], + clear: () => void, + hideResults: () => void, + index: number, + setIndex: Dispatch>, + defaultLink: string +) => { const history = useHistory(); - useEffect(() => { - setIndex(-1); - }, [hits]); const onKeyDown = (e: ReactKeyboardEvent) => { // We use e.which, because ie 11 does not support e.code // https://caniuse.com/keyboardevent-code switch (e.which) { case 40: // e.code: ArrowDown - if (hits) { - setIndex(idx => { - if (idx + 1 < hits.length) { - return idx + 1; - } - return idx; - }); - } + 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( + + ); } - }; + if (context.namespace) { + newEntries.push( + + ); + } + newEntries.push( + + ); + const length = newEntries.length; + hits?.forEach((hit, idx) => { + newEntries.push( + + ); + }); + 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 (
    {showHelp ? : null}
    @@ -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 = () => { )}
    - e.preventDefault()}> + e.preventDefault()}> {error ? ( ) : null} - {!error && data ? ( - - ) : null} + {!error && data ? : null}
    diff --git a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx index 074cc3202d..e40146d8e5 100644 --- a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx @@ -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 = ({ 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("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 = () => {
    {hasExtensions ? ( - {extensions.map(extension => React.createElement(extension))} + {extensions.map((extension) => React.createElement(extension))} ) : null}
    @@ -205,9 +216,7 @@ const Overview: FC = () => { n.namespace === namespace) ? namespace : "" - } + currentGroup={getCurrentGroup(namespace, namespaces)} groups={namespacesToRender} groupSelected={namespaceSelected} groupAriaLabelledby="select-namespace" diff --git a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx index 190df5288a..40af0212a4 100644 --- a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx @@ -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) => { 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("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} /> diff --git a/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx b/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx index 0b739b9db0..e4a455055b 100644 --- a/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx @@ -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 ( diff --git a/scm-ui/ui-webapp/src/search/Search.tsx b/scm-ui/ui-webapp/src/search/Search.tsx index 069a9e7dab..706b4cb822 100644 --- a/scm-ui/ui-webapp/src/search/Search.tsx +++ b/scm-ui/ui-webapp/src/search/Search.tsx @@ -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(); 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 = ({ selectedType, query }) => { <> {t("search.subtitle", { query, - type: t(`plugins:search.types.${selectedType}.subtitle`, selectedType) + type: t(`plugins:search.types.${selectedType}.subtitle`, selectedType), })}
    ]} /> @@ -111,26 +117,40 @@ const SearchSubTitle: FC = ({ 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 = () => { - {types.map(type => ( - + {types.map((type) => ( + 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()); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java index 47bfd36a94..046bbaec31 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java @@ -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 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())) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index f4de560a12..a2e1d2e32b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -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(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParameters.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParameters.java index d615c65516..a11e9e66a1 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParameters.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParameters.java @@ -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; + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParametersLimitedToNamespace.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParametersLimitedToNamespace.java new file mode 100644 index 0000000000..2e9e3171e2 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParametersLimitedToNamespace.java @@ -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; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParametersLimitedToRepository.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParametersLimitedToRepository.java new file mode 100644 index 0000000000..ad983ff4a7 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchParametersLimitedToRepository.java @@ -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; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchResource.java index 6f9fc10a42..41280af69f 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchResource.java @@ -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 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 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 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 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 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 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 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 getTypes(Predicate 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())); - } - } diff --git a/scm-webapp/src/main/java/sonia/scm/search/LuceneIndex.java b/scm-webapp/src/main/java/sonia/scm/search/LuceneIndex.java index e5a69e100d..9ad8fcfca5 100644 --- a/scm-webapp/src/main/java/sonia/scm/search/LuceneIndex.java +++ b/scm-webapp/src/main/java/sonia/scm/search/LuceneIndex.java @@ -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 implements Index, AutoCloseable { private class LuceneDeleteBy implements DeleteBy { - private final Map, String> map = new HashMap<>(); + private final Map, Collection> 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; } diff --git a/scm-webapp/src/main/java/sonia/scm/search/LuceneSearchableType.java b/scm-webapp/src/main/java/sonia/scm/search/LuceneSearchableType.java index 638cd2a0cb..3cd767432e 100644 --- a/scm-webapp/src/main/java/sonia/scm/search/LuceneSearchableType.java +++ b/scm-webapp/src/main/java/sonia/scm/search/LuceneSearchableType.java @@ -50,6 +50,8 @@ public class LuceneSearchableType implements SearchableType { Map boosts; Map pointsConfig; TypeConverter typeConverter; + boolean repositoryScoped; + boolean namespaceScoped; public LuceneSearchableType(Class type, @Nonnull IndexedType annotation, List 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 getPermission() { @@ -106,4 +110,14 @@ public class LuceneSearchableType implements SearchableType { public Collection getAllFields() { return Collections.unmodifiableCollection(fields); } + + @Override + public boolean limitableToRepository() { + return repositoryScoped; + } + + @Override + public boolean limitableToNamespace() { + return repositoryScoped || namespaceScoped; + } } diff --git a/scm-webapp/src/main/java/sonia/scm/search/Queries.java b/scm-webapp/src/main/java/sonia/scm/search/Queries.java index 63d1b14519..46f8d6ac79 100644 --- a/scm-webapp/src/main/java/sonia/scm/search/Queries.java +++ b/scm-webapp/src/main/java/sonia/scm/search/Queries.java @@ -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, String> filters = params.getFilters(); + Map, Collection> 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, String> filters) { + static Query filterQuery(Map, Collection> filters) { return builder(filters).build(); } - private static BooleanQuery.Builder builder(Map, String> filters) { + private static BooleanQuery.Builder builder(Map, Collection> filters) { BooleanQuery.Builder builder = new BooleanQuery.Builder(); - for (Map.Entry, String> e : filters.entrySet()) { - Term term = createTerm(e.getKey(), e.getValue()); - builder.add(new TermQuery(term), MUST); + for (Map.Entry, Collection> 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; } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java index a7219ea02b..f54be51e32 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java @@ -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 diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java index 8ada808bfc..3e70493d36 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java @@ -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> filterCaptor; diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java index d237a5a5b7..dbd7fb3a70 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java @@ -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); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java index bbcd97e228..7cdd8165af 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java @@ -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 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) {