Add detailed search result ui (#1738)

Add a dedicated search page with more results and different types.
Users and groups are now indexed along with repositories.

Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>
This commit is contained in:
Sebastian Sdorra
2021-07-28 11:19:00 +02:00
committed by GitHub
parent ad6000722d
commit 91fec0f478
60 changed files with 2665 additions and 517 deletions

View File

@@ -0,0 +1,4 @@
- type: Added
description: Add users and groups to default search index ([#1738](https://github.com/scm-manager/scm-manager/pull/1738))
- type: Added
description: Add dedicated search page with more details and different types ([#1738](https://github.com/scm-manager/scm-manager/pull/1738))

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.group;
//~--- non-JDK imports --------------------------------------------------------
@@ -34,6 +34,8 @@ import com.google.common.collect.Lists;
import sonia.scm.BasicPropertiesAware;
import sonia.scm.ModelObject;
import sonia.scm.ReducedModelObject;
import sonia.scm.search.Indexed;
import sonia.scm.search.IndexedType;
import sonia.scm.util.Util;
import sonia.scm.util.ValidationUtil;
@@ -53,6 +55,7 @@ import java.util.List;
*
* @author Sebastian Sdorra
*/
@IndexedType(permission = "group:list")
@StaticPermissions(
value = "group",
globalPermissions = {"create", "list", "autocomplete"},
@@ -484,18 +487,22 @@ public class Group extends BasicPropertiesAware
private boolean external = false;
/** timestamp of the creation date of this group */
@Indexed
private Long creationDate;
/** description of this group */
@Indexed(defaultQuery = true, highlighted = true)
private String description;
/** timestamp of the last modified date of this group */
@Indexed
private Long lastModified;
/** members of this group */
private List<String> members;
/** name of this group */
@Indexed(defaultQuery = true, boost = 1.5f)
private String name;
/** type of this group */

View File

@@ -0,0 +1,66 @@
/*
* 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.search;
import sonia.scm.HandlerEventType;
import sonia.scm.event.HandlerEvent;
/**
* Keep index in sync with {@link HandlerEvent}.
*
* @param <T> type of indexed item
* @since 2.22.0
*/
public final class HandlerEventIndexSyncer<T> {
private final Indexer<T> indexer;
public HandlerEventIndexSyncer(Indexer<T> indexer) {
this.indexer = indexer;
}
/**
* Update index based on {@link HandlerEvent}.
*
* @param event handler event
*/
public void handleEvent(HandlerEvent<T> event) {
HandlerEventType type = event.getEventType();
if (type.isPost()) {
updateIndex(type, event.getItem());
}
}
private void updateIndex(HandlerEventType type, T item) {
try (Indexer.Updater<T> updater = indexer.open()) {
if (type == HandlerEventType.DELETE) {
updater.delete(item);
} else {
updater.store(item);
}
}
}
}

View File

@@ -0,0 +1,102 @@
/*
* 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.search;
import sonia.scm.plugin.ExtensionPoint;
/**
* Indexer for a specific type.
* Note: An implementation of the indexer creates only a bootstrap index.
* To keep the index in sync you have to implement a subscriber, see {@link HandlerEventIndexSyncer}.
*
* @param <T> type to index
* @since 2.22.0
* @see HandlerEventIndexSyncer
*/
@ExtensionPoint
public interface Indexer<T> {
/**
* Returns class of type.
*
* @return class of type
*/
Class<T> getType();
/**
* Returns name of index.
*
* @return name of index
*/
String getIndex();
/**
* Returns version of index type.
* The version should be increased if the type changes and should be re indexed.
*
* @return current index type version
*/
int getVersion();
/**
* Opens the index and return an updater for the given type.
*
* @return updater with open index
*/
Updater<T> open();
/**
* Updater for index.
*
* @param <T> type to index
*/
interface Updater<T> extends AutoCloseable {
/**
* Stores the given item in the index.
*
* @param item item to index
*/
void store(T item);
/**
* Delete the given item from the index
*
* @param item item to delete
*/
void delete(T item);
/**
* Re index all existing items.
*/
void reIndexAll();
/**
* Close the given index.
*/
void close();
}
}

View File

@@ -26,7 +26,6 @@ package sonia.scm.search;
import com.google.common.annotations.Beta;
import lombok.Value;
import sonia.scm.ContextEntry;
import sonia.scm.NotFoundException;
import sonia.scm.repository.Repository;
@@ -99,6 +98,20 @@ public abstract class QueryBuilder {
return execute(new QueryParams(type, repositoryId, queryString, start, limit));
}
/**
* Executes the query and returns the total count of hits.
*
* @param type type of objects which are searched
* @param queryString searched query
*
* @return total count of hits
* @since 2.22.0
*/
public QueryCountResult count(Class<?> type, String queryString) {
return count(new QueryParams(type, repositoryId, queryString, start, limit));
}
/**
* Executes the query and returns the matches.
*
@@ -109,8 +122,20 @@ public abstract class QueryBuilder {
* @throws NotFoundException if type could not be found
*/
public QueryResult execute(String typeName, String queryString){
Class<?> type = resolveByName(typeName).orElseThrow(() -> notFound(entity("type", typeName)));
return execute(type, queryString);
return execute(resolveTypeByName(typeName), queryString);
}
/**
* Executes the query and returns the total count of hits.
*
* @param typeName type name of objects which are searched
* @param queryString searched query
*
* @return total count of hits
* @since 2.22.0
*/
public QueryCountResult count(String typeName, String queryString) {
return count(resolveTypeByName(typeName), queryString);
}
/**
@@ -128,6 +153,20 @@ public abstract class QueryBuilder {
*/
protected abstract QueryResult execute(QueryParams queryParams);
/**
* Executes the query and returns the total count of hits.
* @param queryParams query parameter
* @return total hit count
* @since 2.22.0
*/
protected QueryCountResult count(QueryParams queryParams) {
return execute(queryParams);
}
private Class<?> resolveTypeByName(String typeName) {
return resolveByName(typeName).orElseThrow(() -> notFound(entity("type", typeName)));
}
/**
* The searched query and all parameters, which belong to the query.
*/

View File

@@ -0,0 +1,58 @@
/*
* 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.search;
import com.google.common.annotations.Beta;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
/**
* Result of a counting query.
*
* @since 2.22.0
* @see QueryBuilder
*/
@Beta
@Getter
@ToString
@EqualsAndHashCode
public class QueryCountResult {
/**
* Searched type of object.
*/
private final Class<?> type;
/**
* Total count of hits, which are matched by the query.
*/
private final long totalHits;
public QueryCountResult(Class<?> type, long totalHits) {
this.type = type;
this.totalHits = totalHits;
}
}

View File

@@ -25,8 +25,11 @@
package sonia.scm.search;
import com.google.common.annotations.Beta;
import lombok.Value;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
import java.util.Collections;
import java.util.List;
/**
@@ -34,24 +37,20 @@ import java.util.List;
* @since 2.21.0
*/
@Beta
@Value
public class QueryResult {
/**
* Total count of hits, which are matched by the query.
*/
long totalHits;
/**
* Searched type of object.
*/
Class<?> type;
@Getter
@ToString
@EqualsAndHashCode(callSuper = true)
public class QueryResult extends QueryCountResult {
/**
* List of hits found by the query.
* The list contains only those hits which are starting at start and they are limit by the given amount.
* @see QueryBuilder
*/
List<Hit> hits;
private final List<Hit> hits;
public QueryResult(long totalHits, Class<?> type, List<Hit> hits) {
super(type, totalHits);
this.hits = Collections.unmodifiableList(hits);
}
}

View File

@@ -35,6 +35,8 @@ import lombok.Setter;
import sonia.scm.BasicPropertiesAware;
import sonia.scm.ModelObject;
import sonia.scm.ReducedModelObject;
import sonia.scm.search.Indexed;
import sonia.scm.search.IndexedType;
import sonia.scm.util.Util;
import sonia.scm.util.ValidationUtil;
@@ -49,6 +51,7 @@ import java.security.Principal;
permissions = {"read", "modify", "delete", "changePassword", "changePublicKeys", "changeApiKeys"},
custom = true, customGlobal = true
)
@IndexedType(permission = "user:list")
@XmlRootElement(name = "users")
@XmlAccessorType(XmlAccessType.FIELD)
@Getter
@@ -61,10 +64,15 @@ public class User extends BasicPropertiesAware implements Principal, ModelObject
private boolean active = true;
private boolean external;
@Indexed
private Long creationDate;
@Indexed(defaultQuery = true)
private String displayName;
@Indexed
private Long lastModified;
@Indexed
private String mail;
@Indexed(defaultQuery = true, boost = 1.5f)
private String name;
private String password;

View File

@@ -19,7 +19,7 @@
"@scm-manager/eslint-config": "^2.10.1",
"@scm-manager/jest-preset": "^2.12.7",
"@scm-manager/prettier-config": "^2.10.1",
"@scm-manager/tsconfig": "^2.11.2",
"@scm-manager/tsconfig": "^2.12.0",
"@testing-library/react-hooks": "^5.0.3",
"@types/react": "^17.0.1",
"react-test-renderer": "^17.0.1"

View File

@@ -26,7 +26,7 @@ import { ApiResult, useIndexLinks } from "./base";
import { Link, QueryResult } from "@scm-manager/ui-types";
import { apiClient } from "./apiclient";
import { createQueryString } from "./utils";
import { useQuery } from "react-query";
import { useQueries, useQuery } from "react-query";
export type SearchOptions = {
type: string;
@@ -38,7 +38,44 @@ const defaultSearchOptions: SearchOptions = {
type: "repository",
};
const useSearchLink = (name: string) => {
const isString = (str: string | undefined): str is string => !!str;
export const useSearchTypes = () => {
return useSearchLinks()
.map((link) => link.name)
.filter(isString);
};
export const useSearchCounts = (types: string[], query: string) => {
const searchLinks = useSearchLinks();
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()),
}))
);
const result: { [type: string]: ApiResult<number> } = {};
queries.forEach((q, i) => {
result[types[i]] = {
isLoading: q.isLoading,
error: q.error as Error,
data: (q.data as QueryResult)?.totalHits,
};
});
return result;
};
const findLink = (links: Link[], name: string) => {
for (const l of links) {
if (l.name === name) {
return l.href;
}
}
throw new Error(`could not find search link for ${name}`);
};
const useSearchLinks = () => {
const links = useIndexLinks();
const searchLinks = links["search"];
if (!searchLinks) {
@@ -48,14 +85,12 @@ const useSearchLink = (name: string) => {
if (!Array.isArray(searchLinks)) {
throw new Error("search links returned in wrong format, array is expected");
}
return searchLinks as Link[];
};
for (const l of searchLinks as Link[]) {
if (l.name === name) {
return l.href;
}
}
throw new Error(`could not find search link for ${name}`);
const useSearchLink = (name: string) => {
const searchLinks = useSearchLinks();
return findLink(searchLinks, name);
};
export const useSearch = (query: string, optionParam = defaultSearchOptions): ApiResult<QueryResult> => {
@@ -71,7 +106,7 @@ export const useSearch = (query: string, optionParam = defaultSearchOptions): Ap
queryParams.pageSize = options.pageSize.toString();
}
return useQuery<QueryResult, Error>(
["search", query],
["search", options.type, queryParams],
() => apiClient.get(`${link}?${createQueryString(queryParams)}`).then((response) => response.json()),
{
enabled: query.length > 1,

View File

@@ -24,7 +24,7 @@
"@scm-manager/eslint-config": "^2.12.0",
"@scm-manager/jest-preset": "^2.12.7",
"@scm-manager/prettier-config": "^2.10.1",
"@scm-manager/tsconfig": "^2.11.2",
"@scm-manager/tsconfig": "^2.12.0",
"@scm-manager/ui-tests": "^2.21.1-SNAPSHOT",
"@storybook/addon-actions": "^6.1.17",
"@storybook/addon-storyshots": "^6.1.17",

View File

@@ -21,126 +21,120 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { PagedCollection } from "@scm-manager/ui-types";
import { Button } from "./buttons";
type Props = WithTranslation & {
type Props = {
collection: PagedCollection;
page: number;
filter?: string;
};
class LinkPaginator extends React.Component<Props> {
addFilterToLink(link: string) {
const { filter } = this.props;
const LinkPaginator: FC<Props> = ({ collection, page, filter }) => {
const [t] = useTranslation("commons");
const addFilterToLink = (link: string) => {
if (filter) {
return `${link}?q=${filter}`;
}
return link;
}
};
renderFirstButton() {
return <Button className="pagination-link" label={"1"} disabled={false} link={this.addFilterToLink("1")} />;
}
const renderFirstButton = () => {
return <Button className="pagination-link" label={"1"} disabled={false} link={addFilterToLink("1")} />;
};
renderPreviousButton(className: string, label?: string) {
const { page } = this.props;
const renderPreviousButton = (className: string, label?: string) => {
const previousPage = page - 1;
return (
<Button
className={className}
label={label ? label : previousPage.toString()}
disabled={!this.hasLink("prev")}
link={this.addFilterToLink(`${previousPage}`)}
disabled={!hasLink("prev")}
link={addFilterToLink(`${previousPage}`)}
/>
);
}
};
hasLink(name: string) {
const { collection } = this.props;
const hasLink = (name: string) => {
return collection._links[name];
}
};
renderNextButton(className: string, label?: string) {
const { page } = this.props;
const renderNextButton = (className: string, label?: string) => {
const nextPage = page + 1;
return (
<Button
className={className}
label={label ? label : nextPage.toString()}
disabled={!this.hasLink("next")}
link={this.addFilterToLink(`${nextPage}`)}
disabled={!hasLink("next")}
link={addFilterToLink(`${nextPage}`)}
/>
);
}
};
renderLastButton() {
const { collection } = this.props;
const renderLastButton = () => {
return (
<Button
className="pagination-link"
label={`${collection.pageTotal}`}
disabled={false}
link={this.addFilterToLink(`${collection.pageTotal}`)}
link={addFilterToLink(`${collection.pageTotal}`)}
/>
);
}
};
separator() {
const separator = () => {
return <span className="pagination-ellipsis">&hellip;</span>;
}
};
currentPage(page: number) {
const currentPage = (page: number) => {
return <Button className="pagination-link is-current" label={"" + page} disabled={true} />;
}
pageLinks() {
const { collection } = this.props;
};
const pageLinks = () => {
const links = [];
const page = collection.page + 1;
const pageTotal = collection.pageTotal;
if (page > 1) {
links.push(this.renderFirstButton());
links.push(renderFirstButton());
}
if (page > 3) {
links.push(this.separator());
links.push(separator());
}
if (page > 2) {
links.push(this.renderPreviousButton("pagination-link"));
links.push(renderPreviousButton("pagination-link"));
}
links.push(this.currentPage(page));
links.push(currentPage(page));
if (page + 1 < pageTotal) {
links.push(this.renderNextButton("pagination-link"));
links.push(renderNextButton("pagination-link"));
}
if (page + 2 < pageTotal) links.push(this.separator());
if (page + 2 < pageTotal) links.push(separator());
//if there exists pages between next and last
if (page < pageTotal) {
links.push(this.renderLastButton());
links.push(renderLastButton());
}
return links;
}
render() {
const { collection, t } = this.props;
if (collection) {
return (
<nav className="pagination is-centered" aria-label="pagination">
{this.renderPreviousButton("pagination-previous", t("paginator.previous"))}
<ul className="pagination-list">
{this.pageLinks().map((link, index) => (
<li key={index}>{link}</li>
))}
</ul>
{this.renderNextButton("pagination-next", t("paginator.next"))}
</nav>
);
}
};
if (!collection) {
return null;
}
}
export default withTranslation("commons")(LinkPaginator);
return (
<nav className="pagination is-centered" aria-label="pagination">
{renderPreviousButton("pagination-previous", t("paginator.previous"))}
<ul className="pagination-list">
{pageLinks().map((link, index) => (
<li key={index}>{link}</li>
))}
</ul>
{renderNextButton("pagination-next", t("paginator.next"))}
</nav>
);
};
export default LinkPaginator;

View File

@@ -72025,11 +72025,11 @@ exports[`Storyshots Navigation|Secondary Active when match 1`] = `
className="column is-3"
>
<aside
className="SecondaryNavigation__SectionContainer-sc-8p1rgi-0 cmlWoX menu"
className="SecondaryNavigation__SectionContainer-sc-8p1rgi-0 iqbNdU menu"
>
<div>
<p
className="SecondaryNavigation__MenuLabel-sc-8p1rgi-2 fzDYSb menu-label"
className="SecondaryNavigation__MenuLabel-sc-8p1rgi-2 fCZGGb menu-label has-cursor-pointer"
onClick={[Function]}
>
<i
@@ -72087,11 +72087,11 @@ exports[`Storyshots Navigation|Secondary Default 1`] = `
className="column is-3"
>
<aside
className="SecondaryNavigation__SectionContainer-sc-8p1rgi-0 cmlWoX menu"
className="SecondaryNavigation__SectionContainer-sc-8p1rgi-0 iqbNdU menu"
>
<div>
<p
className="SecondaryNavigation__MenuLabel-sc-8p1rgi-2 fzDYSb menu-label"
className="SecondaryNavigation__MenuLabel-sc-8p1rgi-2 fCZGGb menu-label has-cursor-pointer"
onClick={[Function]}
>
<i
@@ -72149,11 +72149,11 @@ exports[`Storyshots Navigation|Secondary Extension Point 1`] = `
className="column is-3"
>
<aside
className="SecondaryNavigation__SectionContainer-sc-8p1rgi-0 cmlWoX menu"
className="SecondaryNavigation__SectionContainer-sc-8p1rgi-0 iqbNdU menu"
>
<div>
<p
className="SecondaryNavigation__MenuLabel-sc-8p1rgi-2 fzDYSb menu-label"
className="SecondaryNavigation__MenuLabel-sc-8p1rgi-2 fCZGGb menu-label has-cursor-pointer"
onClick={[Function]}
>
<i
@@ -72237,11 +72237,11 @@ exports[`Storyshots Navigation|Secondary Sub Navigation 1`] = `
className="column is-3"
>
<aside
className="SecondaryNavigation__SectionContainer-sc-8p1rgi-0 cmlWoX menu"
className="SecondaryNavigation__SectionContainer-sc-8p1rgi-0 iqbNdU menu"
>
<div>
<p
className="SecondaryNavigation__MenuLabel-sc-8p1rgi-2 fzDYSb menu-label"
className="SecondaryNavigation__MenuLabel-sc-8p1rgi-2 fCZGGb menu-label has-cursor-pointer"
onClick={[Function]}
>
<i

View File

@@ -96,6 +96,7 @@ export * from "./repos";
export * from "./table";
export * from "./toast";
export * from "./popover";
export * from "./search";
export * from "./markdown/markdownExtensions";
export {

View File

@@ -21,14 +21,13 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import * as React from "react";
import React, { FC } from "react";
import classNames from "classnames";
import { Link } from "react-router-dom";
import { RoutingProps } from "./RoutingProps";
import { FC } from "react";
import useMenuContext from "./MenuContext";
import useActiveMatch from "./useActiveMatch";
import {createAttributesForTesting} from "../devBuild";
import { createAttributesForTesting } from "../devBuild";
type Props = RoutingProps & {
label: string;
@@ -37,21 +36,29 @@ type Props = RoutingProps & {
testId?: string;
};
const NavLink: FC<Props> = ({ to, activeWhenMatch, activeOnlyWhenExact, icon, label, title, testId }) => {
type NavLinkContentProp = {
label: string;
icon?: string;
collapsed: boolean;
};
const NavLinkContent: FC<NavLinkContentProp> = ({ label, icon, collapsed }) => (
<>
{icon ? (
<>
<i className={classNames(icon, "fa-fw")} />{" "}
</>
) : null}
{collapsed ? null : label}
</>
);
const NavLink: FC<Props> = ({ to, activeWhenMatch, activeOnlyWhenExact, title, testId, children, ...contentProps }) => {
const active = useActiveMatch({ to, activeWhenMatch, activeOnlyWhenExact });
const context = useMenuContext();
const collapsed = context.isCollapsed();
let showIcon = null;
if (icon) {
showIcon = (
<>
<i className={classNames(icon, "fa-fw")} />{" "}
</>
);
}
return (
<li title={collapsed ? title : undefined}>
<Link
@@ -59,15 +66,14 @@ const NavLink: FC<Props> = ({ to, activeWhenMatch, activeOnlyWhenExact, icon, la
to={to}
{...createAttributesForTesting(testId)}
>
{showIcon}
{collapsed ? null : label}
{children ? children : <NavLinkContent {...contentProps} collapsed={collapsed} />}
</Link>
</li>
);
};
NavLink.defaultProps = {
activeOnlyWhenExact: true
activeOnlyWhenExact: true,
};
export default NavLink;

View File

@@ -25,9 +25,11 @@
import React, { FC } from "react";
import styled from "styled-components";
import useMenuContext from "./MenuContext";
import classNames from "classnames";
type Props = {
label: string;
collapsible?: boolean;
};
type CollapsedProps = {
@@ -38,6 +40,7 @@ const SectionContainer = styled.aside`
position: sticky;
position: -webkit-sticky; /* Safari */
top: 2rem;
width: 100%;
@media (max-height: 900px) {
position: relative;
@@ -55,19 +58,20 @@ const Icon = styled.i<CollapsedProps>`
const MenuLabel = styled.p<CollapsedProps>`
justify-content: ${(props: CollapsedProps) => (props.collapsed ? "center" : "inherit")};
cursor: pointer;
`;
const SecondaryNavigation: FC<Props> = ({ label, children }) => {
const SecondaryNavigation: FC<Props> = ({ label, children, collapsible = true }) => {
const menuContext = useMenuContext();
const isCollapsed = menuContext.isCollapsed();
const isCollapsed = collapsible && menuContext.isCollapsed();
const toggleCollapseState = () => {
menuContext.setCollapsed(!isCollapsed);
if (collapsible) {
menuContext.setCollapsed(!isCollapsed);
}
};
const uncollapseMenu = () => {
if (isCollapsed) {
if (collapsible && isCollapsed) {
menuContext.setCollapsed(false);
}
};
@@ -77,10 +81,16 @@ const SecondaryNavigation: FC<Props> = ({ label, children }) => {
return (
<SectionContainer className="menu">
<div>
<MenuLabel className="menu-label" collapsed={isCollapsed} onClick={toggleCollapseState}>
<Icon color="info" className="is-medium" collapsed={isCollapsed}>
{arrowIcon}
</Icon>
<MenuLabel
className={classNames("menu-label", { "has-cursor-pointer": collapsible })}
collapsed={isCollapsed}
onClick={toggleCollapseState}
>
{collapsible ? (
<Icon color="info" className="is-medium" collapsed={isCollapsed}>
{arrowIcon}
</Icon>
) : null}
{isCollapsed ? "" : label}
</MenuLabel>
<ul className="menu-list" onClick={uncollapseMenu}>

View File

@@ -26,9 +26,15 @@ import { useLocation, useRouteMatch } from "react-router-dom";
import { RoutingProps } from "./RoutingProps";
const useActiveMatch = ({ to, activeOnlyWhenExact, activeWhenMatch }: RoutingProps) => {
let path = to;
const index = to.indexOf("?");
if (index > 0) {
path = to.substr(0, index);
}
const match = useRouteMatch({
path: to,
exact: activeOnlyWhenExact
path,
exact: activeOnlyWhenExact,
});
const location = useLocation();
@@ -36,7 +42,7 @@ const useActiveMatch = ({ to, activeOnlyWhenExact, activeWhenMatch }: RoutingPro
const isActiveWhenMatch = () => {
if (activeWhenMatch) {
return activeWhenMatch({
location
location,
});
}
return false;

View File

@@ -33,6 +33,7 @@ const Avatar = styled.p`
type Props = {
repository: Repository;
size?: 16 | 24 | 32 | 48 | 64 | 96 | 128;
};
const renderExtensionPoint = (repository: Repository) => {
@@ -40,13 +41,13 @@ const renderExtensionPoint = (repository: Repository) => {
<ExtensionPoint
name="repos.repository-avatar.primary"
props={{
repository
repository,
}}
>
<ExtensionPoint
name="repos.repository-avatar"
props={{
repository
repository,
}}
>
<Image src="/images/blib.jpg" alt="Logo" />
@@ -55,8 +56,8 @@ const renderExtensionPoint = (repository: Repository) => {
);
};
const RepositoryAvatar: FC<Props> = ({ repository }) => {
return <Avatar className="image is-64x64">{renderExtensionPoint(repository)}</Avatar>;
const RepositoryAvatar: FC<Props> = ({ repository, size = 64 }) => {
return <Avatar className={`image is-${size}x${size}`}>{renderExtensionPoint(repository)}</Avatar>;
};
export default RepositoryAvatar;

View File

@@ -0,0 +1,58 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
type Props = {
value: string;
};
const HighlightedFragment: FC<Props> = ({ value }) => {
let content = value;
const result = [];
while (content.length > 0) {
const start = content.indexOf("<>");
const end = content.indexOf("</>");
if (start >= 0 && end > 0) {
if (start > 0) {
result.push(content.substring(0, start));
}
result.push(<strong>{content.substring(start + 2, end)}</strong>);
content = content.substring(end + 3);
} else {
result.push(content);
break;
}
}
return (
<>
{result.map((c, i) => (
<React.Fragment key={i}>{c}</React.Fragment>
))}
</>
);
};
export default HighlightedFragment;

View File

@@ -0,0 +1,51 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Hit as HitType } from "@scm-manager/ui-types";
export type HitProps = {
hit: HitType;
};
type SearchResultType = FC & {
Title: FC;
Left: FC;
Content: FC;
Right: FC;
};
const Hit: SearchResultType = ({ children }) => {
return <article className="media p-1">{children}</article>;
};
Hit.Title = ({ children }) => <h3 className="has-text-weight-bold is-ellipsis-overflow">{children}</h3>;
Hit.Left = ({ children }) => <div className="media-left">{children}</div>;
Hit.Right = ({ children }) => <div className="media-right is-size-7 has-text-right">{children}</div>;
Hit.Content = ({ children }) => <div className="media-content">{children}</div>;
export default Hit;

View File

@@ -0,0 +1,62 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { HighlightedHitField, Hit } from "@scm-manager/ui-types";
import HighlightedFragment from "./HighlightedFragment";
import { isHighlightedHitField } from "./fields";
type Props = {
hit: Hit;
field: string;
};
type HighlightedTextFieldProps = {
field: HighlightedHitField;
};
const HighlightedTextField: FC<HighlightedTextFieldProps> = ({ field }) => (
<>
{field.fragments.map((fr, i) => (
<React.Fragment key={fr}>
{" ... "}
<HighlightedFragment value={fr} />
{i + 1 >= field.fragments.length ? " ... " : null}
</React.Fragment>
))}
</>
);
const TextHitField: FC<Props> = ({ hit, field: fieldName }) => {
const field = hit.fields[fieldName];
if (!field) {
return null;
} else if (isHighlightedHitField(field)) {
return <HighlightedTextField field={field} />;
} else {
return <>{field.value}</>;
}
};
export default TextHitField;

View File

@@ -0,0 +1,85 @@
/*
* 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 { HitField, HighlightedHitField, Hit, ValueHitField } from "@scm-manager/ui-types";
export const isHighlightedHitField = (field: HitField): field is HighlightedHitField => {
return field.highlighted;
};
export const isValueHitField = (field: HitField): field is ValueHitField => {
return !field.highlighted;
};
export const useHitFieldValue = (hit: Hit, fieldName: string) => {
const field = hit.fields[fieldName];
if (!field) {
return;
}
if (isValueHitField(field)) {
return field.value;
} else {
throw new Error(`${fieldName} is a highlighted field and not a value field`);
}
};
export const useStringHitFieldValue = (hit: Hit, fieldName: string): string | undefined => {
const value = useHitFieldValue(hit, fieldName);
if (value) {
if (typeof value === "string") {
return value;
} else {
throw new Error(`field value of ${fieldName} is not a string`);
}
}
};
export const useNumberHitFieldValue = (hit: Hit, fieldName: string): number | undefined => {
const value = useHitFieldValue(hit, fieldName);
if (value) {
if (typeof value === "number") {
return value;
} else {
throw new Error(`field value of ${fieldName} is not a number`);
}
}
};
export const useDateHitFieldValue = (hit: Hit, fieldName: string): Date | undefined => {
const value = useNumberHitFieldValue(hit, fieldName);
if (value) {
return new Date(value);
}
};
export const useBooleanHitFieldValue = (hit: Hit, fieldName: string): boolean | undefined => {
const value = useHitFieldValue(hit, fieldName);
if (value) {
if (typeof value === "boolean") {
return value;
} else {
throw new Error(`field value of ${fieldName} is not a boolean`);
}
}
};

View File

@@ -0,0 +1,27 @@
/*
* 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.
*/
export * from "./fields";
export { default as Hit, HitProps } from "./Hit";
export { default as TextHitField } from "./TextHitField";

View File

@@ -18,7 +18,7 @@
"@scm-manager/eslint-config": "^2.12.0",
"@scm-manager/jest-preset": "^2.12.7",
"@scm-manager/prettier-config": "^2.10.1",
"@scm-manager/tsconfig": "^2.11.2",
"@scm-manager/tsconfig": "^2.12.0",
"@types/enzyme": "^3.10.3",
"@types/jest": "^24.0.19",
"@types/react": "^17.0.1"

View File

@@ -23,7 +23,7 @@
"@scm-manager/eslint-config": "^2.12.0",
"@scm-manager/jest-preset": "^2.12.7",
"@scm-manager/prettier-config": "^2.10.1",
"@scm-manager/tsconfig": "^2.11.2",
"@scm-manager/tsconfig": "^2.12.0",
"@scm-manager/ui-scripts": "^2.21.1-SNAPSHOT",
"@scm-manager/ui-tests": "^2.21.1-SNAPSHOT",
"@scm-manager/ui-types": "^2.21.1-SNAPSHOT",

View File

@@ -23,7 +23,7 @@
"access": "public"
},
"devDependencies": {
"@scm-manager/tsconfig": "^2.11.2",
"@scm-manager/tsconfig": "^2.12.0",
"@types/enzyme": "^3.10.3",
"@types/jest": "^24.0.19"
}

View File

@@ -1,7 +1,7 @@
{
"name": "@scm-manager/ui-types",
"version": "2.21.1-SNAPSHOT",
"description": "Flow types for SCM-Manager related Objects",
"description": "Typescript types for SCM-Manager related Objects",
"main": "src/index.ts",
"files": [
"dist",
@@ -14,7 +14,7 @@
"typecheck": "tsc"
},
"devDependencies": {
"@scm-manager/tsconfig": "^2.11.2"
"@scm-manager/tsconfig": "^2.12.0"
},
"babel": {
"presets": [

View File

@@ -24,21 +24,21 @@
import { HalRepresentation, PagedCollection } from "./hal";
export type ValueField = {
export type ValueHitField = {
highlighted: false;
value: unknown;
};
export type HighlightedField = {
export type HighlightedHitField = {
highlighted: true;
fragments: string[];
};
export type Field = ValueField | HighlightedField;
export type HitField = ValueHitField | HighlightedHitField;
export type Hit = HalRepresentation & {
score: number;
fields: { [name: string]: Field };
fields: { [name: string]: HitField };
};
export type HitEmbedded = {
@@ -47,4 +47,5 @@ export type HitEmbedded = {
export type QueryResult = PagedCollection<HitEmbedded> & {
type: string;
totalHits: number;
};

View File

@@ -152,9 +152,14 @@
"w_plural": "{{count}} Wochen"
},
"search": {
"title": "Suche",
"subtitle": "{{type}} Ergebnisse für \"{{query}}\"",
"types": "Ergebnisse",
"noHits": "Die Suche ergab keine Treffer",
"quickSearch": {
"resultHeading": "Top-Ergebnisse Repositories",
"parseError": "Der Suchstring is ungültig",
"moreResults": "Mehr Ergebnisse",
"noResults": "Es konnten keine Repositories gefunden werden"
}
}

View File

@@ -153,10 +153,15 @@
"w_plural": "{{count}} weeks"
},
"search": {
"title": "Search",
"subtitle": "{{type}} results for \"{{query}}\"",
"types": "Results",
"noHits": "No results found",
"quickSearch": {
"resultHeading": "Top repository results",
"parseError": "Failed to parse query",
"noResults": "Could not find matching repository"
"noResults": "Could not find matching repository",
"moreResults": "More Results"
}
}
}

View File

@@ -21,9 +21,9 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import React, { FC } from "react";
import { Redirect, Route, Switch, withRouter } from "react-router-dom";
import { Redirect, Route, Switch } from "react-router-dom";
import { Links, Me } from "@scm-manager/ui-types";
import Overview from "../repos/containers/Overview";
@@ -49,6 +49,7 @@ import NamespaceRoot from "../repos/namespaces/containers/NamespaceRoot";
import ImportLog from "../repos/importlog/ImportLog";
import CreateRepositoryRoot from "../repos/containers/CreateRepositoryRoot";
import styled from "styled-components";
import Search from "../search/Search";
type Props = {
me: Me;
@@ -60,66 +61,59 @@ type StyledMainProps = {
isSmallHeader: boolean;
};
const StyledMain = styled.div.attrs((props) => ({}))<StyledMainProps>`
const StyledMain = styled.div<StyledMainProps>`
min-height: calc(100vh - ${(props) => (props.isSmallHeader ? 250 : 210)}px);
`;
class Main extends React.Component<Props> {
render() {
const { authenticated, me, links } = this.props;
const redirectUrlFactory = binder.getExtension("main.redirect", this.props);
let url = "/";
if (authenticated) {
url = "/repos/";
}
if (redirectUrlFactory) {
url = redirectUrlFactory(this.props);
}
if (!me) {
url = "/login";
}
return (
<ErrorBoundary>
<StyledMain className="main" isSmallHeader={!!links.logout}>
<Switch>
<Redirect exact from="/" to={url} />
<Route exact path="/login" component={Login} />
<Route path="/logout" component={Logout} />
<Redirect exact strict from="/repos" to="/repos/" />
<ProtectedRoute exact path="/repos/" component={Overview} authenticated={authenticated} />
<ProtectedRoute path="/repos/create" component={CreateRepositoryRoot} authenticated={authenticated} />
<Redirect exact strict from="/repos/import" to="/repos/create/import" />
<ProtectedRoute exact path="/repos/:namespace" component={Overview} authenticated={authenticated} />
<ProtectedRoute exact path="/repos/:namespace/:page" component={Overview} authenticated={authenticated} />
<ProtectedRoute path="/repo/:namespace/:name" component={RepositoryRoot} authenticated={authenticated} />
<ProtectedRoute path="/namespace/:namespaceName" component={NamespaceRoot} authenticated={authenticated} />
<Redirect exact strict from="/users" to="/users/" />
<ProtectedRoute exact path="/users/" component={Users} authenticated={authenticated} />
<ProtectedRoute path="/users/create" component={CreateUser} authenticated={authenticated} />
<ProtectedRoute exact path="/users/:page" component={Users} authenticated={authenticated} />
<ProtectedRoute path="/user/:name" component={SingleUser} authenticated={authenticated} />
<Redirect exact strict from="/groups" to="/groups/" />
<ProtectedRoute exact path="/groups/" component={Groups} authenticated={authenticated} />
<ProtectedRoute path="/group/:name" component={SingleGroup} authenticated={authenticated} />
<ProtectedRoute path="/groups/create" component={CreateGroup} authenticated={authenticated} />
<ProtectedRoute exact path="/groups/:page" component={Groups} authenticated={authenticated} />
<ProtectedRoute path="/admin" component={Admin} authenticated={authenticated} />
<ProtectedRoute path="/me" component={Profile} authenticated={authenticated} />
<ProtectedRoute path="/importlog/:logId" component={ImportLog} authenticated={authenticated} />
<ExtensionPoint
name="main.route"
renderAll={true}
props={{
me,
links,
authenticated,
}}
/>
</Switch>
</StyledMain>
</ErrorBoundary>
);
const Main: FC<Props> = (props) => {
const { authenticated, me, links } = props;
const redirectUrlFactory = binder.getExtension("main.redirect", props);
let url = "/";
if (authenticated) {
url = "/repos/";
}
}
if (redirectUrlFactory) {
url = redirectUrlFactory(props);
}
if (!me) {
url = "/login";
}
return (
<ErrorBoundary>
<StyledMain className="main" isSmallHeader={!!links.logout}>
<Switch>
<Redirect exact from="/" to={url} />
<Route exact path="/login" component={Login} />
<Route path="/logout" component={Logout} />
<Redirect exact strict from="/repos" to="/repos/" />
<ProtectedRoute exact path="/repos/" component={Overview} authenticated={authenticated} />
<ProtectedRoute path="/repos/create" component={CreateRepositoryRoot} authenticated={authenticated} />
<Redirect exact strict from="/repos/import" to="/repos/create/import" />
<ProtectedRoute exact path="/repos/:namespace" component={Overview} authenticated={authenticated} />
<ProtectedRoute exact path="/repos/:namespace/:page" component={Overview} authenticated={authenticated} />
<ProtectedRoute path="/repo/:namespace/:name" component={RepositoryRoot} authenticated={authenticated} />
<ProtectedRoute path="/namespace/:namespaceName" component={NamespaceRoot} authenticated={authenticated} />
<Redirect exact strict from="/users" to="/users/" />
<ProtectedRoute exact path="/users/" component={Users} authenticated={authenticated} />
<ProtectedRoute path="/users/create" component={CreateUser} authenticated={authenticated} />
<ProtectedRoute exact path="/users/:page" component={Users} authenticated={authenticated} />
<ProtectedRoute path="/user/:name" component={SingleUser} authenticated={authenticated} />
<Redirect exact strict from="/groups" to="/groups/" />
<ProtectedRoute exact path="/groups/" component={Groups} authenticated={authenticated} />
<ProtectedRoute path="/group/:name" component={SingleGroup} authenticated={authenticated} />
<ProtectedRoute path="/groups/create" component={CreateGroup} authenticated={authenticated} />
<ProtectedRoute exact path="/groups/:page" component={Groups} authenticated={authenticated} />
<ProtectedRoute path="/admin" component={Admin} authenticated={authenticated} />
<ProtectedRoute path="/me" component={Profile} authenticated={authenticated} />
<ProtectedRoute path="/importlog/:logId" component={ImportLog} authenticated={authenticated} />
<Redirect exact strict from="/search/:type" to="/search/:type/" />
<ProtectedRoute path="/search/:type/:page" component={Search} authenticated={authenticated} />
<ProtectedRoute path="/search/:type/" component={Search} authenticated={authenticated} />
<ExtensionPoint name="main.route" renderAll={true} props={props} />
</Switch>
</StyledMain>
</ErrorBoundary>
);
};
export default withRouter(Main);
export default Main;

View File

@@ -21,14 +21,14 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, KeyboardEvent, MouseEvent, useCallback, useState, useEffect } from "react";
import { Hit, Links, ValueField } from "@scm-manager/ui-types";
import React, { FC, KeyboardEvent as ReactKeyboardEvent, MouseEvent, useCallback, useState, useEffect } from "react";
import { Hit, Links, ValueHitField } from "@scm-manager/ui-types";
import styled from "styled-components";
import { BackendError, useSearch } from "@scm-manager/ui-api";
import classNames from "classnames";
import { Link, useHistory } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ErrorNotification, Notification } from "@scm-manager/ui-components";
import { Button, ErrorNotification, Notification } from "@scm-manager/ui-components";
const Field = styled.div`
margin-bottom: 0 !important;
@@ -43,24 +43,30 @@ type Props = {
};
const namespaceAndName = (hit: Hit) => {
const namespace = (hit.fields["namespace"] as ValueField).value as string;
const name = (hit.fields["name"] as ValueField).value as string;
const namespace = (hit.fields["namespace"] as ValueHitField).value as string;
const name = (hit.fields["name"] as ValueHitField).value as string;
return `${namespace}/${name}`;
};
type HitsProps = {
hits: Hit[];
index: number;
gotoDetailSearch: () => void;
clear: () => void;
};
const QuickSearchNotification: FC = ({ children }) => <div className="dropdown-content p-4">{children}</div>;
const EmptyHits = () => {
type GotoProps = {
gotoDetailSearch: () => void;
};
const EmptyHits: FC<GotoProps> = ({ gotoDetailSearch }) => {
const [t] = useTranslation("commons");
return (
<QuickSearchNotification>
<Notification type="info">{t("search.quickSearch.noResults")}</Notification>
<MoreResults gotoDetailSearch={gotoDetailSearch} />
</QuickSearchNotification>
);
};
@@ -95,26 +101,44 @@ const SearchErrorNotification: FC<ErrorProps> = ({ error }) => {
);
};
const ResultHeading = styled.div`
const ResultHeading = styled.h3`
border-bottom: 1px solid lightgray;
margin: 0 0.5rem;
padding: 0.375rem 0.5rem;
font-weight: bold;
`;
const DropdownMenu = styled.div`
max-width: 20rem;
`;
const Hits: FC<HitsProps> = ({ hits, index, clear }) => {
const ResultFooter = styled.div`
border-top: 1px solid lightgray;
margin: 0 0.5rem;
padding: 0.375rem 0.5rem;
`;
const MoreResults: FC<GotoProps> = ({ gotoDetailSearch }) => {
const [t] = useTranslation("commons");
return (
<ResultFooter className="dropdown-item has-text-centered">
<Button action={gotoDetailSearch} color="primary" data-omnisearch="true">
{t("search.quickSearch.moreResults")}
</Button>
</ResultFooter>
);
};
const Hits: FC<HitsProps> = ({ hits, index, clear, gotoDetailSearch }) => {
const id = useCallback(namespaceAndName, [hits]);
const [t] = useTranslation("commons");
if (hits.length === 0) {
return <EmptyHits />;
return <EmptyHits gotoDetailSearch={gotoDetailSearch} />;
}
return (
<div className="dropdown-content">
<div aria-expanded="true" role="listbox" className="dropdown-content">
<ResultHeading className="dropdown-item">{t("search.quickSearch.resultHeading")}</ResultHeading>
{hits.map((hit, idx) => (
<div key={id(hit)} onMouseDown={(e) => e.preventDefault()} onClick={clear}>
@@ -124,23 +148,26 @@ const Hits: FC<HitsProps> = ({ hits, index, clear }) => {
})}
title={id(hit)}
to={`/repo/${id(hit)}`}
role="option"
data-omnisearch="true"
>
{id(hit)}
</Link>
</div>
))}
<MoreResults gotoDetailSearch={gotoDetailSearch} />
</div>
);
};
const useKeyBoardNavigation = (clear: () => void, hits?: Array<Hit>) => {
const useKeyBoardNavigation = (gotoDetailSearch: () => void, clear: () => void, hits?: Array<Hit>) => {
const [index, setIndex] = useState(-1);
const history = useHistory();
useEffect(() => {
setIndex(-1);
}, [hits]);
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
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) {
@@ -169,10 +196,17 @@ const useKeyBoardNavigation = (clear: () => void, hits?: Array<Hit>) => {
const hit = hits[index];
history.push(`/repo/${namespaceAndName(hit)}`);
clear();
} else {
e.preventDefault();
gotoDetailSearch();
}
break;
case 27: // e.code: Escape
clear();
if (index >= 0) {
setIndex(-1);
} else {
clear();
}
break;
}
};
@@ -196,26 +230,68 @@ 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);
};
const useShowResultsOnFocus = () => {
const [showResults, setShowResults] = useState(false);
useEffect(() => {
if (showResults) {
const close = () => {
setShowResults(false);
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.which === 9) { // tab
const element = document.activeElement;
if (!element || !isOnmiSearchElement(element)) {
close();
}
}
};
window.addEventListener("click", close);
window.addEventListener("keyup", onKeyUp);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("keyup", onKeyUp);
};
}
}, [showResults]);
return {
showResults,
onClick: (e: MouseEvent<HTMLInputElement>) => e.stopPropagation(),
onClick: (e: MouseEvent<HTMLInputElement>) => {
e.stopPropagation();
setShowResults(true);
},
onKeyPress: () => setShowResults(true),
onFocus: () => setShowResults(true),
onBlur: () => setShowResults(false),
hideResults: () => setShowResults(false),
};
};
const OmniSearch: FC = () => {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 250);
const { data, isLoading, error } = useSearch(debouncedQuery, { pageSize: 5 });
const { showResults, ...handlers } = useShowResultsOnFocus();
const { data, isLoading, error } = useSearch(debouncedQuery, { type: "repository", pageSize: 5 });
const { showResults, hideResults, ...handlers } = useShowResultsOnFocus();
const history = useHistory();
const clearQuery = () => {
setQuery("");
};
const { onKeyDown, index } = useKeyBoardNavigation(clearQuery, data?._embedded.hits);
const gotoDetailSearch = () => {
history.push(`/search/repository/?q=${query}`);
hideResults();
};
const { onKeyDown, index } = useKeyBoardNavigation(gotoDetailSearch, clearQuery, data?._embedded.hits);
return (
<Field className="navbar-item field">
@@ -233,6 +309,9 @@ const OmniSearch: FC = () => {
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
value={query}
role="combobox"
aria-autocomplete="list"
data-omnisearch="true"
{...handlers}
/>
{isLoading ? null : (
@@ -241,9 +320,11 @@ const OmniSearch: FC = () => {
</span>
)}
</div>
<DropdownMenu className="dropdown-menu">
<DropdownMenu className="dropdown-menu" onMouseDown={(e) => e.preventDefault()}>
{error ? <SearchErrorNotification error={error} /> : null}
{!error && data ? <Hits clear={clearQuery} index={index} hits={data._embedded.hits} /> : null}
{!error && data ? (
<Hits gotoDetailSearch={gotoDetailSearch} clear={clearQuery} index={index} hits={data._embedded.hits} />
) : null}
</DropdownMenu>
</div>
</div>

View File

@@ -0,0 +1,50 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Hit, HitProps, TextHitField } from "@scm-manager/ui-components";
import styled from "styled-components";
const LabelColumn = styled.th`
min-width: 10rem;
`;
const GenericHit: FC<HitProps> = ({ hit }) => (
<Hit>
<Hit.Content>
<table>
{Object.keys(hit.fields).map((field) => (
<tr key={field}>
<LabelColumn>{field}:</LabelColumn>
<td>
<TextHitField hit={hit} field={field} />
</td>
</tr>
))}
</table>
</Hit.Content>
</Hit>
);
export default GenericHit;

View File

@@ -0,0 +1,61 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import {
Hit,
HitProps,
DateFromNow,
TextHitField,
useDateHitFieldValue,
useStringHitFieldValue,
} from "@scm-manager/ui-components";
import { Link } from "react-router-dom";
const GroupHit: FC<HitProps> = ({ hit }) => {
const name = useStringHitFieldValue(hit, "name");
const lastModified = useDateHitFieldValue(hit, "lastModified");
const creationDate = useDateHitFieldValue(hit, "creationDate");
const date = lastModified || creationDate;
return (
<Hit>
<Hit.Content>
<Link to={`/group/${name}`}>
<Hit.Title>
<TextHitField hit={hit} field="name" />
</Hit.Title>
</Link>
<p>
<TextHitField hit={hit} field="description" />
</p>
</Hit.Content>
<Hit.Right>
<DateFromNow date={date} />
</Hit.Right>
</Hit>
);
};
export default GroupHit;

View File

@@ -0,0 +1,85 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Hit as HitType } from "@scm-manager/ui-types";
import RepositoryHit from "./RepositoryHit";
import GenericHit from "./GenericHit";
import UserHit from "./UserHit";
import GroupHit from "./GroupHit";
import { Notification, HitProps } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
type Props = {
type: string;
hits: HitType[];
};
const hitComponents: { [name: string]: FC<HitProps> } = {
repository: RepositoryHit,
user: UserHit,
group: GroupHit,
};
const findComponent = (type: string) => {
const cmp = hitComponents[type];
if (!cmp) {
return GenericHit;
}
return cmp;
};
type HitComponentProps = {
type: string;
hit: HitType;
};
const InternalHitRenderer: FC<HitComponentProps> = ({ type, hit }) => {
const Cmp = findComponent(type);
return <Cmp hit={hit} />;
};
const HitComponent: FC<HitComponentProps> = ({ hit, type }) => (
<ExtensionPoint name={`search.hit.${type}.renderer`} props={{ hit }}>
<InternalHitRenderer type={type} hit={hit} />
</ExtensionPoint>
);
const NoHits: FC = () => {
const [t] = useTranslation("commons");
return <Notification>{t("search.noHits")}</Notification>;
};
const Hits: FC<Props> = ({ type, hits }) => (
<div className="panel-block">
{hits.length > 0 ? (
hits.map((hit, c) => <HitComponent key={`${type}_${c}_${hit.score}`} hit={hit} type={type} />)
) : (
<NoHits />
)}
</div>
);
export default Hits;

View File

@@ -0,0 +1,82 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Repository } from "@scm-manager/ui-types";
import { Link } from "react-router-dom";
import {
useDateHitFieldValue,
useStringHitFieldValue,
DateFromNow,
RepositoryAvatar,
TextHitField,
Hit,
HitProps,
} from "@scm-manager/ui-components";
const RepositoryHit: FC<HitProps> = ({ hit }) => {
const namespace = useStringHitFieldValue(hit, "namespace");
const name = useStringHitFieldValue(hit, "name");
const type = useStringHitFieldValue(hit, "type");
const lastModified = useDateHitFieldValue(hit, "lastModified");
const creationDate = useDateHitFieldValue(hit, "creationDate");
const date = lastModified || creationDate;
if (!namespace || !name || !type) {
return null;
}
const repository: Repository = {
namespace,
name,
type,
_links: {},
_embedded: hit._embedded,
};
return (
<Hit>
<Hit.Left>
<Link to={`/repo/${namespace}/${name}`}>
<RepositoryAvatar repository={repository} size={48} />
</Link>
</Hit.Left>
<Hit.Content>
<Link to={`/repo/${namespace}/${name}`}>
<Hit.Title>
{namespace}/{name}
</Hit.Title>
</Link>
<p>
<TextHitField hit={hit} field="description" />
</p>
</Hit.Content>
<Hit.Right>
<DateFromNow date={date} />
</Hit.Right>
</Hit>
);
};
export default RepositoryHit;

View File

@@ -0,0 +1,48 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { QueryResult } from "@scm-manager/ui-types";
import Hits from "./Hits";
import { LinkPaginator } from "@scm-manager/ui-components";
type Props = {
result: QueryResult;
type: string;
page: number;
query: string;
};
const Results: FC<Props> = ({ result, type, page, query }) => {
return (
<div className="panel">
<Hits type={type} hits={result._embedded.hits} />
<div className="panel-footer">
<LinkPaginator collection={result} page={page} filter={query} />
</div>
</div>
);
};
export default Results;

View File

@@ -0,0 +1,149 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import {
CustomQueryFlexWrappedColumns,
Level,
NavLink,
Page,
PrimaryContentColumn,
SecondaryNavigation,
Tag,
urls,
} from "@scm-manager/ui-components";
import { useLocation, useParams } from "react-router-dom";
import { useSearch, useSearchCounts, useSearchTypes } from "@scm-manager/ui-api";
import Results from "./Results";
import { useTranslation } from "react-i18next";
type PathParams = {
type: string;
page: string;
};
type CountProps = {
isLoading: boolean;
isSelected: boolean;
count?: number;
};
const Count: FC<CountProps> = ({ isLoading, isSelected, count }) => {
if (isLoading) {
return <span className={"small-loading-spinner"} />;
}
return (
<Tag rounded={true} color={isSelected ? "info" : "light"}>
{count}
</Tag>
);
};
const usePageParams = () => {
const location = useLocation();
const { type: selectedType, ...params } = useParams<PathParams>();
const page = urls.getPageFromMatch({ params });
const query = urls.getQueryStringFromLocation(location);
return {
page,
selectedType,
query,
};
};
const orderTypes = (left: string, right: string) => {
if (left === "repository" && right !== "repository") {
return -1;
} else if (left !== "repository" && right === "repository") {
return 1;
} else if (left < right) {
return -1;
} else if (left > right) {
return 1;
}
return 0;
};
const Search: FC = () => {
const [t] = useTranslation(["commons", "plugins"]);
const { query, selectedType, page } = usePageParams();
const { data, isLoading, error } = useSearch(query, {
type: selectedType,
page: page - 1,
pageSize: 25,
});
const types = useSearchTypes();
types.sort(orderTypes);
const searchCounts = useSearchCounts(
types.filter((t) => t !== selectedType),
query
);
const counts = {
[selectedType]: {
isLoading,
error,
data: data?.totalHits,
},
...searchCounts,
};
return (
<Page
title={t("search.title")}
subtitle={t("search.subtitle", {
query,
type: t(`plugins:search.types.${selectedType}.subtitle`, selectedType),
})}
loading={isLoading}
error={error}
>
{data ? (
<CustomQueryFlexWrappedColumns>
<PrimaryContentColumn>
<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}>
<Level
left={t(`plugins:search.types.${type}.navItem`, type)}
right={
<Count
isLoading={counts[type].isLoading}
isSelected={type === selectedType}
count={counts[type].data}
/>
}
/>
</NavLink>
))}
</SecondaryNavigation>
</CustomQueryFlexWrappedColumns>
) : null}
</Page>
);
};
export default Search;

View File

@@ -0,0 +1,63 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Link } from "react-router-dom";
import {
DateFromNow,
useDateHitFieldValue,
useStringHitFieldValue,
TextHitField,
Hit,
HitProps,
} from "@scm-manager/ui-components";
const UserHit: FC<HitProps> = ({ hit }) => {
const name = useStringHitFieldValue(hit, "name");
const lastModified = useDateHitFieldValue(hit, "lastModified");
const creationDate = useDateHitFieldValue(hit, "creationDate");
const date = lastModified || creationDate;
return (
<Hit>
<Hit.Content>
<Link to={`/user/${name}`}>
<Hit.Title>
<TextHitField hit={hit} field="name" />
</Hit.Title>
</Link>
<p>
<TextHitField hit={hit} field="displayName" /> &lt;
<TextHitField hit={hit} field="mail" />
&gt;
</p>
</Hit.Content>
<Hit.Right>
<DateFromNow date={date} />
</Hit.Right>
</Hit>
);
};
export default UserHit;

View File

@@ -35,6 +35,7 @@ import lombok.Setter;
public class QueryResultDto extends CollectionDto {
private Class<?> type;
private long totalHits;
QueryResultDto(Links links, Embedded embedded) {
super(links, embedded);

View File

@@ -61,6 +61,9 @@ public class SearchParameters {
@PathParam("type")
private String type;
@QueryParam("countOnly")
private boolean countOnly = false;
String getSelfLink() {
return uriInfo.getAbsolutePath().toASCIIString();
}

View File

@@ -32,6 +32,7 @@ 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.search.IndexNames;
import sonia.scm.search.QueryCountResult;
import sonia.scm.search.QueryResult;
import sonia.scm.search.SearchEngine;
import sonia.scm.web.VndMediaType;
@@ -42,6 +43,7 @@ import javax.ws.rs.BeanParam;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import java.util.Collections;
@Path(SearchResource.PATH)
@OpenAPIDefinition(tags = {
@@ -98,7 +100,18 @@ public class SearchResource {
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);
}
return search(params);
}
private QueryResultDto search(SearchParameters params) {
QueryResult result = engine.search(IndexNames.DEFAULT)
.start(params.getPage() * params.getPageSize())
.limit(params.getPageSize())
@@ -107,4 +120,11 @@ public class SearchResource {
return mapper.map(params, result);
}
private QueryResultDto count(SearchParameters params) {
QueryCountResult result = engine.search(IndexNames.DEFAULT)
.count(params.getType(), params.getQuery());
return mapper.map(params, new QueryResult(result.getTotalHits(), result.getType(), Collections.emptyList()));
}
}

View File

@@ -0,0 +1,116 @@
/*
* 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.group;
import com.github.legman.Subscribe;
import com.google.common.annotations.VisibleForTesting;
import sonia.scm.plugin.Extension;
import sonia.scm.search.HandlerEventIndexSyncer;
import sonia.scm.search.Id;
import sonia.scm.search.Index;
import sonia.scm.search.IndexNames;
import sonia.scm.search.IndexQueue;
import sonia.scm.search.Indexer;
import javax.inject.Inject;
import javax.inject.Singleton;
@Extension
@Singleton
public class GroupIndexer implements Indexer<Group> {
@VisibleForTesting
static final String INDEX = IndexNames.DEFAULT;
@VisibleForTesting
static final int VERSION = 1;
private final GroupManager groupManager;
private final IndexQueue indexQueue;
@Inject
public GroupIndexer(GroupManager groupManager, IndexQueue indexQueue) {
this.groupManager = groupManager;
this.indexQueue = indexQueue;
}
@Override
public Class<Group> getType() {
return Group.class;
}
@Override
public String getIndex() {
return INDEX;
}
@Override
public int getVersion() {
return VERSION;
}
@Subscribe(async = false)
public void handleEvent(GroupEvent event) {
new HandlerEventIndexSyncer<>(this).handleEvent(event);
}
@Override
public Updater<Group> open() {
return new GroupIndexUpdater(groupManager, indexQueue.getQueuedIndex(INDEX));
}
public static class GroupIndexUpdater implements Updater<Group> {
private final GroupManager groupManager;
private final Index index;
private GroupIndexUpdater(GroupManager groupManager, Index index) {
this.groupManager = groupManager;
this.index = index;
}
@Override
public void store(Group group) {
index.store(Id.of(group), GroupPermissions.read(group).asShiroString(), group);
}
@Override
public void delete(Group group) {
index.delete(Id.of(group), Group.class);
}
@Override
public void reIndexAll() {
index.deleteByType(Group.class);
for (Group group : groupManager.getAll()) {
store(group);
}
}
@Override
public void close() {
index.close();
}
}
}

View File

@@ -1,140 +0,0 @@
/*
* 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.repository;
import com.github.legman.Subscribe;
import com.google.common.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.HandlerEventType;
import sonia.scm.plugin.Extension;
import sonia.scm.search.Id;
import sonia.scm.search.Index;
import sonia.scm.search.IndexLog;
import sonia.scm.search.IndexLogStore;
import sonia.scm.search.IndexNames;
import sonia.scm.search.IndexQueue;
import sonia.scm.web.security.AdministrationContext;
import sonia.scm.web.security.PrivilegedAction;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import java.util.Optional;
@Extension
@Singleton
public class IndexUpdateListener implements ServletContextListener {
private static final Logger LOG = LoggerFactory.getLogger(IndexUpdateListener.class);
@VisibleForTesting
static final int INDEX_VERSION = 2;
private final AdministrationContext administrationContext;
private final IndexQueue queue;
private final IndexLogStore indexLogStore;
@Inject
public IndexUpdateListener(AdministrationContext administrationContext, IndexQueue queue, IndexLogStore indexLogStore) {
this.administrationContext = administrationContext;
this.queue = queue;
this.indexLogStore = indexLogStore;
}
@Subscribe(async = false)
public void handleEvent(RepositoryEvent event) {
HandlerEventType type = event.getEventType();
if (type.isPost()) {
updateIndex(type, event.getItem());
}
}
private void updateIndex(HandlerEventType type, Repository repository) {
try (Index index = queue.getQueuedIndex(IndexNames.DEFAULT)) {
if (type == HandlerEventType.DELETE) {
index.deleteByRepository(repository.getId());
} else {
store(index, repository);
}
}
}
@Override
public void contextInitialized(ServletContextEvent servletContextEvent) {
Optional<IndexLog> indexLog = indexLogStore.get(IndexNames.DEFAULT, Repository.class);
if (indexLog.isPresent()) {
int version = indexLog.get().getVersion();
if (version < INDEX_VERSION) {
LOG.debug("repository index {} is older then {}, start reindexing of all repositories", version, INDEX_VERSION);
indexAll();
}
} else {
LOG.debug("could not find log entry for repository index, start reindexing of all repositories");
indexAll();
}
}
private void indexAll() {
administrationContext.runAsAdmin(ReIndexAll.class);
indexLogStore.log(IndexNames.DEFAULT, Repository.class, INDEX_VERSION);
}
@Override
public void contextDestroyed(ServletContextEvent servletContextEvent) {
// we have nothing to destroy
}
private static void store(Index index, Repository repository) {
index.store(Id.of(repository), RepositoryPermissions.read(repository).asShiroString(), repository);
}
static class ReIndexAll implements PrivilegedAction {
private final RepositoryManager repositoryManager;
private final IndexQueue queue;
@Inject
public ReIndexAll(RepositoryManager repositoryManager, IndexQueue queue) {
this.repositoryManager = repositoryManager;
this.queue = queue;
}
@Override
public void run() {
try (Index index = queue.getQueuedIndex(IndexNames.DEFAULT)) {
// delete v1 types
index.deleteByTypeName(Repository.class.getName());
for (Repository repository : repositoryManager.getAll()) {
store(index, repository);
}
}
}
}
}

View File

@@ -0,0 +1,119 @@
/*
* 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.repository;
import com.github.legman.Subscribe;
import com.google.common.annotations.VisibleForTesting;
import sonia.scm.plugin.Extension;
import sonia.scm.search.HandlerEventIndexSyncer;
import sonia.scm.search.Id;
import sonia.scm.search.Index;
import sonia.scm.search.IndexNames;
import sonia.scm.search.IndexQueue;
import sonia.scm.search.Indexer;
import javax.inject.Inject;
import javax.inject.Singleton;
@Singleton
@Extension
public class RepositoryIndexer implements Indexer<Repository> {
@VisibleForTesting
static final int VERSION = 2;
@VisibleForTesting
static final String INDEX = IndexNames.DEFAULT;
private final RepositoryManager repositoryManager;
private final IndexQueue indexQueue;
@Inject
public RepositoryIndexer(RepositoryManager repositoryManager, IndexQueue indexQueue) {
this.repositoryManager = repositoryManager;
this.indexQueue = indexQueue;
}
@Override
public int getVersion() {
return VERSION;
}
@Override
public Class<Repository> getType() {
return Repository.class;
}
@Override
public String getIndex() {
return INDEX;
}
@Subscribe(async = false)
public void handleEvent(RepositoryEvent event) {
new HandlerEventIndexSyncer<>(this).handleEvent(event);
}
@Override
public Updater<Repository> open() {
return new RepositoryIndexUpdater(repositoryManager, indexQueue.getQueuedIndex(INDEX));
}
public static class RepositoryIndexUpdater implements Updater<Repository> {
private final RepositoryManager repositoryManager;
private final Index index;
public RepositoryIndexUpdater(RepositoryManager repositoryManager, Index index) {
this.repositoryManager = repositoryManager;
this.index = index;
}
@Override
public void store(Repository repository) {
index.store(Id.of(repository), RepositoryPermissions.read(repository).asShiroString(), repository);
}
@Override
public void delete(Repository repository) {
index.deleteByRepository(repository.getId());
}
@Override
public void reIndexAll() {
// v1 used the whole classname as type
index.deleteByTypeName(Repository.class.getName());
index.deleteByType(Repository.class);
for (Repository repository : repositoryManager.getAll()) {
store(repository);
}
}
@Override
public void close() {
index.close();
}
}
}

View File

@@ -0,0 +1,92 @@
/*
* 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.search;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.plugin.Extension;
import sonia.scm.web.security.AdministrationContext;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import java.util.Optional;
import java.util.Set;
@Singleton
@Extension
@SuppressWarnings("rawtypes")
public class IndexBootstrapListener implements ServletContextListener {
private static final Logger LOG = LoggerFactory.getLogger(IndexBootstrapListener.class);
private final AdministrationContext administrationContext;
private final IndexLogStore indexLogStore;
private final Set<Indexer> indexers;
@Inject
public IndexBootstrapListener(AdministrationContext administrationContext, IndexLogStore indexLogStore, Set<Indexer> indexers) {
this.administrationContext = administrationContext;
this.indexLogStore = indexLogStore;
this.indexers = indexers;
}
@Override
public void contextInitialized(ServletContextEvent servletContextEvent) {
for (Indexer indexer : indexers) {
bootstrap(indexer);
}
}
private void bootstrap(Indexer indexer) {
Optional<IndexLog> indexLog = indexLogStore.get(indexer.getIndex(), indexer.getType());
if (indexLog.isPresent()) {
int version = indexLog.get().getVersion();
if (version < indexer.getVersion()) {
LOG.debug("index version {} is older then {}, start reindexing of all {}", version, indexer.getVersion(), indexer.getType());
indexAll(indexer);
}
} else {
LOG.debug("could not find log entry for {} index, start reindexing", indexer.getType());
indexAll(indexer);
}
}
private void indexAll(Indexer indexer) {
administrationContext.runAsAdmin(() -> {
try (Indexer.Updater updater = indexer.open()) {
updater.reIndexAll();
}
});
indexLogStore.log(indexer.getIndex(), indexer.getType(), indexer.getVersion());
}
@Override
public void contextDestroyed(ServletContextEvent servletContextEvent) {
// nothing to destroy here
}
}

View File

@@ -38,6 +38,7 @@ import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.TopScoreDocCollector;
import org.apache.lucene.search.TotalHitCountCollector;
import org.apache.lucene.search.WildcardQuery;
import org.apache.lucene.search.highlight.InvalidTokenOffsetsException;
import org.apache.shiro.SecurityUtils;
@@ -70,8 +71,25 @@ public class LuceneQueryBuilder extends QueryBuilder {
return resolver.resolveClassByName(typeName);
}
@Override
protected QueryCountResult count(QueryParams queryParams) {
TotalHitCountCollector totalHitCountCollector = new TotalHitCountCollector();
return search(
queryParams, totalHitCountCollector,
(searcher, type, query) -> new QueryCountResult(type.getType(), totalHitCountCollector.getTotalHits())
);
}
@Override
protected QueryResult execute(QueryParams queryParams) {
TopScoreDocCollector topScoreCollector = createTopScoreCollector(queryParams);
return search(queryParams, topScoreCollector, (searcher, searchableType, query) -> {
QueryResultFactory resultFactory = new QueryResultFactory(analyzer, searcher, searchableType, query);
return resultFactory.create(getTopDocs(queryParams, topScoreCollector));
});
}
private <T> T search(QueryParams queryParams, Collector collector, ResultBuilder<T> resultBuilder) {
String queryString = Strings.nullToEmpty(queryParams.getQueryString());
LuceneSearchableType searchableType = resolver.resolve(queryParams.getType());
@@ -87,12 +105,9 @@ public class LuceneQueryBuilder extends QueryBuilder {
try (IndexReader reader = opener.openForRead(indexName)) {
IndexSearcher searcher = new IndexSearcher(reader);
TopScoreDocCollector topScoreCollector = createTopScoreCollector(queryParams);
Collector collector = new PermissionAwareCollector(reader, topScoreCollector);
searcher.search(query, collector);
searcher.search(query, new PermissionAwareCollector(reader, collector));
QueryResultFactory resultFactory = new QueryResultFactory(analyzer, searcher, searchableType, query);
return resultFactory.create(getTopDocs(queryParams, topScoreCollector));
return resultBuilder.create(searcher, searchableType, parsedQuery);
} catch (IOException e) {
throw new SearchEngineException("failed to search index", e);
} catch (InvalidTokenOffsetsException e) {
@@ -155,4 +170,9 @@ public class LuceneQueryBuilder extends QueryBuilder {
}
return queryString;
}
@FunctionalInterface
private interface ResultBuilder<T> {
T create(IndexSearcher searcher, LuceneSearchableType searchableType, Query query) throws IOException, InvalidTokenOffsetsException;
}
}

View File

@@ -82,6 +82,7 @@ public class QueryResultFactory {
}
return new Hit(document.get(FieldNames.ID), scoreDoc.score, fields);
}
private Optional<Hit.Field> field(Document document, SearchableField field) throws IOException, InvalidTokenOffsetsException {
Object value = field.value(document);
if (value != null) {

View File

@@ -0,0 +1,116 @@
/*
* 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.user;
import com.github.legman.Subscribe;
import com.google.common.annotations.VisibleForTesting;
import sonia.scm.plugin.Extension;
import sonia.scm.search.HandlerEventIndexSyncer;
import sonia.scm.search.Id;
import sonia.scm.search.Index;
import sonia.scm.search.IndexNames;
import sonia.scm.search.IndexQueue;
import sonia.scm.search.Indexer;
import javax.inject.Inject;
import javax.inject.Singleton;
@Extension
@Singleton
public class UserIndexer implements Indexer<User> {
@VisibleForTesting
static final String INDEX = IndexNames.DEFAULT;
@VisibleForTesting
static final int VERSION = 1;
private final UserManager userManager;
private final IndexQueue queue;
@Inject
public UserIndexer(UserManager userManager, IndexQueue queue) {
this.userManager = userManager;
this.queue = queue;
}
@Override
public Class<User> getType() {
return User.class;
}
@Override
public String getIndex() {
return INDEX;
}
@Override
public int getVersion() {
return VERSION;
}
@Subscribe(async = false)
public void handleEvent(UserEvent event) {
new HandlerEventIndexSyncer<>(this).handleEvent(event);
}
@Override
public Updater<User> open() {
return new UserIndexUpdater(userManager, queue.getQueuedIndex(INDEX));
}
public static class UserIndexUpdater implements Updater<User> {
private final UserManager userManager;
private final Index index;
private UserIndexUpdater(UserManager userManager, Index index) {
this.userManager = userManager;
this.index = index;
}
@Override
public void store(User user) {
index.store(Id.of(user), UserPermissions.read(user).asShiroString(), user);
}
@Override
public void delete(User user) {
index.delete(Id.of(user), User.class);
}
@Override
public void reIndexAll() {
index.deleteByType(User.class);
for (User user : userManager.getAll()) {
store(user);
}
}
@Override
public void close() {
index.close();
}
}
}

View File

@@ -433,5 +433,21 @@
"exportFinished": "Der Repository Export wurde abgeschlossen.",
"exportFailed": "Der Repository Export ist fehlgeschlagen. Versuchen Sie es erneut oder wenden Sie sich an einen Administrator.",
"healthCheckFailed": "Der Repository Health Check ist fehlgeschlagen."
},
"search": {
"types": {
"repository": {
"subtitle": "Repository",
"navItem": "Repositories"
},
"user": {
"subtitle": "Benutzer",
"navItem": "Benutzer"
},
"group": {
"subtitle": "Gruppe",
"navItem": "Gruppen"
}
}
}
}

View File

@@ -377,5 +377,21 @@
"exportFinished": "The repository export has been finished.",
"exportFailed": "The repository export has failed. Try it again or contact your administrator.",
"healthCheckFailed": "The repository health check has failed."
},
"search": {
"types": {
"repository": {
"subtitle": "Repository",
"navItem": "Repositories"
},
"user": {
"subtitle": "User",
"navItem": "Users"
},
"group": {
"subtitle": "Group",
"navItem": "Groups"
}
}
}
}

View File

@@ -39,6 +39,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.search.Hit;
import sonia.scm.search.IndexNames;
import sonia.scm.search.QueryCountResult;
import sonia.scm.search.QueryResult;
import sonia.scm.search.SearchEngine;
import sonia.scm.web.JsonMockHttpResponse;
@@ -182,6 +183,25 @@ class SearchResourceTest {
assertThat(highlightedField.get("fragments").get(0).asText()).isEqualTo("Hello");
}
@Test
void shouldReturnCountOnly() throws URISyntaxException {
when(
searchEngine.search(IndexNames.DEFAULT)
.count("string", "Hello")
).thenReturn(new QueryCountResult(String.class, 2L));
MockHttpRequest request = MockHttpRequest.get("/v2/search/query/string?q=Hello&countOnly=true");
JsonMockHttpResponse response = new JsonMockHttpResponse();
dispatcher.invoke(request, response);
JsonNode node = response.getContentAsJson();
assertThat(node.get("totalHits").asLong()).isEqualTo(2L);
JsonNode hits = node.get("_embedded").get("hits");
assertThat(hits.size()).isZero();
}
}
private void assertLink(JsonNode links, String self, String s) {
@@ -245,6 +265,7 @@ class SearchResourceTest {
return response;
}
@Getter
@Setter
public static class SampleEmbedded extends HalRepresentation {

View File

@@ -0,0 +1,125 @@
/*
* 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.group;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.HandlerEventType;
import sonia.scm.search.Id;
import sonia.scm.search.Index;
import sonia.scm.search.IndexQueue;
import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GroupIndexerTest {
@Mock
private GroupManager groupManager;
@Mock
private IndexQueue indexQueue;
@InjectMocks
private GroupIndexer indexer;
@Test
void shouldReturnRepositoryClass() {
assertThat(indexer.getType()).isEqualTo(Group.class);
}
@Test
void shouldReturnIndexName() {
assertThat(indexer.getIndex()).isEqualTo(GroupIndexer.INDEX);
}
@Test
void shouldReturnVersion() {
assertThat(indexer.getVersion()).isEqualTo(GroupIndexer.VERSION);
}
@Nested
class UpdaterTests {
@Mock
private Index index;
private final Group group = new Group("xml", "astronauts");
@BeforeEach
void open() {
when(indexQueue.getQueuedIndex(GroupIndexer.INDEX)).thenReturn(index);
}
@Test
void shouldStoreRepository() {
indexer.open().store(group);
verify(index).store(Id.of(group), "group:read:astronauts", group);
}
@Test
void shouldDeleteByRepository() {
indexer.open().delete(group);
verify(index).delete(Id.of(group), Group.class);
}
@Test
void shouldReIndexAll() {
when(groupManager.getAll()).thenReturn(singletonList(group));
indexer.open().reIndexAll();
verify(index).deleteByType(Group.class);
verify(index).store(Id.of(group), "group:read:astronauts", group);
}
@Test
void shouldHandleEvent() {
GroupEvent event = new GroupEvent(HandlerEventType.DELETE, group);
indexer.handleEvent(event);
verify(index).delete(Id.of(group), Group.class);
}
@Test
void shouldCloseIndex() {
indexer.open().close();
verify(index).close();
}
}
}

View File

@@ -1,147 +0,0 @@
/*
* 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.repository;
import com.google.common.collect.ImmutableList;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.HandlerEventType;
import sonia.scm.search.Id;
import sonia.scm.search.Index;
import sonia.scm.search.IndexLog;
import sonia.scm.search.IndexLogStore;
import sonia.scm.search.IndexNames;
import sonia.scm.search.IndexQueue;
import sonia.scm.web.security.AdministrationContext;
import java.util.List;
import java.util.Optional;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IndexUpdateListenerTest {
@Mock
private RepositoryManager repositoryManager;
@Mock
private AdministrationContext administrationContext;
@Mock
private IndexQueue indexQueue;
@Mock
private Index index;
@Mock
private IndexLogStore indexLogStore;
@InjectMocks
private IndexUpdateListener updateListener;
@Test
@SuppressWarnings("java:S6068")
void shouldIndexAllRepositories() {
when(indexLogStore.get(IndexNames.DEFAULT, Repository.class)).thenReturn(Optional.empty());
doAnswer(ic -> {
IndexUpdateListener.ReIndexAll reIndexAll = new IndexUpdateListener.ReIndexAll(repositoryManager, indexQueue);
reIndexAll.run();
return null;
})
.when(administrationContext)
.runAsAdmin(IndexUpdateListener.ReIndexAll.class);
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
Repository puzzle42 = RepositoryTestData.create42Puzzle();
List<Repository> repositories = ImmutableList.of(heartOfGold, puzzle42);
when(repositoryManager.getAll()).thenReturn(repositories);
when(indexQueue.getQueuedIndex(IndexNames.DEFAULT)).thenReturn(index);
updateListener.contextInitialized(null);
verify(index).store(Id.of(heartOfGold), RepositoryPermissions.read(heartOfGold).asShiroString(), heartOfGold);
verify(index).store(Id.of(puzzle42), RepositoryPermissions.read(puzzle42).asShiroString(), puzzle42);
verify(index).close();
verify(indexLogStore).log(IndexNames.DEFAULT, Repository.class, IndexUpdateListener.INDEX_VERSION);
}
@Test
void shouldSkipReIndex() {
IndexLog log = new IndexLog(1);
when(indexLogStore.get(IndexNames.DEFAULT, Repository.class)).thenReturn(Optional.of(log));
updateListener.contextInitialized(null);
verifyNoInteractions(indexQueue);
}
@ParameterizedTest
@EnumSource(value = HandlerEventType.class, mode = EnumSource.Mode.MATCH_ANY, names = "BEFORE_.*")
void shouldIgnoreBeforeEvents(HandlerEventType type) {
RepositoryEvent event = new RepositoryEvent(type, RepositoryTestData.create42Puzzle());
updateListener.handleEvent(event);
verifyNoInteractions(indexQueue);
}
@ParameterizedTest
@EnumSource(value = HandlerEventType.class, mode = EnumSource.Mode.INCLUDE, names = {"CREATE", "MODIFY"})
void shouldStore(HandlerEventType type) {
when(indexQueue.getQueuedIndex(IndexNames.DEFAULT)).thenReturn(index);
Repository puzzle = RepositoryTestData.create42Puzzle();
RepositoryEvent event = new RepositoryEvent(type, puzzle);
updateListener.handleEvent(event);
verify(index).store(Id.of(puzzle), RepositoryPermissions.read(puzzle).asShiroString(), puzzle);
verify(index).close();
}
@Test
void shouldDelete() {
when(indexQueue.getQueuedIndex(IndexNames.DEFAULT)).thenReturn(index);
Repository puzzle = RepositoryTestData.create42Puzzle();
RepositoryEvent event = new RepositoryEvent(HandlerEventType.DELETE, puzzle);
updateListener.handleEvent(event);
verify(index).deleteByRepository(puzzle.getId());
verify(index).close();
}
}

View File

@@ -0,0 +1,128 @@
/*
* 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.repository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.HandlerEventType;
import sonia.scm.search.Id;
import sonia.scm.search.Index;
import sonia.scm.search.IndexQueue;
import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class RepositoryIndexerTest {
@Mock
private RepositoryManager repositoryManager;
@Mock
private IndexQueue indexQueue;
@InjectMocks
private RepositoryIndexer indexer;
@Test
void shouldReturnRepositoryClass() {
assertThat(indexer.getType()).isEqualTo(Repository.class);
}
@Test
void shouldReturnIndexName() {
assertThat(indexer.getIndex()).isEqualTo(RepositoryIndexer.INDEX);
}
@Test
void shouldReturnVersion() {
assertThat(indexer.getVersion()).isEqualTo(RepositoryIndexer.VERSION);
}
@Nested
class UpdaterTests {
@Mock
private Index index;
private Repository repository;
@BeforeEach
void open() {
when(indexQueue.getQueuedIndex(RepositoryIndexer.INDEX)).thenReturn(index);
repository = new Repository();
repository.setId("42");
}
@Test
void shouldStoreRepository() {
indexer.open().store(repository);
verify(index).store(Id.of(repository), "repository:read:42", repository);
}
@Test
void shouldDeleteByRepository() {
indexer.open().delete(repository);
verify(index).deleteByRepository("42");
}
@Test
void shouldReIndexAll() {
when(repositoryManager.getAll()).thenReturn(singletonList(repository));
indexer.open().reIndexAll();
verify(index).deleteByTypeName(Repository.class.getName());
verify(index).deleteByType(Repository.class);
verify(index).store(Id.of(repository), "repository:read:42", repository);
}
@Test
void shouldHandleEvent() {
RepositoryEvent event = new RepositoryEvent(HandlerEventType.DELETE, repository);
indexer.handleEvent(event);
verify(index).deleteByRepository("42");
}
@Test
void shouldCloseIndex() {
indexer.open().close();
verify(index).close();
}
}
}

View File

@@ -0,0 +1,88 @@
/*
* 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.search;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.HandlerEventType;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryEvent;
import sonia.scm.repository.RepositoryTestData;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class HandlerEventIndexSyncerTest {
@Mock
private Indexer<Repository> indexer;
@Mock
private Indexer.Updater<Repository> updater;
@ParameterizedTest
@EnumSource(value = HandlerEventType.class, mode = EnumSource.Mode.MATCH_ANY, names = "BEFORE_.*")
void shouldIgnoreBeforeEvents(HandlerEventType type) {
RepositoryEvent event = new RepositoryEvent(type, RepositoryTestData.create42Puzzle());
new HandlerEventIndexSyncer<>(indexer).handleEvent(event);
verifyNoInteractions(indexer);
}
@ParameterizedTest
@EnumSource(value = HandlerEventType.class, mode = EnumSource.Mode.INCLUDE, names = {"CREATE", "MODIFY"})
void shouldStore(HandlerEventType type) {
when(indexer.open()).thenReturn(updater);
Repository puzzle = RepositoryTestData.create42Puzzle();
RepositoryEvent event = new RepositoryEvent(type, puzzle);
new HandlerEventIndexSyncer<>(indexer).handleEvent(event);
verify(updater).store(puzzle);
verify(updater).close();
}
@Test
void shouldDelete() {
when(indexer.open()).thenReturn(updater);
Repository puzzle = RepositoryTestData.create42Puzzle();
RepositoryEvent event = new RepositoryEvent(HandlerEventType.DELETE, puzzle);
new HandlerEventIndexSyncer<>(indexer).handleEvent(event);
verify(updater).delete(puzzle);
verify(updater).close();
}
}

View File

@@ -0,0 +1,145 @@
/*
* 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.search;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.group.Group;
import sonia.scm.repository.Repository;
import sonia.scm.user.User;
import sonia.scm.web.security.AdministrationContext;
import sonia.scm.web.security.PrivilegedAction;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IndexBootstrapListenerTest {
@Mock
private AdministrationContext administrationContext;
@Mock
private IndexLogStore indexLogStore;
@Test
void shouldReIndexWithoutLog() {
mockAdminContext();
Indexer<Repository> indexer = indexer(Repository.class, 1);
Indexer.Updater<Repository> updater = updater(indexer);
mockEmptyIndexLog(Repository.class);
doInitialization(indexer);
verify(updater).reIndexAll();
verify(updater).close();
verify(indexLogStore).log(IndexNames.DEFAULT, Repository.class, 1);
}
@Test
void shouldReIndexIfVersionWasUpdated() {
mockAdminContext();
Indexer<User> indexer = indexer(User.class, 2);
Indexer.Updater<User> updater = updater(indexer);
mockIndexLog(User.class, 1);
doInitialization(indexer);
verify(updater).reIndexAll();
verify(updater).close();
verify(indexLogStore).log(IndexNames.DEFAULT, User.class, 2);
}
@Test
void shouldSkipReIndexIfVersionIsEqual() {
Indexer<Group> indexer = indexer(Group.class, 3);
mockIndexLog(Group.class, 3);
doInitialization(indexer);
verify(indexer, never()).open();
}
private <T> void mockIndexLog(Class<T> type, int version) {
mockIndexLog(type, new IndexLog(version));
}
private <T> void mockEmptyIndexLog(Class<T> type) {
mockIndexLog(type, null);
}
private <T> void mockIndexLog(Class<T> type, @Nullable IndexLog indexLog) {
when(indexLogStore.get(IndexNames.DEFAULT, type)).thenReturn(Optional.ofNullable(indexLog));
}
private void mockAdminContext() {
doAnswer(ic -> {
PrivilegedAction action = ic.getArgument(0);
action.run();
return null;
}).when(administrationContext).runAsAdmin(any(PrivilegedAction.class));
}
@SuppressWarnings("rawtypes")
private void doInitialization(Indexer... indexers) {
IndexBootstrapListener listener = listener(indexers);
listener.contextInitialized(null);
}
@Nonnull
@SuppressWarnings("rawtypes")
private IndexBootstrapListener listener(Indexer... indexers) {
return new IndexBootstrapListener(
administrationContext, indexLogStore, new HashSet<>(Arrays.asList(indexers))
);
}
private <T> Indexer<T> indexer(Class<T> type, int version) {
Indexer<T> indexer = mock(Indexer.class);
when(indexer.getType()).thenReturn(type);
when(indexer.getVersion()).thenReturn(version);
when(indexer.getIndex()).thenReturn(IndexNames.DEFAULT);
return indexer;
}
private <T> Indexer.Updater<T> updater(Indexer<T> indexer) {
Indexer.Updater<T> updater = mock(Indexer.Updater.class);
when(indexer.open()).thenReturn(updater);
return updater;
}
}

View File

@@ -87,6 +87,16 @@ class LuceneQueryBuilderTest {
assertThat(result.getTotalHits()).isOne();
}
@Test
void shouldReturnCount() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(inetOrgPersonDoc("Arthur", "Dent", "Arthur Dent", "4211"));
}
long hits = count(InetOrgPerson.class, "Arthur");
assertThat(hits).isOne();
}
@Test
void shouldMatchPartial() throws IOException {
try (IndexWriter writer = writer()) {
@@ -239,6 +249,18 @@ class LuceneQueryBuilderTest {
});
}
@Test
void shouldCountOnlyPermittedHits() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(permissionDoc("Awesome content one", "abc"));
writer.addDocument(permissionDoc("Awesome content two", "cde"));
writer.addDocument(permissionDoc("Awesome content three", "fgh"));
}
long hits = count(Simple.class, "content:awesome");
assertThat(hits).isOne();
}
@Test
void shouldFilterByRepository() throws IOException {
try (IndexWriter writer = writer()) {
@@ -499,6 +521,17 @@ class LuceneQueryBuilderTest {
return query(type, queryString, null, null);
}
private long count(Class<?> type, String queryString) throws IOException {
try (DirectoryReader reader = DirectoryReader.open(directory)) {
lenient().when(opener.openForRead("default")).thenReturn(reader);
SearchableTypeResolver resolver = new SearchableTypeResolver(type);
LuceneQueryBuilder builder = new LuceneQueryBuilder(
opener, resolver, "default", new StandardAnalyzer()
);
return builder.count(type, queryString).getTotalHits();
}
}
private QueryResult query(Class<?> type, String queryString, Integer start, Integer limit) throws IOException {
try (DirectoryReader reader = DirectoryReader.open(directory)) {
lenient().when(opener.openForRead("default")).thenReturn(reader);

View File

@@ -0,0 +1,125 @@
/*
* 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.user;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.HandlerEventType;
import sonia.scm.search.Id;
import sonia.scm.search.Index;
import sonia.scm.search.IndexQueue;
import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class UserIndexerTest {
@Mock
private UserManager userManager;
@Mock
private IndexQueue indexQueue;
@InjectMocks
private UserIndexer indexer;
@Test
void shouldReturnRepositoryClass() {
assertThat(indexer.getType()).isEqualTo(User.class);
}
@Test
void shouldReturnIndexName() {
assertThat(indexer.getIndex()).isEqualTo(UserIndexer.INDEX);
}
@Test
void shouldReturnVersion() {
assertThat(indexer.getVersion()).isEqualTo(UserIndexer.VERSION);
}
@Nested
class UpdaterTests {
@Mock
private Index index;
private final User user = UserTestData.createTrillian();
@BeforeEach
void open() {
when(indexQueue.getQueuedIndex(UserIndexer.INDEX)).thenReturn(index);
}
@Test
void shouldStoreRepository() {
indexer.open().store(user);
verify(index).store(Id.of(user), "user:read:trillian", user);
}
@Test
void shouldDeleteByRepository() {
indexer.open().delete(user);
verify(index).delete(Id.of(user), User.class);
}
@Test
void shouldReIndexAll() {
when(userManager.getAll()).thenReturn(singletonList(user));
indexer.open().reIndexAll();
verify(index).deleteByType(User.class);
verify(index).store(Id.of(user), "user:read:trillian", user);
}
@Test
void shouldHandleEvent() {
UserEvent event = new UserEvent(HandlerEventType.DELETE, user);
indexer.handleEvent(event);
verify(index).delete(Id.of(user), User.class);
}
@Test
void shouldCloseIndex() {
indexer.open().close();
verify(index).close();
}
}
}

View File

@@ -2995,12 +2995,12 @@
unist-util-generated "^1.1.6"
unist-util-visit "^2.0.3"
"@scm-manager/tsconfig@^2.11.2":
version "2.11.2"
resolved "https://registry.yarnpkg.com/@scm-manager/tsconfig/-/tsconfig-2.11.2.tgz#7f0ed5fd49ca77a9ecf34066507779535f2270bd"
integrity sha512-Y69XFe6EZFg/ZMt2g90uBEsxL3IPaAUYmra0bYHF2/dkmlJeZWJ0+0i3TrHMMajokXxTSpXwJUTrjC8bBWEYIw==
"@scm-manager/tsconfig@^2.12.0":
version "2.12.0"
resolved "https://registry.yarnpkg.com/@scm-manager/tsconfig/-/tsconfig-2.12.0.tgz#ccbf149b4fccd7ef7a7cc9c1c4c7f19bec4d78e1"
integrity sha512-vcT1/Tn3MaNdj5vssQX7pBOfLJ9/RWVF3Ut1sVpcM2dsuQEo/qaXFlfLsVZxeQr1bJ45731l0hB/z55GjXTLKw==
dependencies:
typescript "^4.1.3"
typescript "^4.3.5"
"@sinonjs/commons@^1.7.0":
version "1.8.3"
@@ -19393,11 +19393,16 @@ typedarray@^0.0.6:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
typescript@^4.0.5, typescript@^4.1.3:
typescript@^4.0.5:
version "4.2.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961"
integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==
typescript@^4.3.5:
version "4.3.5"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4"
integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==
uglify-js@^3.1.4:
version "3.13.6"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.13.6.tgz#6815ac7fdd155d03c83e2362bb717e5b39b74013"
@@ -20035,8 +20040,10 @@ watchpack@^1.7.4:
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.7.5.tgz#1267e6c55e0b9b5be44c2023aed5437a2c26c453"
integrity sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==
dependencies:
chokidar "^3.4.1"
graceful-fs "^4.1.2"
neo-async "^2.5.0"
watchpack-chokidar2 "^2.0.1"
optionalDependencies:
chokidar "^3.4.1"
watchpack-chokidar2 "^2.0.1"