mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-03-06 04:10:52 +01:00
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:
4
gradle/changelog/search.yaml
Normal file
4
gradle/changelog/search.yaml
Normal 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))
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
102
scm-core/src/main/java/sonia/scm/search/Indexer.java
Normal file
102
scm-core/src/main/java/sonia/scm/search/Indexer.java
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">…</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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -96,6 +96,7 @@ export * from "./repos";
|
||||
export * from "./table";
|
||||
export * from "./toast";
|
||||
export * from "./popover";
|
||||
export * from "./search";
|
||||
export * from "./markdown/markdownExtensions";
|
||||
|
||||
export {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
58
scm-ui/ui-components/src/search/HighlightedFragment.tsx
Normal file
58
scm-ui/ui-components/src/search/HighlightedFragment.tsx
Normal 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;
|
||||
51
scm-ui/ui-components/src/search/Hit.tsx
Normal file
51
scm-ui/ui-components/src/search/Hit.tsx
Normal 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;
|
||||
62
scm-ui/ui-components/src/search/TextHitField.tsx
Normal file
62
scm-ui/ui-components/src/search/TextHitField.tsx
Normal 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;
|
||||
85
scm-ui/ui-components/src/search/fields.ts
Normal file
85
scm-ui/ui-components/src/search/fields.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
};
|
||||
27
scm-ui/ui-components/src/search/index.ts
Normal file
27
scm-ui/ui-components/src/search/index.ts
Normal 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";
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
50
scm-ui/ui-webapp/src/search/GenericHit.tsx
Normal file
50
scm-ui/ui-webapp/src/search/GenericHit.tsx
Normal 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;
|
||||
61
scm-ui/ui-webapp/src/search/GroupHit.tsx
Normal file
61
scm-ui/ui-webapp/src/search/GroupHit.tsx
Normal 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;
|
||||
85
scm-ui/ui-webapp/src/search/Hits.tsx
Normal file
85
scm-ui/ui-webapp/src/search/Hits.tsx
Normal 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;
|
||||
82
scm-ui/ui-webapp/src/search/RepositoryHit.tsx
Normal file
82
scm-ui/ui-webapp/src/search/RepositoryHit.tsx
Normal 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;
|
||||
48
scm-ui/ui-webapp/src/search/Results.tsx
Normal file
48
scm-ui/ui-webapp/src/search/Results.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import React, { 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;
|
||||
149
scm-ui/ui-webapp/src/search/Search.tsx
Normal file
149
scm-ui/ui-webapp/src/search/Search.tsx
Normal 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;
|
||||
63
scm-ui/ui-webapp/src/search/UserHit.tsx
Normal file
63
scm-ui/ui-webapp/src/search/UserHit.tsx
Normal 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" /> <
|
||||
<TextHitField hit={hit} field="mail" />
|
||||
>
|
||||
</p>
|
||||
</Hit.Content>
|
||||
<Hit.Right>
|
||||
<DateFromNow date={date} />
|
||||
</Hit.Right>
|
||||
</Hit>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserHit;
|
||||
@@ -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);
|
||||
|
||||
@@ -61,6 +61,9 @@ public class SearchParameters {
|
||||
@PathParam("type")
|
||||
private String type;
|
||||
|
||||
@QueryParam("countOnly")
|
||||
private boolean countOnly = false;
|
||||
|
||||
String getSelfLink() {
|
||||
return uriInfo.getAbsolutePath().toASCIIString();
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
116
scm-webapp/src/main/java/sonia/scm/group/GroupIndexer.java
Normal file
116
scm-webapp/src/main/java/sonia/scm/group/GroupIndexer.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
116
scm-webapp/src/main/java/sonia/scm/user/UserIndexer.java
Normal file
116
scm-webapp/src/main/java/sonia/scm/user/UserIndexer.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
125
scm-webapp/src/test/java/sonia/scm/group/GroupIndexerTest.java
Normal file
125
scm-webapp/src/test/java/sonia/scm/group/GroupIndexerTest.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
125
scm-webapp/src/test/java/sonia/scm/user/UserIndexerTest.java
Normal file
125
scm-webapp/src/test/java/sonia/scm/user/UserIndexerTest.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
19
yarn.lock
19
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user