From ddd2fc1055ead78f1ca6a071cdeda43568f3fb13 Mon Sep 17 00:00:00 2001 From: Konstantin Schaper Date: Mon, 9 Aug 2021 12:07:28 +0200 Subject: [PATCH] Add additional help to quick search and an advanced search documentation page (#1757) Co-authored-by: Sebastian Sdorra --- gradle/changelog/search_syntax.yaml | 2 + .../sonia/scm/search/SearchableField.java | 49 +++++ .../java/sonia/scm/search/SearchableType.java | 10 + .../main/java/sonia/scm/web/VndMediaType.java | 1 + scm-ui/ui-api/src/help/search/modal.de.ts | 33 +++ scm-ui/ui-api/src/help/search/modal.en.ts | 33 +++ scm-ui/ui-api/src/help/search/syntax.de.ts | 148 +++++++++++++ scm-ui/ui-api/src/help/search/syntax.en.ts | 145 +++++++++++++ scm-ui/ui-api/src/index.ts | 1 + scm-ui/ui-api/src/search.ts | 35 +++- scm-ui/ui-components/src/HelpIcon.tsx | 11 +- .../src/buttons/LinkStyleButton.tsx | 32 +++ .../src/buttons/NoStyleButton.tsx | 38 ++++ scm-ui/ui-components/src/buttons/index.ts | 2 + scm-ui/ui-components/src/index.ts | 17 +- scm-ui/ui-components/src/layout/Page.tsx | 4 +- scm-ui/ui-types/src/Search.ts | 12 +- .../ui-webapp/public/locales/de/commons.json | 42 +++- .../ui-webapp/public/locales/en/commons.json | 42 +++- scm-ui/ui-webapp/src/containers/Main.tsx | 2 + .../ui-webapp/src/containers/OmniSearch.tsx | 82 +++++--- scm-ui/ui-webapp/src/search/Search.tsx | 30 ++- scm-ui/ui-webapp/src/search/Syntax.tsx | 194 ++++++++++++++++++ scm-ui/ui-webapp/src/search/SyntaxHelp.tsx | 44 ++++ scm-ui/ui-webapp/src/search/SyntaxModal.tsx | 70 +++++++ .../api/v2/resources/IndexDtoGenerator.java | 1 + .../scm/api/v2/resources/MapperModule.java | 2 + .../scm/api/v2/resources/ResourceLinks.java | 1 + .../scm/api/v2/resources/SearchResource.java | 42 +++- .../api/v2/resources/SearchableFieldDto.java | 32 +++ .../api/v2/resources/SearchableTypeDto.java | 40 ++++ .../v2/resources/SearchableTypeMapper.java | 53 +++++ .../sonia/scm/search/AnalyzerFactory.java | 4 +- ...eField.java => LuceneSearchableField.java} | 5 +- .../scm/search/LuceneSearchableType.java | 25 ++- .../sonia/scm/search/QueryResultFactory.java | 6 +- .../sonia/scm/search/SearchableTypes.java | 6 +- .../main/java/sonia/scm/search/TypeCheck.java | 4 +- .../main/resources/locales/de/plugins.json | 93 ++++++++- .../main/resources/locales/en/plugins.json | 94 ++++++++- .../v2/resources/IndexDtoGeneratorTest.java | 1 + .../api/v2/resources/SearchResourceTest.java | 37 +++- .../resources/SearchableTypeMapperTest.java | 63 ++++++ 43 files changed, 1494 insertions(+), 94 deletions(-) create mode 100644 gradle/changelog/search_syntax.yaml create mode 100644 scm-core/src/main/java/sonia/scm/search/SearchableField.java create mode 100644 scm-ui/ui-api/src/help/search/modal.de.ts create mode 100644 scm-ui/ui-api/src/help/search/modal.en.ts create mode 100644 scm-ui/ui-api/src/help/search/syntax.de.ts create mode 100644 scm-ui/ui-api/src/help/search/syntax.en.ts create mode 100644 scm-ui/ui-components/src/buttons/LinkStyleButton.tsx create mode 100644 scm-ui/ui-components/src/buttons/NoStyleButton.tsx create mode 100644 scm-ui/ui-webapp/src/search/Syntax.tsx create mode 100644 scm-ui/ui-webapp/src/search/SyntaxHelp.tsx create mode 100644 scm-ui/ui-webapp/src/search/SyntaxModal.tsx create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchableFieldDto.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchableTypeDto.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchableTypeMapper.java rename scm-webapp/src/main/java/sonia/scm/search/{SearchableField.java => LuceneSearchableField.java} (95%) create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchableTypeMapperTest.java diff --git a/gradle/changelog/search_syntax.yaml b/gradle/changelog/search_syntax.yaml new file mode 100644 index 0000000000..1bf3c6d82d --- /dev/null +++ b/gradle/changelog/search_syntax.yaml @@ -0,0 +1,2 @@ +- type: Added + description: Add additional help to quick search and an advanced search documentation page ([#1757](https://github.com/scm-manager/scm-manager/pull/1757) diff --git a/scm-core/src/main/java/sonia/scm/search/SearchableField.java b/scm-core/src/main/java/sonia/scm/search/SearchableField.java new file mode 100644 index 0000000000..cd9b39335e --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/search/SearchableField.java @@ -0,0 +1,49 @@ +/* + * 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; + +/** + * A field of a {@link SearchableType}. + * + * @since 2.23.0 + */ +@Beta +public interface SearchableField { + + /** + * Returns the name of the searchable field. + * + * @return field name + */ + String getName(); + + /** + * Returns the type of the searchable field. + * + * @return field type + */ + Class getType(); +} diff --git a/scm-core/src/main/java/sonia/scm/search/SearchableType.java b/scm-core/src/main/java/sonia/scm/search/SearchableType.java index 25ba2c1edc..bb08717029 100644 --- a/scm-core/src/main/java/sonia/scm/search/SearchableType.java +++ b/scm-core/src/main/java/sonia/scm/search/SearchableType.java @@ -26,6 +26,8 @@ package sonia.scm.search; import com.google.common.annotations.Beta; +import java.util.Collection; + /** * A type which can be searched with the {@link SearchEngine}. * @@ -47,4 +49,12 @@ public interface SearchableType { * @return class of type */ Class getType(); + + /** + * Returns collection of searchable fields. + * + * @return collection of searchable fields + * @since 2.23.0 + */ + Collection getFields(); } diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index dbb310ad7d..d40bbdad01 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -96,6 +96,7 @@ public class VndMediaType { public static final String NOTIFICATION_COLLECTION = PREFIX + "notificationCollection" + SUFFIX; public static final String QUERY_RESULT = PREFIX + "queryResult" + SUFFIX; + public static final String SEARCHABLE_TYPE_COLLECTION = PREFIX + "searchableTypeCollection" + SUFFIX; private VndMediaType() { } diff --git a/scm-ui/ui-api/src/help/search/modal.de.ts b/scm-ui/ui-api/src/help/search/modal.de.ts new file mode 100644 index 0000000000..6081439065 --- /dev/null +++ b/scm-ui/ui-api/src/help/search/modal.de.ts @@ -0,0 +1,33 @@ +/* + * 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 default `Unsere Suche basiert auf Lucene. Sie können die [vollständige Syntax](http://lucene.apache.org/core/8_9_0/index.html) nutzen. + +Um mehr über die fortgeschrittene Suche zu erfahren, lesen sie unsere [Expertenseite](/help/search-syntax). + +### Einfache Suche + +- Die relevantesten Repositories werden in den Quick Results angezeigt. +- Über die Eingabe-Taste oder den Button "Alle Ergebnisse anzeigen" bekommen Sie Ergebnisse aller durchsuchten Entitäten wie Nutzern oder Gruppen. +- Eine Wildcard für eine beliebige Anzahl an beliebigen Zeichen wird Ihrer Suche standardmäßig angehängt. +- Geben Sie keine Wildcards vor dem Suchbegriff ein!`; diff --git a/scm-ui/ui-api/src/help/search/modal.en.ts b/scm-ui/ui-api/src/help/search/modal.en.ts new file mode 100644 index 0000000000..aa76cdbe34 --- /dev/null +++ b/scm-ui/ui-api/src/help/search/modal.en.ts @@ -0,0 +1,33 @@ +/* + * 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 default `Our search uses Lucene and you may use the [full syntax](http://lucene.apache.org/core/8_9_0/index.html). + +To learn about advanced search read our [Expert Search Site](/help/search-syntax). + +### Basic Search + +- The most relevant repositories are shown in the quick results. +- Press "enter" or click the "Show all results" button to find more results for all entities like users or groups. +- A multi-character wildcard (*) is added to your search by default. +- Do not enter Wildcards in front of the search!`; diff --git a/scm-ui/ui-api/src/help/search/syntax.de.ts b/scm-ui/ui-api/src/help/search/syntax.de.ts new file mode 100644 index 0000000000..e28245eeab --- /dev/null +++ b/scm-ui/ui-api/src/help/search/syntax.de.ts @@ -0,0 +1,148 @@ +/* + * 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 default `## Modifikatoren + +Hinweis: Sie können keine Wildcards als erstes Zeichen einer Suche verwenden. + + + + + + + + + + + + + + +
DefinitionBeispiel
? - Einzelzeichen-WildcardUltimate?Repo - findet z.B. Ultimate-Repo, Ultimate Repo, Ultimate+Repo
* - mehrstelliger PlatzhalterUltimat*y - findet z.B. Ultimate Repository, Ultimates-Spezial-Repository, Ultimate
+ + +### Bereiche + +Bereichsabfragen ermöglichen den Abgleich von Dokumenten, deren Feldwerte zwischen der unteren und der oberen Grenze liegen, die in der Bereichsabfrage angegeben sind. Bereichsabfragen können die obere und untere Grenze einschließen oder ausschließen. Die Sortierung erfolgt lexikografisch. + +Bereiche sind nicht auf numerische Felder beschränkt. + + + + + + + + + + + + + + +
DefinitionBeispiel
[ … TO … ] - inklusiver BereichcreationDate:[1609459200000 TO 1612137600000] – findet z.B. Repositories, die zwischen dem 01.01.2021 und dem 01.02.2021 angelegt wurden.
{… TO …} - ausschließender Bereichname:{Aida TO Carmen} – findet Namen zwischen Aida und Carmen, jedoch ohne die beiden Namen einzuschließen.
+ + +## Boosten + +Mit dem Boosting können Sie die Relevanz eines Dokuments steuern, indem Sie seinen Term verstärken. + + + + + + + + + + +
DefinitionBeispiel
term^Zahlultimate^2 repository – erhöht die Relevanz von „ultimate"
+ + +Standardmäßig ist der Boost-Faktor 1. Obwohl der Boost-Faktor positiv sein muss, kann er kleiner als 1 sein (z. B. 0,2) + +Standardmäßig werden Repository-Namen um 1,5 und Namespace-Namen um 1,25 geboostet. + +## Boolesche Operatoren + +Hinweis: Logische Operatoren müssen in Großbuchstaben eingegeben werden (z. B. „AND"). + + + + + + + + + + + + + + + + + + + + + + + + + + +
DefinitionBeispiel
AND – beide Terme müssen enthalten seinUltimate AND Repository – findet z.B. Ultimate Repository, Ultimate Special Repository +
OR – mindestens einer der Terme muss enthalten seinUltimate OR Repository – findet z.B.. Ultimate Repository, Ultimate User, Special Repository
NOT – der nachfolgende Term darf nicht enthalten sein. „!" kann alternativ verwendet werden.Ultimate NOT Repository – findet z.B.. Ultimate user, nicht jedoch z.B. Ultimate Repository
– schließt den folgenden Term von der Suche ausUltimate Repository -Special – findet z.B. Ultimate Repository, schließt z.B. Ultimate Special Repository aus
– der folgende Term muss enthalten seinUltimate +Repository – findet z.B. my Repository, Ultimate Repository
+ + +## Gruppieren + +Die Suche unterstützt die Verwendung von Klammern zur Gruppierung von Begriffen, um Teilabfragen zu bilden. Dies kann sehr nützlich sein, wenn Sie die boolesche Logik für eine Abfrage steuern möchten. + + + + + + + + + + +
DefinitionBeispiel
() – Terme zwischen den Klammern werden gruppiert(Ultimate OR my) AND Repository – findet z.B.. Ultimate Repository, my Repository, schließt z.B. Super Repository. Entweder "Ultimate" oder “My” müssen im Ergebnis existieren, “Repository” muss immer enthalten sein. +
+ + +## Umgang mit Sonderzeichen + +Die Suche unterstützt Sonderzeichen, die Teil der Abfragesyntax sind. Die aktuellen Sonderzeichen der Liste sind + ++ - && || ! ( ) { } [ ] ^ " ~ * ? : \\ / + +Um diese Zeichen zu nutzen, verwenden Sie das „\\" vor dem jeweiligen Zeichen. Um zum Beispiel nach (1+1):2 zu suchen, verwenden Sie diese Abfrage: + +\\(1\\+1\\)\\:2 + +Partiell übersetzt mit www.DeepL.com/Translator (kostenlose Version) + +Quelle [https://javadoc.io/static/org.apache.lucene/lucene-queryparser/8.9.0/org/apache/lucene/queryparser/classic/package-summary.html#package.description](https://javadoc.io/static/org.apache.lucene/lucene-queryparser/8.9.0/org/apache/lucene/queryparser/classic/package-summary.html#package.description)`; diff --git a/scm-ui/ui-api/src/help/search/syntax.en.ts b/scm-ui/ui-api/src/help/search/syntax.en.ts new file mode 100644 index 0000000000..2193037461 --- /dev/null +++ b/scm-ui/ui-api/src/help/search/syntax.en.ts @@ -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. + */ +export default `### Modifiers + +Note: You can not use wildcards as the first character of a search. + + + + + + + + + + + + + + +
DefinitionExample
? - single character WildcardUltimate?Repo – finds e.g. Ultimate-Repo, Ultimate Repo, Ultimate+Repo
* - multiple character WildcardUltimat*y - finds e.g. Ultimate Repository, Ultimate-Special-Repository, Ultimately
+ + +### Ranges + +Range Queries allow one to match documents whose field(s) values are between the lower and upper bound specified by the Range Query. Range Queries can be inclusive or exclusive of the upper and lower bounds. Sorting is done lexicographically. + +Ranges are not reserved to numerical fields. + + + + + + + + + + + + + + +
DefinitionExample
[ … TO … ] - inclusive rangecreationDate:[1609459200000 TO 1612137600000] – finds e.G. repositories created between 2021-01-01 and 2021-02-01
{… TO …} - exclusive rangename:{Aida TO Carmen} – finds e.G. repositories with names between Aida and Carmen, excluding these to values.
+ + +### Boosting + +Boosting allows you to control the relevance of a document by boosting its term. + + + + + + + + + + +
DefinitionExample
term^numberultimate^2 repository – makes the term "ultimate" more relevant.
+ + +By default, the boost factor is 1. Although the boost factor must be positive, it can be less than 1 (e.g. 0.2) + +By default Repository names are boosted by 1.5, namespace by 1.25. + +## Boolean Operators + +Note: Logical Operators must be entered in upper case (e.g. "AND"). + + + + + + + + + + + + + + + + + + + + + + + + + + +
DefinitionExample
AND – both terms must be includedUltimate AND Repository – finds e.g. Ultimate Repository, Ultimate Special Repository
OR – at least one of the terms must be includedUltimate OR Repository – finds e.g. Ultimate Repository, Ultimate User, Special Repository
NOT – following term may not be included, "!" may be used alternativelyUltimate NOT Repository – finds e.g. Ultimate user, excludes e.g. Ultimate Repository
– excludes following term from searchUltimate Repository -Special – finds e.g. Ultimate Repository, excludes e.g. Ultimate Special Repository
– following term must be includedUltimate +Repository – finds e.g. my Repository, Ultimate Repository
+ + +## Grouping + +Search supports using parentheses to group clauses to form sub queries. This can be very useful if you want to control the boolean logic for a query. + + + + + + + + + + +
DefinitionExample
() – terms inside parentheses are grouped together(Ultimate OR my) AND Repository – finds e.g. Ultimate Repository, my Repository, excludes e.g. Super Repository. Either "Ultimate" or “My” must exist, “Repository” must always exist. +
+ + +## Escaping Special Characters + +The search supports escaping special characters that are part of the query syntax. The current list special characters are + ++ - && || ! ( ) { } [ ] ^ " ~ * ? : \\ / + +To escape these characters use the "\\" before the character. For example to search for (1+1):2 use the query: + +\\(1\\+1\\)\\:2 + +Source: [https://javadoc.io/static/org.apache.lucene/lucene-queryparser/8.9.0/org/apache/lucene/queryparser/classic/package-summary.html#package.description](https://javadoc.io/static/org.apache.lucene/lucene-queryparser/8.9.0/org/apache/lucene/queryparser/classic/package-summary.html#package.description)`; diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index 5cb88941a6..2d9ec81a26 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -23,6 +23,7 @@ */ import * as urls from "./urls"; + export { urls }; export * from "./errors"; diff --git a/scm-ui/ui-api/src/search.ts b/scm-ui/ui-api/src/search.ts index d7593c64e6..a28a6f2e7f 100644 --- a/scm-ui/ui-api/src/search.ts +++ b/scm-ui/ui-api/src/search.ts @@ -22,11 +22,12 @@ * SOFTWARE. */ -import { ApiResult, useIndexLinks } from "./base"; -import { Link, QueryResult } from "@scm-manager/ui-types"; +import { ApiResult, useIndexJsonResource, useIndexLinks } from "./base"; +import { Link, QueryResult, SearchableType } from "@scm-manager/ui-types"; import { apiClient } from "./apiclient"; import { createQueryString } from "./utils"; import { useQueries, useQuery } from "react-query"; +import { useEffect, useState } from "react"; export type SearchOptions = { type: string; @@ -46,6 +47,8 @@ export const useSearchTypes = () => { .filter(isString); }; +export const useSearchableTypes = () => useIndexJsonResource("searchableTypes"); + export const useSearchCounts = (types: string[], query: string) => { const searchLinks = useSearchLinks(); const queries = useQueries( @@ -113,3 +116,31 @@ export const useSearch = (query: string, optionParam = defaultSearchOptions): Ap } ); }; + +const useObserveAsync = (fn: (...args: D) => Promise, deps: D) => { + const [data, setData] = useState(); + const [isLoading, setLoading] = useState(false); + const [error, setError] = useState(); + useEffect(() => { + setLoading(true); + fn(...deps) + .then(setData) + .catch(setError) + .finally(() => setLoading(false)); + }, deps); + return { data, isLoading, error }; +}; + +const supportedLanguages = ["de", "en"]; + +const pickLang = (language: string) => { + if (!supportedLanguages.includes(language)) { + return "en"; + } + return language; +}; + +export const useSearchHelpContent = (language: string) => + useObserveAsync((lang) => import(`./help/search/modal.${pickLang(lang)}`).then((module) => module.default), [language]); +export const useSearchSyntaxContent = (language: string) => + useObserveAsync((lang) => import(`./help/search/syntax.${pickLang(lang)}`).then((module) => module.default), [language]); diff --git a/scm-ui/ui-components/src/HelpIcon.tsx b/scm-ui/ui-components/src/HelpIcon.tsx index f834467bb9..ba665607c9 100644 --- a/scm-ui/ui-components/src/HelpIcon.tsx +++ b/scm-ui/ui-components/src/HelpIcon.tsx @@ -21,16 +21,13 @@ * 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 Icon from "./Icon"; type Props = { className?: string; }; -export default class HelpIcon extends React.Component { - render() { - const { className } = this.props; - return ; - } -} +const HelpIcon: FC = ({ className }) => ; + +export default HelpIcon; diff --git a/scm-ui/ui-components/src/buttons/LinkStyleButton.tsx b/scm-ui/ui-components/src/buttons/LinkStyleButton.tsx new file mode 100644 index 0000000000..71a2c0461a --- /dev/null +++ b/scm-ui/ui-components/src/buttons/LinkStyleButton.tsx @@ -0,0 +1,32 @@ +/* + * 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 styled from "styled-components"; +import NoStyleButton from "./NoStyleButton"; + +const LinkStyleButton = styled(NoStyleButton)` + text-decoration: underline; +`; + +export default LinkStyleButton; diff --git a/scm-ui/ui-components/src/buttons/NoStyleButton.tsx b/scm-ui/ui-components/src/buttons/NoStyleButton.tsx new file mode 100644 index 0000000000..9ef55ce4ad --- /dev/null +++ b/scm-ui/ui-components/src/buttons/NoStyleButton.tsx @@ -0,0 +1,38 @@ +/* + * 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 styled from "styled-components"; + +const NoStyleButton = styled.button` + background: none; + color: inherit; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + outline: inherit; + text-align: inherit; +`; + +export default NoStyleButton; diff --git a/scm-ui/ui-components/src/buttons/index.ts b/scm-ui/ui-components/src/buttons/index.ts index cc7307572d..d432107e53 100644 --- a/scm-ui/ui-components/src/buttons/index.ts +++ b/scm-ui/ui-components/src/buttons/index.ts @@ -35,3 +35,5 @@ export { default as ButtonGroup } from "./ButtonGroup"; export { default as ButtonAddons } from "./ButtonAddons"; export { default as OpenInFullscreenButton } from "./OpenInFullscreenButton"; export { default as RemoveEntryOfTableButton } from "./RemoveEntryOfTableButton"; +export { default as NoStyleButton } from "./NoStyleButton"; +export { default as LinkStyleButton } from "./LinkStyleButton"; diff --git a/scm-ui/ui-components/src/index.ts b/scm-ui/ui-components/src/index.ts index ca8d4c044f..1ba854f193 100644 --- a/scm-ui/ui-components/src/index.ts +++ b/scm-ui/ui-components/src/index.ts @@ -29,15 +29,15 @@ import { urls } from "@scm-manager/ui-api"; // not sure if it is required import { + AnnotationFactory, + AnnotationFactoryContext, + BaseContext, + Change, + DiffEventContext, + DiffEventHandler, File, FileChangeType, Hunk, - Change, - BaseContext, - AnnotationFactory, - AnnotationFactoryContext, - DiffEventHandler, - DiffEventContext } from "./repos"; export { validation, repositories }; @@ -81,6 +81,7 @@ export { default as SplitAndReplace, Replacement } from "./SplitAndReplace"; export { regExpPattern as changesetShortLinkRegex } from "./markdown/remarkChangesetShortLinkParser"; export * from "./markdown/PluginApi"; export * from "./devices"; +export { default as copyToClipboard } from "./CopyToClipboard"; export { default as comparators } from "./comparators"; @@ -108,7 +109,7 @@ export { AnnotationFactory, AnnotationFactoryContext, DiffEventHandler, - DiffEventContext + DiffEventContext, }; // Re-export from ui-api @@ -125,7 +126,7 @@ export { MissingLinkError, createBackendError, isBackendError, - TOKEN_EXPIRED_ERROR_CODE + TOKEN_EXPIRED_ERROR_CODE, } from "@scm-manager/ui-api"; export { urls }; diff --git a/scm-ui/ui-components/src/layout/Page.tsx b/scm-ui/ui-components/src/layout/Page.tsx index 22f8dcc85f..74b3c63caf 100644 --- a/scm-ui/ui-components/src/layout/Page.tsx +++ b/scm-ui/ui-components/src/layout/Page.tsx @@ -37,7 +37,7 @@ type Props = { // and use something different than a string for the title property. documentTitle?: string; afterTitle?: ReactNode; - subtitle?: string; + subtitle?: ReactNode; loading?: boolean; error?: Error | null; showContentOnError?: boolean; @@ -123,7 +123,7 @@ export default class Page extends React.Component { {this.getTitleComponent()} {afterTitle && {afterTitle}} - + {subtitle ? {subtitle} : null} {pageActions} diff --git a/scm-ui/ui-types/src/Search.ts b/scm-ui/ui-types/src/Search.ts index 016235e434..56c3a8cef0 100644 --- a/scm-ui/ui-types/src/Search.ts +++ b/scm-ui/ui-types/src/Search.ts @@ -22,7 +22,7 @@ * SOFTWARE. */ -import { HalRepresentationWithEmbedded, PagedCollection } from "./hal"; +import { HalRepresentation, HalRepresentationWithEmbedded, PagedCollection } from "./hal"; import { Repository } from "./Repositories"; export type ValueHitField = { @@ -54,3 +54,13 @@ export type QueryResult = PagedCollection & { type: string; totalHits: number; }; + +export type SearchableField = { + name: string; + type: string; +}; + +export type SearchableType = HalRepresentation & { + name: string; + fields: SearchableField[]; +}; diff --git a/scm-ui/ui-webapp/public/locales/de/commons.json b/scm-ui/ui-webapp/public/locales/de/commons.json index 6a31f2bc33..de55ad0e8b 100644 --- a/scm-ui/ui-webapp/public/locales/de/commons.json +++ b/scm-ui/ui-webapp/public/locales/de/commons.json @@ -156,11 +156,49 @@ "subtitle": "{{type}} Ergebnisse für \"{{query}}\"", "types": "Ergebnisse", "noHits": "Die Suche ergab keine Treffer", + "syntaxHelp": "Finden Sie bessere Ergebnisse durch die Nutzung der vollen <0>Such-Syntax", "quickSearch": { "resultHeading": "Top-Ergebnisse Repositories", - "parseError": "Der Suchstring is ungültig", + "parseError": "Der Suchstring is ungültig.", + "parseErrorHelp": "Hinweise zu ihrer Suche", "moreResults": "Mehr Ergebnisse", - "noResults": "Es konnten keine Repositories gefunden werden" + "noResults": "Es konnten keine Repositories gefunden werden", + "hintsIcon": "Suchtipps", + "hints": "Hinweise zu ihrer Suche" + }, + "syntax": { + "title": "Experten-Suche", + "subtitle": "Erfahren Sie mehr über die Felder der Entitäten, Modifikatoren und Operatoren.", + "exampleQueriesAndFields": { + "title": "Beispielabfragen und Felder", + "description": "Felder können einzeln abgefragt werden, um Ihre Suche einzugrenzen, z.B. name:ultimate sucht nur nach ultimate im Feld \"name\" der Entitäten. Einige Felder können in allen oder den meisten Entitäten gefunden werden (z.B. Name, creationDate). Andere Felder sind nur bei einzelnen Entitäten vorhanden." + }, + "fields": { + "name": "Name", + "type": "Typ", + "exampleValue": "Beispielwert", + "hints": "Hinweise" + }, + "exampleQueries": { + "title": "Beispielabfragen", + "description": "Felder mit Modifikatoren und Operatoren um Repositories zu finden.", + "table": { + "description": "Wonach gesucht wird", + "query": "Abfrage", + "explanation": "Erklärung" + } + }, + "utilities": { + "title": "Hilfsmittel", + "description": "Wandeln Sie menschlich-lesbare Zeitmarken in standard Millisekunden um, um diese in Ihren Queries zu verwenden. Das Datum ist ein Pflichtfeld, Stunde, Minute und Sekunde sind optional.", + "datetime":{ + "label": "Datum in Standardzeitstempel umwandeln", + "format": "yyyy-mm-dd hh:mm:ss", + "convertButtonLabel": "Konvertieren" + }, + "timestampPlaceholder": "Standard Zeitstempel", + "copyTimestampTooltip": "Zeitstempel in Zwischenablage kopieren" + } } } } diff --git a/scm-ui/ui-webapp/public/locales/en/commons.json b/scm-ui/ui-webapp/public/locales/en/commons.json index fda508e803..8e2711f41d 100644 --- a/scm-ui/ui-webapp/public/locales/en/commons.json +++ b/scm-ui/ui-webapp/public/locales/en/commons.json @@ -157,11 +157,49 @@ "subtitle": "{{type}} results for \"{{query}}\"", "types": "Results", "noHits": "No results found", + "syntaxHelp": "Find better results by using the full <0>search syntax", "quickSearch": { "resultHeading": "Top repository results", - "parseError": "Failed to parse query", + "parseError": "Failed to parse query.", + "parseErrorHelp": "Hints for your Search", "noResults": "Could not find matching repository", - "moreResults": "More Results" + "moreResults": "More Results", + "hintsIcon": "Search Hints", + "hints": "Hints for your Search" + }, + "syntax": { + "title": "Expert Search", + "subtitle": "Learn About the fields of the entities, modifiers and operators.", + "exampleQueriesAndFields": { + "title": "Example Queries and Fields", + "description": "Fields may be queried individually to narrow down your search, e.g. name:ultimate will query for ultimate only in the field “name” of entities. Some fields may be found in all or most entities (e.g. Name, creationDate). Other fields only exist with single entities." + }, + "fields": { + "name": "Name", + "type": "Type", + "exampleValue": "Example Value", + "hints": "Hints" + }, + "exampleQueries": { + "title": "Example Queries", + "description": "Combine Fields with Modifiers and Operators to find your repositories.", + "table": { + "description": "What you search for", + "query": "Query", + "explanation": "Explanation" + } + }, + "utilities": { + "title": "Utilities", + "description": "Convert human-readable timestamps to Epoch Milliseconds to use in your query. Date is mandatory, hour, minute and seconds are optional.", + "datetime":{ + "label": "Convert timestamps to Epoch Milliseconds", + "format": "yyyy-mm-dd hh:mm:ss", + "convertButtonLabel": "Convert" + }, + "timestampPlaceholder": "Epoch Timestamp", + "copyTimestampTooltip": "Copy Timestamp to Clipboard" + } } } } diff --git a/scm-ui/ui-webapp/src/containers/Main.tsx b/scm-ui/ui-webapp/src/containers/Main.tsx index d4eebc0c13..4036540fc5 100644 --- a/scm-ui/ui-webapp/src/containers/Main.tsx +++ b/scm-ui/ui-webapp/src/containers/Main.tsx @@ -50,6 +50,7 @@ import ImportLog from "../repos/importlog/ImportLog"; import CreateRepositoryRoot from "../repos/containers/CreateRepositoryRoot"; import styled from "styled-components"; import Search from "../search/Search"; +import Syntax from "../search/Syntax"; type Props = { me: Me; @@ -109,6 +110,7 @@ const Main: FC = (props) => { + diff --git a/scm-ui/ui-webapp/src/containers/OmniSearch.tsx b/scm-ui/ui-webapp/src/containers/OmniSearch.tsx index c4491ebac8..2bedb86949 100644 --- a/scm-ui/ui-webapp/src/containers/OmniSearch.tsx +++ b/scm-ui/ui-webapp/src/containers/OmniSearch.tsx @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { FC, KeyboardEvent as ReactKeyboardEvent, MouseEvent, useCallback, useState, useEffect } from "react"; +import React, { FC, KeyboardEvent as ReactKeyboardEvent, MouseEvent, useCallback, useEffect, useState } from "react"; import { Hit, Links, ValueHitField } from "@scm-manager/ui-types"; import styled from "styled-components"; import { BackendError, useSearch } from "@scm-manager/ui-api"; @@ -32,10 +32,13 @@ import { Button, ErrorNotification, HitProps, + LinkStyleButton, Notification, RepositoryAvatar, useStringHitFieldValue, } from "@scm-manager/ui-components"; +import SyntaxHelp from "../search/SyntaxHelp"; +import SyntaxModal from "../search/SyntaxModal"; const Field = styled.div` margin-bottom: 0 !important; @@ -58,6 +61,7 @@ const namespaceAndName = (hit: Hit) => { type HitsProps = { hits: Hit[]; index: number; + showHelp: () => void; gotoDetailSearch: () => void; clear: () => void; }; @@ -68,26 +72,28 @@ type GotoProps = { gotoDetailSearch: () => void; }; -const EmptyHits: FC = ({ gotoDetailSearch }) => { +const EmptyHits: FC = () => { const [t] = useTranslation("commons"); return ( - - {t("search.quickSearch.noResults")} - - + + {t("search.quickSearch.noResults")} + ); }; type ErrorProps = { error: Error; + showHelp: () => void; }; -const ParseErrorNotification: FC = () => { +const ParseErrorNotification: FC = ({ showHelp }) => { const [t] = useTranslation("commons"); - // TODO add link to query syntax page/modal return ( - {t("search.quickSearch.parseError")} + +

{t("search.quickSearch.parseError")}

+ {t("search.quickSearch.parseErrorHelp")} +
); }; @@ -96,10 +102,10 @@ const isBackendError = (error: Error | BackendError): error is BackendError => { return (error as BackendError).errorCode !== undefined; }; -const SearchErrorNotification: FC = ({ error }) => { +const SearchErrorNotification: FC = ({ error, showHelp }) => { // 5VScek8Xp1 is the id of sonia.scm.search.QueryParseException if (isBackendError(error) && error.errorCode === "5VScek8Xp1") { - return ; + return ; } return ( @@ -113,6 +119,9 @@ const ResultHeading = styled.h3` margin: 0 0.5rem; padding: 0.375rem 0.5rem; font-weight: bold; + display: flex; + align-items: center; + justify-content: space-between; `; const DropdownMenu = styled.div` @@ -153,17 +162,13 @@ const MoreResults: FC = ({ gotoDetailSearch }) => { ); }; -const Hits: FC = ({ hits, index, clear, gotoDetailSearch }) => { +const HitsList: FC = ({ hits, index, clear, gotoDetailSearch }) => { const id = useCallback(namespaceAndName, [hits]); - const [t] = useTranslation("commons"); - if (hits.length === 0) { - return ; + return ; } - return ( -
- {t("search.quickSearch.resultHeading")} + <> {hits.map((hit, idx) => (
e.preventDefault()} onClick={clear}> = ({ hits, index, clear, gotoDetailSearch }) => { role="option" data-omnisearch="true" > - {id(hit)} + + {id(hit)}
))} - -
+ + ); +}; + +const Hits: FC = ({ showHelp, gotoDetailSearch, ...rest }) => { + const [t] = useTranslation("commons"); + + return ( + <> +
+ + {t("search.quickSearch.resultHeading")} + + + + +
+ ); }; @@ -305,11 +327,12 @@ const OmniSearch: FC = () => { const debouncedQuery = useDebounce(query, 250); const { data, isLoading, error } = useSearch(debouncedQuery, { type: "repository", pageSize: 5 }); const { showResults, hideResults, ...handlers } = useShowResultsOnFocus(); + const [showHelp, setShowHelp] = useState(false); const history = useHistory(); - const clearQuery = () => { - setQuery(""); - }; + const openHelp = () => setShowHelp(true); + const closeHelp = () => setShowHelp(false); + const clearQuery = () => setQuery(""); const gotoDetailSearch = () => { history.push(`/search/repository/?q=${query}`); @@ -320,6 +343,7 @@ const OmniSearch: FC = () => { return ( + {showHelp ? : null}
{ )}
e.preventDefault()}> - {error ? : null} + {error ? : null} {!error && data ? ( - + ) : null} diff --git a/scm-ui/ui-webapp/src/search/Search.tsx b/scm-ui/ui-webapp/src/search/Search.tsx index 613e495ec9..c57a6e631f 100644 --- a/scm-ui/ui-webapp/src/search/Search.tsx +++ b/scm-ui/ui-webapp/src/search/Search.tsx @@ -33,10 +33,10 @@ import { Tag, urls, } from "@scm-manager/ui-components"; -import { useLocation, useParams } from "react-router-dom"; +import { Link, useLocation, useParams } from "react-router-dom"; import { useSearch, useSearchCounts, useSearchTypes } from "@scm-manager/ui-api"; import Results from "./Results"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; type PathParams = { type: string; @@ -85,6 +85,27 @@ const orderTypes = (left: string, right: string) => { return 0; }; +type Props = { + selectedType: string; + query: string; +}; + +const SyntaxHelpLink: FC = ({ children }) => {children}; + +const SearchSubTitle: FC = ({ selectedType, query }) => { + const [t] = useTranslation("commons"); + return ( + <> + {t("search.subtitle", { + query, + type: t(`plugins:search.types.${selectedType}.subtitle`, selectedType), + })} +
+ ]} /> + + ); +}; + const Search: FC = () => { const [t] = useTranslation(["commons", "plugins"]); const { query, selectedType, page } = usePageParams(); @@ -112,10 +133,7 @@ const Search: FC = () => { return ( } loading={isLoading} error={error} > diff --git a/scm-ui/ui-webapp/src/search/Syntax.tsx b/scm-ui/ui-webapp/src/search/Syntax.tsx new file mode 100644 index 0000000000..b389f1df76 --- /dev/null +++ b/scm-ui/ui-webapp/src/search/Syntax.tsx @@ -0,0 +1,194 @@ +/* + * 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, useState } from "react"; +import { useSearchableTypes, useSearchSyntaxContent } from "@scm-manager/ui-api"; +import { useTranslation } from "react-i18next"; +import { + Button, + copyToClipboard, + Icon, + InputField, + Loading, + MarkdownView, + Page, + Tooltip, +} from "@scm-manager/ui-components"; +import { parse } from "date-fns"; +import styled from "styled-components"; +import classNames from "classnames"; + +const StyledTooltip = styled(Tooltip)` + height: 40px; +`; + +type ExpandableProps = { + header: React.ReactNode; + className?: string; +}; + +const Expandable: FC = ({ header, children, className }) => { + const [expanded, setExpanded] = useState(false); + return ( +
+
setExpanded(!expanded)} className="card-header is-clickable"> + {header} + + + +
+ {expanded ?
{children}
: null} +
+ ); +}; + +type Example = { + description: string; + query: string; + explanation: string; +}; + +const Syntax: FC = () => { + const { t, i18n } = useTranslation(["commons", "plugins"]); + const { loading: isLoading, data: helpModalContent } = useSearchSyntaxContent(i18n.languages[0]); + const [datetime, setDatetime] = useState(""); + const [timestamp, setTimestamp] = useState(""); + const [copying, setCopying] = useState(false); + const { isLoading: isLoadingSearchableTypes, data: searchableTypes } = useSearchableTypes(); + + const convert = () => { + const format = "yyyy-MM-dd HH:mm:ss"; + const date = parse(datetime, format, new Date()); + const newTimestamp = date.getTime(); + setTimestamp(String(newTimestamp)); + }; + const copyTimestamp = () => { + setCopying(true); + copyToClipboard(timestamp).finally(() => setCopying(false)); + }; + + if (isLoading || isLoadingSearchableTypes) { + return ; + } + + const searchableTypesContent = searchableTypes!.map((searchableType) => { + const examples = t(`plugins:search.types.${searchableType.name}.examples`, { + returnObjects: true, + defaultValue: [], + }); + return ( + + + + + + + + + {searchableType.fields.map((searchableField) => ( + + + + + + + ))} +
{t("search.syntax.fields.name")}{t("search.syntax.fields.type")}{t("search.syntax.fields.exampleValue")}{t("search.syntax.fields.hints")}
{t(`plugins:search.types.${searchableType.name}.fields.${searchableField.name}.name`)}{searchableField.type} + {t(`plugins:search.types.${searchableType.name}.fields.${searchableField.name}.exampleValue`, { + defaultValue: "", + })} + + {t(`plugins:search.types.${searchableType.name}.fields.${searchableField.name}.hints`, { + defaultValue: "", + })} +
+ {examples.length > 0 ? ( + <> +
{t("search.syntax.exampleQueries.title")}
+
{t("search.syntax.exampleQueries.description")}
+ + + + + + + {examples.map((example) => ( + + + + + + ))} +
{t("search.syntax.exampleQueries.table.description")}{t("search.syntax.exampleQueries.table.query")}{t("search.syntax.exampleQueries.table.explanation")}
{example.description}{example.query}{example.explanation}
+ + ) : null} +
+ ); + }); + + return ( + +
+

{t("search.syntax.exampleQueriesAndFields.title")}

+

{t("search.syntax.exampleQueriesAndFields.description")}

+ {searchableTypesContent} +
+ +

{t("search.syntax.utilities.title")}

+

{t("search.syntax.utilities.description")}

+
{t("search.syntax.utilities.datetime.label")}
+
+ + + + + + + + {copying ? ( + + ) : ( + + )} + + +
+
+ ); +}; + +export default Syntax; diff --git a/scm-ui/ui-webapp/src/search/SyntaxHelp.tsx b/scm-ui/ui-webapp/src/search/SyntaxHelp.tsx new file mode 100644 index 0000000000..ea3e391c25 --- /dev/null +++ b/scm-ui/ui-webapp/src/search/SyntaxHelp.tsx @@ -0,0 +1,44 @@ +/* + * 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 { useTranslation } from "react-i18next"; +import { HelpIcon, NoStyleButton } from "@scm-manager/ui-components"; + +type Props = { + onClick: () => void; +}; + +const SyntaxHelp: FC = ({ onClick }) => { + const [t] = useTranslation("commons"); + return ( + <> + + + + + ); +}; + +export default SyntaxHelp; diff --git a/scm-ui/ui-webapp/src/search/SyntaxModal.tsx b/scm-ui/ui-webapp/src/search/SyntaxModal.tsx new file mode 100644 index 0000000000..0d0c05a764 --- /dev/null +++ b/scm-ui/ui-webapp/src/search/SyntaxModal.tsx @@ -0,0 +1,70 @@ +/* + * 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, MouseEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchHelpContent } from "@scm-manager/ui-api"; +import { ErrorNotification, Loading, MarkdownView, Modal } from "@scm-manager/ui-components"; + +type Props = { + close: () => void; +}; + +const SyntaxModalContent: FC = ({ close }) => { + const { i18n } = useTranslation("commons"); + const { isLoading, data, error } = useSearchHelpContent(i18n.languages[0]); + + const handleClickEvent = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (target.tagName === "A") { + close(); + } + }; + + if (error) { + return ; + } else if (isLoading || !data) { + return ; + } else { + return ( +
+ +
+ ); + } +}; + +const SyntaxModal: FC = ({ close }) => { + const [t] = useTranslation("commons"); + return ( + } + closeFunction={close} + /> + ); +}; + +export default SyntaxModal; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java index a7151c0971..9cf73fc3e4 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java @@ -143,6 +143,7 @@ public class IndexDtoGenerator extends HalAppenderMapper { builder.single(link("importLog", resourceLinks.repository().importLog("IMPORT_LOG_ID").replace("IMPORT_LOG_ID", "{logId}"))); builder.array(searchLinks()); + builder.single(link("searchableTypes", resourceLinks.search().searchableTypes())); } else { builder.single(link("login", resourceLinks.authentication().jsonLogin())); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java index 7d973d0d1a..6543b63862 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java @@ -93,5 +93,7 @@ public class MapperModule extends AbstractModule { bind(RepositoryImportDtoToRepositoryImportParametersMapper.class).to(Mappers.getMapperClass(RepositoryImportDtoToRepositoryImportParametersMapper.class)); bind(QueryResultMapper.class).to(Mappers.getMapperClass(QueryResultMapper.class)); + bind(SearchableTypeMapper.class).to(Mappers.getMapperClass(SearchableTypeMapper.class)); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 2a410fb764..e3a8c86efc 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -1128,6 +1128,7 @@ class ResourceLinks { public String query(String type) { return searchLinkBuilder.method("query").parameters(type).href(); } + public String searchableTypes() { return searchLinkBuilder.method("searchableTypes").parameters().href(); } } public InitialAdminAccountLinks initialAdminAccount() { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchResource.java index d848f8b743..6f9fc10a42 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchResource.java @@ -42,7 +42,9 @@ import javax.ws.rs.BeanParam; import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.Produces; +import java.util.Collection; import java.util.Collections; +import java.util.stream.Collectors; @Path(SearchResource.PATH) @OpenAPIDefinition(tags = { @@ -53,12 +55,14 @@ public class SearchResource { static final String PATH = "v2/search"; private final SearchEngine engine; - private final QueryResultMapper mapper; + private final QueryResultMapper queryResultMapper; + private final SearchableTypeMapper searchableTypeMapper; @Inject - public SearchResource(SearchEngine engine, QueryResultMapper mapper) { + public SearchResource(SearchEngine engine, QueryResultMapper mapper, SearchableTypeMapper searchableTypeMapper) { this.engine = engine; - this.mapper = mapper; + this.queryResultMapper = mapper; + this.searchableTypeMapper = searchableTypeMapper; } @GET @@ -110,6 +114,34 @@ public class SearchResource { return search(params); } + @GET + @Path("searchableTypes") + @Produces(VndMediaType.SEARCHABLE_TYPE_COLLECTION) + @Operation( + summary = "Searchable types", + description = "Returns a collection of all searchable types.", + tags = "Search", + operationId = "searchable_types" + ) + @ApiResponse( + responseCode = "200", + description = "success", + content = @Content( + mediaType = VndMediaType.SEARCHABLE_TYPE_COLLECTION + ) + ) + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + public Collection searchableTypes() { + return engine.getSearchableTypes().stream().map(searchableTypeMapper::map).collect(Collectors.toList()); + } + private QueryResultDto search(SearchParameters params) { QueryResult result = engine.forType(params.getType()) .search() @@ -117,7 +149,7 @@ public class SearchResource { .limit(params.getPageSize()) .execute(params.getQuery()); - return mapper.map(params, result); + return queryResultMapper.map(params, result); } private QueryResultDto count(SearchParameters params) { @@ -125,7 +157,7 @@ public class SearchResource { .search() .count(params.getQuery()); - return mapper.map(params, new QueryResult(result.getTotalHits(), result.getType(), Collections.emptyList())); + return queryResultMapper.map(params, new QueryResult(result.getTotalHits(), result.getType(), Collections.emptyList())); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchableFieldDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchableFieldDto.java new file mode 100644 index 0000000000..60939119b1 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchableFieldDto.java @@ -0,0 +1,32 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package sonia.scm.api.v2.resources; + +import lombok.Data; + +@Data +public class SearchableFieldDto { + private String name; + private String type; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchableTypeDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchableTypeDto.java new file mode 100644 index 0000000000..321eb9ca09 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchableTypeDto.java @@ -0,0 +1,40 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.HalRepresentation; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Collection; + +@Getter +@Setter +@NoArgsConstructor +@SuppressWarnings("java:S2160") // we need no equals for dtos +public class SearchableTypeDto extends HalRepresentation { + private String name; + private Collection fields; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchableTypeMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchableTypeMapper.java new file mode 100644 index 0000000000..392e2ee100 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SearchableTypeMapper.java @@ -0,0 +1,53 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.api.v2.resources; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import sonia.scm.search.SearchableField; +import sonia.scm.search.SearchableType; +import sonia.scm.search.TypeCheck; + +@Mapper +public abstract class SearchableTypeMapper { + + @Mapping(target = "attributes", ignore = true) + public abstract SearchableTypeDto map(SearchableType searchableType); + + public abstract SearchableFieldDto map(SearchableField searchableField); + + public String map(Class type) { + if (TypeCheck.isString(type)) { + return "string"; + } else if (TypeCheck.isInstant(type) || TypeCheck.isNumber(type)) { + return "number"; + } else if (TypeCheck.isBoolean(type)) { + return "boolean"; + } else { + return "unknown"; + } + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/search/AnalyzerFactory.java b/scm-webapp/src/main/java/sonia/scm/search/AnalyzerFactory.java index 88e5834bd7..7b5e5f6c24 100644 --- a/scm-webapp/src/main/java/sonia/scm/search/AnalyzerFactory.java +++ b/scm-webapp/src/main/java/sonia/scm/search/AnalyzerFactory.java @@ -66,14 +66,14 @@ public class AnalyzerFactory { Analyzer defaultAnalyzer = create(options); Map analyzerMap = new HashMap<>(); - for (SearchableField field : type.getFields()) { + for (LuceneSearchableField field : type.getFields()) { addFieldAnalyzer(analyzerMap, field); } return new PerFieldAnalyzerWrapper(defaultAnalyzer, analyzerMap); } - private void addFieldAnalyzer(Map analyzerMap, SearchableField field) { + private void addFieldAnalyzer(Map analyzerMap, LuceneSearchableField field) { if (field.getAnalyzer() != Indexed.Analyzer.DEFAULT) { analyzerMap.put(field.getName(), new NonNaturalLanguageAnalyzer()); } diff --git a/scm-webapp/src/main/java/sonia/scm/search/SearchableField.java b/scm-webapp/src/main/java/sonia/scm/search/LuceneSearchableField.java similarity index 95% rename from scm-webapp/src/main/java/sonia/scm/search/SearchableField.java rename to scm-webapp/src/main/java/sonia/scm/search/LuceneSearchableField.java index ee824a370e..c83038a197 100644 --- a/scm-webapp/src/main/java/sonia/scm/search/SearchableField.java +++ b/scm-webapp/src/main/java/sonia/scm/search/LuceneSearchableField.java @@ -32,7 +32,7 @@ import org.apache.lucene.queryparser.flexible.standard.config.PointsConfig; import java.lang.reflect.Field; @Getter -class SearchableField { +class LuceneSearchableField implements SearchableField { private final String name; private final Class type; @@ -43,7 +43,7 @@ class SearchableField { private final PointsConfig pointsConfig; private final Indexed.Analyzer analyzer; - SearchableField(Field field, Indexed indexed) { + LuceneSearchableField(Field field, Indexed indexed) { this.name = name(field, indexed); this.type = field.getType(); this.valueExtractor = ValueExtractors.create(name, type); @@ -65,4 +65,5 @@ class SearchableField { } return field.getName(); } + } diff --git a/scm-webapp/src/main/java/sonia/scm/search/LuceneSearchableType.java b/scm-webapp/src/main/java/sonia/scm/search/LuceneSearchableType.java index 73b2a93c55..de31bb4bde 100644 --- a/scm-webapp/src/main/java/sonia/scm/search/LuceneSearchableType.java +++ b/scm-webapp/src/main/java/sonia/scm/search/LuceneSearchableType.java @@ -28,6 +28,7 @@ import com.google.common.base.Strings; import lombok.Value; import org.apache.lucene.queryparser.flexible.standard.config.PointsConfig; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -42,13 +43,13 @@ public class LuceneSearchableType implements SearchableType { Class type; String name; String permission; - List fields; + List fields; String[] fieldNames; Map boosts; Map pointsConfig; TypeConverter typeConverter; - - LuceneSearchableType(Class type, IndexedType annotation, List fields) { + + public LuceneSearchableType(Class type, IndexedType annotation, List fields) { this.type = type; this.name = name(type, annotation); this.permission = Strings.emptyToNull(annotation.permission()); @@ -72,16 +73,16 @@ public class LuceneSearchableType implements SearchableType { return nameFromAnnotation; } - private String[] fieldNames(List fields) { + private String[] fieldNames(List fields) { return fields.stream() - .filter(SearchableField::isDefaultQuery) - .map(SearchableField::getName) + .filter(LuceneSearchableField::isDefaultQuery) + .map(LuceneSearchableField::getName) .toArray(String[]::new); } - private Map boosts(List fields) { + private Map boosts(List fields) { Map map = new HashMap<>(); - for (SearchableField field : fields) { + for (LuceneSearchableField field : fields) { if (field.isDefaultQuery() && field.getBoost() != DEFAULT_BOOST) { map.put(field.getName(), field.getBoost()); } @@ -89,9 +90,9 @@ public class LuceneSearchableType implements SearchableType { return Collections.unmodifiableMap(map); } - private Map pointsConfig(List fields) { + private Map pointsConfig(List fields) { Map map = new HashMap<>(); - for (SearchableField field : fields) { + for (LuceneSearchableField field : fields) { PointsConfig config = field.getPointsConfig(); if (config != null) { map.put(field.getName(), config); @@ -99,4 +100,8 @@ public class LuceneSearchableType implements SearchableType { } return Collections.unmodifiableMap(map); } + + public Collection getFields() { + return Collections.unmodifiableCollection(fields); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/search/QueryResultFactory.java b/scm-webapp/src/main/java/sonia/scm/search/QueryResultFactory.java index 48de427369..04504f9776 100644 --- a/scm-webapp/src/main/java/sonia/scm/search/QueryResultFactory.java +++ b/scm-webapp/src/main/java/sonia/scm/search/QueryResultFactory.java @@ -77,13 +77,13 @@ public class QueryResultFactory { private Hit createHit(ScoreDoc scoreDoc) throws IOException, InvalidTokenOffsetsException { Document document = searcher.doc(scoreDoc.doc); Map fields = new HashMap<>(); - for (SearchableField field : searchableType.getFields()) { + for (LuceneSearchableField field : searchableType.getFields()) { field(document, field).ifPresent(f -> fields.put(field.getName(), f)); } return new Hit(document.get(FieldNames.ID), document.get(FieldNames.REPOSITORY), scoreDoc.score, fields); } - private Optional field(Document document, SearchableField field) throws IOException, InvalidTokenOffsetsException { + private Optional field(Document document, LuceneSearchableField field) throws IOException, InvalidTokenOffsetsException { Object value = field.value(document); if (value != null) { if (field.isHighlighted()) { @@ -97,7 +97,7 @@ public class QueryResultFactory { return empty(); } - private String[] createFragments(SearchableField field, String value) throws InvalidTokenOffsetsException, IOException { + private String[] createFragments(LuceneSearchableField field, String value) throws InvalidTokenOffsetsException, IOException { return highlighter.getBestFragments(analyzer, field.getName(), value, 5); } diff --git a/scm-webapp/src/main/java/sonia/scm/search/SearchableTypes.java b/scm-webapp/src/main/java/sonia/scm/search/SearchableTypes.java index cd06d6b6e6..e9329b30ed 100644 --- a/scm-webapp/src/main/java/sonia/scm/search/SearchableTypes.java +++ b/scm-webapp/src/main/java/sonia/scm/search/SearchableTypes.java @@ -34,7 +34,7 @@ final class SearchableTypes { } static LuceneSearchableType create(Class type) { - List fields = new ArrayList<>(); + List fields = new ArrayList<>(); IndexedType annotation = type.getAnnotation(IndexedType.class); if (annotation == null) { throw new IllegalArgumentException( @@ -45,7 +45,7 @@ final class SearchableTypes { return new LuceneSearchableType(type, annotation, fields); } - private static void collectFields(Class type, List fields) { + private static void collectFields(Class type, List fields) { Class parent = type.getSuperclass(); if (parent != null) { collectFields(parent, fields); @@ -53,7 +53,7 @@ final class SearchableTypes { for (Field field : type.getDeclaredFields()) { Indexed indexed = field.getAnnotation(Indexed.class); if (indexed != null) { - fields.add(new SearchableField(field, indexed)); + fields.add(new LuceneSearchableField(field, indexed)); } } } diff --git a/scm-webapp/src/main/java/sonia/scm/search/TypeCheck.java b/scm-webapp/src/main/java/sonia/scm/search/TypeCheck.java index 79bd68bb68..5da034ab46 100644 --- a/scm-webapp/src/main/java/sonia/scm/search/TypeCheck.java +++ b/scm-webapp/src/main/java/sonia/scm/search/TypeCheck.java @@ -26,7 +26,7 @@ package sonia.scm.search; import java.time.Instant; -final class TypeCheck { +public final class TypeCheck { private TypeCheck() { } @@ -50,4 +50,6 @@ final class TypeCheck { public static boolean isString(Class type) { return type == String.class; } + + public static boolean isNumber(Class type) { return isLong(type) || isInteger(type); } } diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index 9e4c3a3ae0..0858c7affc 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -438,15 +438,102 @@ "types": { "repository": { "subtitle": "Repository", - "navItem": "Repositories" + "navItem": "Repositories", + "title": "Repositories", + "fields": { + "name": { + "name": "Name", + "exampleValue": "scm-manager" + }, + "namespace": { + "name": "Namespace", + "exampleValue": "cloudogu" + }, + "type": { + "name": "Typ", + "exampleValue": "git, hg oder svn" + }, + "description": { + "name": "Beschreibung", + "exampleValue": "Dieses Repository enthält den Quellcode des SCM-Managers." + }, + "creationDate": { + "name": "Erstellungsdatum", + "exampleValue": "1628077286", + "hints": "Zeitstempel der Erstellung in ms seit dem 01.01.1970. Bereichssuche möglich." + }, + "lastModified": { + "name": "Zuletzt geändert", + "exampleValue": "1623057235", + "hints": "Zeitstempel der letzten Änderung in ms seit dem 01.01.1970. Bereichssuche möglich." + } + }, + "examples": [ + { + "description": "...einem Repository mit dem Namen ultimate-Repository oder ultimate_Repository. Sie sind nicht sicher, welches Zeichen die beiden Begriffe verbindet", + "query": "name:ultimate?Repository", + "explanation": "Sucht nach „ultimate“ und „Repository“ verbunden mit einem einzelnen Zeichen." + }, + { + "description": "...alle Repositories, die zwischen dem 01.01.2021 und dem 01.02.2021 im Namespace ultimate geändert wurden.", + "query": "nameSpace:ultimate AND lastModified:[1609459200000 TO 1612137600000]", + "explanation": "Sucht nach Repositories im Namespace „ultimate“, die im gewünschten Zeitraum geändert wurden Der Zeitraum wird über Zeitstemple in Epoch Millisekunden angegeben. Rechnen Sie Zeitstempel mit unserem Converter in Epoch Millisekunden um." + } + ] }, "user": { "subtitle": "Benutzer", - "navItem": "Benutzer" + "navItem": "Benutzer", + "title": "Benutzer", + "fields": { + "name": { + "name": "Name", + "exampleValue": "admin" + }, + "displayName": { + "name": "Anzeigename", + "exampleValue": "Administrator" + }, + "creationDate": { + "name": "Erstellungsdatum", + "exampleValue": "1628077286", + "hints": "Zeitstempel der Erstellung in ms seit dem 01.01.1970. Bereichssuche möglich." + }, + "lastModified": { + "name": "Zuletzt geändert", + "exampleValue": "1623057235", + "hints": "Zeitstempel der letzten Änderung in ms seit dem 01.01.1970. Bereichssuche möglich." + }, + "mail": { + "name": "Mail", + "exampleValue": "admin@hitchhiker.com" + } + } }, "group": { "subtitle": "Gruppe", - "navItem": "Gruppen" + "navItem": "Gruppen", + "title": "Gruppen", + "fields": { + "name": { + "name": "Name", + "exampleValue": "Entwickler" + }, + "description": { + "name": "Beschreibung", + "exampleValue": "Diese Gruppe enthält alle Softwareentwickler." + }, + "creationDate": { + "name": "Erstellungsdatum", + "exampleValue": "1628077286", + "hints": "Zeitstempel der Erstellung in ms seit dem 01.01.1970. Bereichssuche möglich." + }, + "lastModified": { + "name": "Zuletzt geändert", + "exampleValue": "1623057235", + "hints": "Zeitstempel der letzten Änderung in ms seit dem 01.01.1970. Bereichssuche möglich." + } + } } } } diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index 9726404c01..2eab2d6c06 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -382,16 +382,104 @@ "types": { "repository": { "subtitle": "Repository", - "navItem": "Repositories" + "navItem": "Repositories", + "title": "Repositories", + "fields": { + "name": { + "name": "Name", + "exampleValue": "scm-manager" + }, + "namespace": { + "name": "Namespace", + "exampleValue": "cloudogu" + }, + "type": { + "name": "Type", + "exampleValue": "git, hg or svn" + }, + "description": { + "name": "Description", + "exampleValue": "This repository contains the source code for the SCM-Manager." + }, + "creationDate": { + "name": "Creation Date", + "exampleValue": "1628077286", + "hints": "Timestamp of creation in ms since 01-01-1970. Range search possible." + }, + "lastModified": { + "name": "Last Modified", + "exampleValue": "1623057235", + "hints": "Timestamp of last modification in ms since 01-01-1970. Range search possible." + } + }, + "examples": [ + { + "description": "a repository called ultimate-Repository or ultimate_repository. You are not sure about the connecting character.", + "query": "name:ultimate?Repository", + "explanation": "queries \"ultimate\" and \"repository\" with a single character in between in the field \"name\"." + }, + { + "description": "all repositories changed between 01-01-2021 and 01-02-2021 in the Namespace \"ultimate\"", + "query": "nameSpace:ultimate AND lastModified:[1609459200000 TO 1612137600000]", + "explanation": "queries for repositories that live in the namespace \"ultimate\" and were last changed in the chosen period (displayed in Epoch milliseconds). You can convert datetimes to timestamps with our converter at the bottom of this page." + } + ] }, "user": { "subtitle": "User", - "navItem": "Users" + "navItem": "Users", + "title": "Users", + "fields": { + "name": { + "name": "Name", + "exampleValue": "admin" + }, + "displayName": { + "name": "Display Name", + "exampleValue": "Administrator" + }, + "creationDate": { + "name": "Creation Date", + "exampleValue": "1628077286", + "hints": "Timestamp of creation in ms since 01-01-1970. Range search possible." + }, + "lastModified": { + "name": "Last Modified", + "exampleValue": "1623057235", + "hints": "Timestamp of last modification in ms since 01-01-1970. Range search possible." + }, + "mail": { + "name": "Mail", + "exampleValue": "admin@hitchhiker.com" + } + } }, "group": { "subtitle": "Group", - "navItem": "Groups" + "navItem": "Groups", + "title": "Groups", + "fields": { + "name": { + "name": "Name", + "exampleValue": "Developers" + }, + "description": { + "name": "Description", + "exampleValue": "This group contains all software developers." + }, + "creationDate": { + "name": "Creation Date", + "exampleValue": "1628077286", + "hints": "Timestamp of creation in ms since 01-01-1970. Range search possible." + }, + "lastModified": { + "name": "Last Modified", + "exampleValue": "1623057235", + "hints": "Timestamp of last modification in ms since 01-01-1970. Range search possible." + } + } } } } } + diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java index 7083cfa506..35c4167f2f 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java @@ -228,5 +228,6 @@ class IndexDtoGeneratorTest { when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(scmPathInfo)); when(resourceLinks.me()).thenReturn(new ResourceLinks.MeLinks(scmPathInfo, new ResourceLinks.UserLinks(scmPathInfo))); when(resourceLinks.repository()).thenReturn(new ResourceLinks.RepositoryLinks(scmPathInfo)); + when(resourceLinks.search()).thenReturn(new ResourceLinks.SearchLinks(scmPathInfo)); } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java index debbd97cf9..ef2508552e 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchResourceTest.java @@ -28,6 +28,7 @@ import com.fasterxml.jackson.databind.JsonNode; import de.otto.edison.hal.HalRepresentation; import lombok.Getter; import lombok.Setter; +import org.assertj.core.util.Lists; import org.jboss.resteasy.mock.MockHttpRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -45,6 +46,7 @@ import sonia.scm.search.Hit; import sonia.scm.search.QueryCountResult; import sonia.scm.search.QueryResult; import sonia.scm.search.SearchEngine; +import sonia.scm.search.SearchableType; import sonia.scm.web.JsonMockHttpResponse; import sonia.scm.web.RestDispatcher; import sonia.scm.web.VndMediaType; @@ -80,23 +82,47 @@ class SearchResourceTest { @Mock private HalEnricherRegistry enricherRegistry; + @Mock + private SearchableType searchableTypeOne; + + @Mock + private SearchableType searchableTypeTwo; + @BeforeEach void setUpDispatcher() { ScmPathInfoStore scmPathInfoStore = new ScmPathInfoStore(); scmPathInfoStore.set(() -> URI.create("/")); - QueryResultMapper mapper = Mappers.getMapper(QueryResultMapper.class); - mapper.setRepositoryManager(repositoryManager); - mapper.setResourceLinks(new ResourceLinks(scmPathInfoStore)); + QueryResultMapper queryResultMapper = Mappers.getMapper(QueryResultMapper.class); + queryResultMapper.setRepositoryManager(repositoryManager); + queryResultMapper.setResourceLinks(new ResourceLinks(scmPathInfoStore)); - mapper.setRegistry(enricherRegistry); + SearchableTypeMapper searchableTypeMapper = Mappers.getMapper(SearchableTypeMapper.class); + queryResultMapper.setRegistry(enricherRegistry); SearchResource resource = new SearchResource( - searchEngine, mapper + searchEngine, queryResultMapper, searchableTypeMapper ); dispatcher = new RestDispatcher(); dispatcher.addSingletonResource(resource); } + @Test + void shouldReturnSearchableTypes() throws URISyntaxException { + when(searchEngine.getSearchableTypes()).thenReturn(Lists.list(searchableTypeOne, searchableTypeTwo)); + when(searchableTypeOne.getName()).thenReturn("Type One"); + when(searchableTypeTwo.getName()).thenReturn("Type Two"); + + MockHttpRequest request = MockHttpRequest.get("/v2/search/searchableTypes"); + JsonMockHttpResponse response = new JsonMockHttpResponse(); + dispatcher.invoke(request, response); + + JsonNode contentAsJson = response.getContentAsJson(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(contentAsJson.isArray()).isTrue(); + assertThat(contentAsJson.get(0).get("name").asText()).isEqualTo("Type One"); + assertThat(contentAsJson.get(1).get("name").asText()).isEqualTo("Type Two"); + } + @Test void shouldEnrichQueryResult() throws IOException, URISyntaxException { when(enricherRegistry.allByType(QueryResult.class)) @@ -330,7 +356,6 @@ class SearchResourceTest { return response; } - @Getter @Setter public static class SampleEmbedded extends HalRepresentation { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchableTypeMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchableTypeMapperTest.java new file mode 100644 index 0000000000..fe0cfcbc6f --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SearchableTypeMapperTest.java @@ -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. + */ +package sonia.scm.api.v2.resources; + +import com.google.common.collect.ImmutableList; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mapstruct.factory.Mappers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.search.SearchableField; +import sonia.scm.search.SearchableType; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SearchableTypeMapperTest { + + @Mock + private SearchableType searchableType; + + @Mock + private SearchableField searchableField; + + private final SearchableTypeMapper searchableTypeMapper = Mappers.getMapper(SearchableTypeMapper.class); + + @Test + void shouldMapType() { + when(searchableType.getName()).thenReturn("HitchhikerType"); + doReturn(ImmutableList.of(searchableField)).when(searchableType).getFields(); + when(searchableField.getName()).thenReturn("HitchhikerField"); + doReturn(Integer.class).when(searchableField).getType(); + final SearchableTypeDto typeDto = searchableTypeMapper.map(searchableType); + Assertions.assertThat(typeDto.getName()).isEqualTo("HitchhikerType"); + final SearchableFieldDto searchableFieldDto = typeDto.getFields().stream().findFirst().get(); + Assertions.assertThat(searchableFieldDto.getName()).isEqualTo("HitchhikerField"); + Assertions.assertThat(searchableFieldDto.getType()).isEqualTo("number"); + } + +}