Autocomplete for namespaces (#1916)

Changes the "namespace" input in the repository creation form or the "rename repository" dialog to an
autocomplete input. Of course this is only available for the "custom" namespace strategy.
This commit is contained in:
René Pfeuffer
2022-01-11 16:17:57 +01:00
committed by GitHub
parent 01fa96d29c
commit e9f22e89ec
20 changed files with 346 additions and 77 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -26,7 +26,12 @@ Im SCM-Manager können neue Git, Mercurial & Subersion (SVN) Repositories über
Optional kann man das Repository beim Erstellen direkt initialisieren. Damit werden für Git und Mercurial jeweils der Standard-Branch (master bzw. default) angelegt. Außerdem wird ein initialer Commit ausgeführt, der eine README.md erzeugt. Optional kann man das Repository beim Erstellen direkt initialisieren. Damit werden für Git und Mercurial jeweils der Standard-Branch (master bzw. default) angelegt. Außerdem wird ein initialer Commit ausgeführt, der eine README.md erzeugt.
Für Subversion Repositories wird die README.md in einen Ordner `trunk` abgelegt. Für Subversion Repositories wird die README.md in einen Ordner `trunk` abgelegt.
Ist die Namespace-Strategie auf "Benutzerdefiniert" eingestellt, muss noch ein Namespace eingetragen werden. Für den Namespace gelten dieselben Regeln wie für den Namen des Repositories. Darüber hinaus darf ein Namespace nicht nur aus bis zu drei Ziffern (z. B. "123") oder den Wörter "create" und "import" bestehen. Ist die Namespace-Strategie auf "Benutzerdefiniert" eingestellt, muss noch ein Namespace eingetragen werden.
Für den Namespace gelten dieselben Regeln wie für den Namen des Repositories. Darüber hinaus darf ein Namespace
nicht nur aus bis zu drei Ziffern (z. B. "123") oder den Wörter "create" und "import" bestehen.
Bei der Eingabe werden nach den ersten Zeichen bereits bestehende passende Werte vorgeschlagen, sodass diese leichter
übernommen werden können. Ein neuer Namespace muss explizit mit dem entsprechenden Eintrag in der Vorschlagsliste
neu erstellt werden.
![Repository erstellen](assets/create-repository.png) ![Repository erstellen](assets/create-repository.png)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -24,7 +24,10 @@ In SCM-Manager new Git, Mercurial & Subversion (SVN) repositories can be created
Optionally, repositories can be initialized during the creation. That creates a standard branch (master or default) for Git and Mercurial repositories. Optionally, repositories can be initialized during the creation. That creates a standard branch (master or default) for Git and Mercurial repositories.
Additionally, it performs a commit that creates a README.md. For Subversion repositories the README.md will be created in a directory named `trunk`. Additionally, it performs a commit that creates a README.md. For Subversion repositories the README.md will be created in a directory named `trunk`.
If the namespace strategy is set to custom, the namespace field is also mandatory. The namespace must heed the same restrictions as the name. Additionally, namespaces that only consist of three digits, or the words "create" and "import" are not valid. If the namespace strategy is set to custom, the namespace field is also mandatory. The namespace must heed the same
restrictions as the name. Additionally, namespaces that only consist of three digits, or the words "create"
and "import" are not valid. After typing the first characters, existing matching namespaces are suggested which can
be chosen. To create a new namespace, this has to be chosen from the drop down explicitly.
![Create Repository](assets/create-repository.png) ![Create Repository](assets/create-repository.png)

View File

@@ -0,0 +1,2 @@
- type: changed
description: Autocompletion for namespaces ([#1916](https://github.com/scm-manager/scm-manager/pull/1916))

View File

@@ -36,6 +36,7 @@ export * from "./users";
export * from "./suggestions"; export * from "./suggestions";
export * from "./userSuggestions"; export * from "./userSuggestions";
export * from "./groupSuggestions"; export * from "./groupSuggestions";
export * from "./namespaceSuggestions";
export * from "./repositories"; export * from "./repositories";
export * from "./namespaces"; export * from "./namespaces";
export * from "./branches"; export * from "./branches";

View File

@@ -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 { Link } from "@scm-manager/ui-types";
import { useIndexLinks } from "./base";
import { useSuggestions } from "./suggestions";
export const useNamespaceSuggestions = () => {
const indexLinks = useIndexLinks();
const autocompleteLink = (indexLinks.autocomplete as Link[]).find((i) => i.name === "namespaces");
return useSuggestions(autocompleteLink?.href);
};

View File

@@ -37,8 +37,11 @@ type Props = {
placeholder: string; placeholder: string;
loadingMessage: string; loadingMessage: string;
noOptionsMessage: string; noOptionsMessage: string;
errorMessage?: string;
informationMessage?: string;
creatable?: boolean; creatable?: boolean;
className?: string; className?: string;
disabled?: boolean;
}; };
type State = {}; type State = {};
@@ -78,8 +81,11 @@ class Autocomplete extends React.Component<Props, State> {
loadingMessage, loadingMessage,
noOptionsMessage, noOptionsMessage,
loadSuggestions, loadSuggestions,
errorMessage,
informationMessage,
creatable, creatable,
className className,
disabled
} = this.props; } = this.props;
return ( return (
@@ -108,6 +114,7 @@ class Autocomplete extends React.Component<Props, State> {
}); });
}} }}
aria-label={helpText || label} aria-label={helpText || label}
isDisabled={disabled}
/> />
) : ( ) : (
<Async <Async
@@ -121,9 +128,12 @@ class Autocomplete extends React.Component<Props, State> {
loadingMessage={() => loadingMessage} loadingMessage={() => loadingMessage}
noOptionsMessage={() => noOptionsMessage} noOptionsMessage={() => noOptionsMessage}
aria-label={helpText || label} aria-label={helpText || label}
isDisabled={disabled}
/> />
)} )}
</div> </div>
{errorMessage ? <p className="help is-danger">{errorMessage}</p> : null}
{informationMessage ? <p className="help is-info">{informationMessage}</p> : null}
</div> </div>
); );
} }

View File

@@ -19219,6 +19219,10 @@ exports[`Storyshots Modal/Modal With form elements 1`] = `null`;
exports[`Storyshots Modal/Modal With long tooltips 1`] = `null`; exports[`Storyshots Modal/Modal With long tooltips 1`] = `null`;
exports[`Storyshots Modal/Modal With overflow 1`] = `null`;
exports[`Storyshots Modal/Modal With overflow and footer 1`] = `null`;
exports[`Storyshots Notification Closeable 1`] = ` exports[`Storyshots Notification Closeable 1`] = `
<div <div
className="Notificationstories__Wrapper-sc-8fx7tr-0 gJPbVB" className="Notificationstories__Wrapper-sc-8fx7tr-0 gJPbVB"

View File

@@ -32,6 +32,8 @@ import ExternalLink from "../navigation/ExternalLink";
import { Radio, Textarea, InputField } from "../forms"; import { Radio, Textarea, InputField } from "../forms";
import { ButtonGroup, Button } from "../buttons"; import { ButtonGroup, Button } from "../buttons";
import Notification from "../Notification"; import Notification from "../Notification";
import { Autocomplete } from "../index";
import { SelectValue } from "@scm-manager/ui-types";
const TopAndBottomMargin = styled.div` const TopAndBottomMargin = styled.div`
margin: 0.75rem 0; // only for aesthetic reasons margin: 0.75rem 0; // only for aesthetic reasons
@@ -51,7 +53,9 @@ const text = `Mind-paralyzing change needed improbability vortex machine sorts s
ordinary mob.`; ordinary mob.`;
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
const doNothing = () => {}; const doNothing = () => {
// nothing to do
};
const withFormElementsBody = ( const withFormElementsBody = (
<> <>
<RadioList> <RadioList>
@@ -71,8 +75,21 @@ const withFormElementsFooter = (
</ButtonGroup> </ButtonGroup>
); );
const loadSuggestions: (p: string) => Promise<SelectValue[]> = () =>
new Promise(resolve => {
setTimeout(() => {
resolve([
{ value: { id: "trillian", displayName: "Tricia McMillan" }, label: "Tricia McMillan" },
{ value: { id: "zaphod", displayName: "Zaphod Beeblebrox" }, label: "Zaphod Beeblebrox" },
{ value: { id: "ford", displayName: "Ford Prefect" }, label: "Ford Prefect" },
{ value: { id: "dent", displayName: "Arthur Dent" }, label: "Arthur Dent" },
{ value: { id: "marvin", displayName: "Marvin" }, label: "Marvin the Paranoid Android " }
]);
});
});
storiesOf("Modal/Modal", module) storiesOf("Modal/Modal", module)
.addDecorator((story) => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>) .addDecorator(story => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
.add("Default", () => ( .add("Default", () => (
<NonCloseableModal> <NonCloseableModal>
<p>{text}</p> <p>{text}</p>
@@ -104,7 +121,7 @@ storiesOf("Modal/Modal", module)
This story exists because we had a problem, that long tooltips causes a horizontal scrollbar on the modal. This story exists because we had a problem, that long tooltips causes a horizontal scrollbar on the modal.
</Notification> </Notification>
<hr /> <hr />
<p>The following elements will have a verly long help text, which has triggered the scrollbar in the past.</p> <p>The following elements will have a very long help text, which has triggered the scrollbar in the past.</p>
<hr /> <hr />
<TopAndBottomMargin> <TopAndBottomMargin>
<Checkbox label="Checkbox" checked={true} helpText={text} /> <Checkbox label="Checkbox" checked={true} helpText={text} />
@@ -211,10 +228,47 @@ storiesOf("Modal/Modal", module)
</p> </p>
</div> </div>
</NonCloseableModal> </NonCloseableModal>
)); ))
.add("With overflow", () => {
return (
<NonCloseableModal overflowVisible={true}>
<h1 className="title">Please Select</h1>
<Autocomplete
valueSelected={() => {
// nothing to do
}}
loadSuggestions={loadSuggestions}
/>
</NonCloseableModal>
);
})
.add("With overflow and footer", () => {
return (
<NonCloseableModal overflowVisible={true} footer={withFormElementsFooter}>
<h1 className="title">Please Select</h1>
<Autocomplete
valueSelected={() => {
// nothing to do
}}
loadSuggestions={loadSuggestions}
/>
</NonCloseableModal>
);
});
const NonCloseableModal: FC = ({ children }) => { type NonCloseableModalProps = { overflowVisible?: boolean; footer?: any };
return <Modal body={children} closeFunction={doNothing} active={true} title={"Hitchhiker Modal"} />;
const NonCloseableModal: FC<NonCloseableModalProps> = ({ overflowVisible, footer, children }) => {
return (
<Modal
body={children}
closeFunction={doNothing}
active={true}
title={"Hitchhiker Modal"}
overflowVisible={overflowVisible}
footer={footer}
/>
);
}; };
const CloseableModal: FC = ({ children }) => { const CloseableModal: FC = ({ children }) => {

View File

@@ -42,10 +42,24 @@ type Props = {
headColor?: string; headColor?: string;
headTextColor?: string; headTextColor?: string;
size?: ModalSize; size?: ModalSize;
overflowVisible?: boolean;
}; };
const SizedModal = styled.div<{ size?: ModalSize }>` const SizedModal = styled.div<{ size?: ModalSize; overflow: string }>`
width: ${props => (props.size ? `${modalSizes[props.size]}%` : "640px")}; width: ${props => (props.size ? `${modalSizes[props.size]}%` : "640px")};
overflow: ${props => props.overflow};
`;
const DivWithOptionalOverflow = styled.div<{ overflow: string; borderBottomRadius: string }>`
overflow: ${props => props.overflow};
border-bottom-left-radius: ${props => props.borderBottomRadius};
border-bottom-right-radius: ${props => props.borderBottomRadius};
`;
const SectionWithOptionalOverflow = styled.section<{ overflow: string; borderBottomRadius: string }>`
overflow: ${props => props.overflow};
border-bottom-left-radius: ${props => props.borderBottomRadius};
border-bottom-right-radius: ${props => props.borderBottomRadius};
`; `;
export const Modal: FC<Props> = ({ export const Modal: FC<Props> = ({
@@ -57,7 +71,8 @@ export const Modal: FC<Props> = ({
className, className,
headColor = "secondary-less", headColor = "secondary-less",
headTextColor = "secondary-most", headTextColor = "secondary-most",
size size,
overflowVisible
}) => { }) => {
const portalRootElement = usePortalRootElement("modalsRoot"); const portalRootElement = usePortalRootElement("modalsRoot");
const initialFocusRef = useRef(null); const initialFocusRef = useRef(null);
@@ -85,18 +100,29 @@ export const Modal: FC<Props> = ({
} }
}; };
const overflowAttribute = overflowVisible ? "visible" : "auto";
const borderBottomRadiusAttribute = overflowVisible && !footer ? "inherit" : "unset";
const modalElement = ( const modalElement = (
<div className={classNames("modal", className, isActive)} ref={trapRef} onKeyDown={onKeyDown}> <DivWithOptionalOverflow
className={classNames("modal", className, isActive)}
ref={trapRef}
onKeyDown={onKeyDown}
overflow={overflowAttribute}
borderBottomRadius={borderBottomRadiusAttribute}
>
<div className="modal-background" onClick={closeFunction} /> <div className="modal-background" onClick={closeFunction} />
<SizedModal className="modal-card" size={size}> <SizedModal className="modal-card" size={size} overflow={overflowAttribute}>
<header className={classNames("modal-card-head", `has-background-${headColor}`)}> <header className={classNames("modal-card-head", `has-background-${headColor}`)}>
<h2 className={`modal-card-title m-0 has-text-${headTextColor}`}>{title}</h2> <h2 className={`modal-card-title m-0 has-text-${headTextColor}`}>{title}</h2>
<button className="delete" aria-label="close" onClick={closeFunction} ref={initialFocusRef} autoFocus /> <button className="delete" aria-label="close" onClick={closeFunction} ref={initialFocusRef} autoFocus />
</header> </header>
<section className="modal-card-body">{body}</section> <SectionWithOptionalOverflow className="modal-card-body" overflow={overflowAttribute} borderBottomRadius={borderBottomRadiusAttribute}>
{body}
</SectionWithOptionalOverflow>
{showFooter} {showFooter}
</SizedModal> </SizedModal>
</div> </DivWithOptionalOverflow>
); );
return ReactDOM.createPortal(modalElement, portalRootElement); return ReactDOM.createPortal(modalElement, portalRootElement);

View File

@@ -24,7 +24,7 @@
export type AutocompleteObject = { export type AutocompleteObject = {
id: string; id: string;
displayName: string; displayName?: string;
}; };
export type SelectValue = { export type SelectValue = {

View File

@@ -23,12 +23,12 @@
*/ */
import React, { FC, useEffect, useState } from "react"; import React, { FC, useEffect, useState } from "react";
import { CUSTOM_NAMESPACE_STRATEGY, RepositoryCreation } from "@scm-manager/ui-types"; import { CUSTOM_NAMESPACE_STRATEGY, RepositoryCreation} from "@scm-manager/ui-types";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { InputField } from "@scm-manager/ui-components"; import { InputField } from "@scm-manager/ui-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import * as validator from "./form/repositoryValidation"; import * as validator from "./form/repositoryValidation";
import { useNamespaceStrategies } from "@scm-manager/ui-api"; import { useNamespaceStrategies} from "@scm-manager/ui-api";
import NamespaceInput from "./NamespaceInput";
type Props = { type Props = {
repository: RepositoryCreation; repository: RepositoryCreation;
@@ -82,30 +82,6 @@ const NamespaceAndNameFields: FC<Props> = ({ repository, onChange, setValid, dis
onChange({ ...repository, name }); onChange({ ...repository, name });
}; };
const renderNamespaceField = () => {
let informationMessage = undefined;
if (repository?.namespace?.indexOf(" ") > 0) {
informationMessage = t("validation.namespaceSpaceWarningText");
}
const props = {
label: t("repository.namespace"),
helpText: t("help.namespaceHelpText"),
value: repository ? repository.namespace : "",
onChange: handleNamespaceChange,
errorMessage: t("validation.namespace-invalid"),
validationError: namespaceValidationError,
disabled: disabled,
informationMessage
};
if (namespaceStrategy === CUSTOM_NAMESPACE_STRATEGY) {
return <InputField {...props} />;
}
return <ExtensionPoint name="repos.create.namespace" props={props} renderAll={false} />;
};
// not yet loaded // not yet loaded
if (namespaceStrategy === "") { if (namespaceStrategy === "") {
return null; return null;
@@ -113,7 +89,13 @@ const NamespaceAndNameFields: FC<Props> = ({ repository, onChange, setValid, dis
return ( return (
<> <>
{renderNamespaceField()} <NamespaceInput
namespace={repository?.namespace}
handleNamespaceChange={handleNamespaceChange}
namespaceStrategy={namespaceStrategy}
namespaceValidationError={namespaceValidationError}
disabled={disabled}
/>
<InputField <InputField
label={t("repository.name")} label={t("repository.name")}
onChange={handleNameChange} onChange={handleNameChange}

View File

@@ -0,0 +1,83 @@
/*
* 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 { CUSTOM_NAMESPACE_STRATEGY } from "@scm-manager/ui-types";
import { Autocomplete } from "@scm-manager/ui-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { useTranslation } from "react-i18next";
import { useNamespaceSuggestions } from "@scm-manager/ui-api";
type Props = {
namespace: string;
handleNamespaceChange: (namespace: string) => void;
namespaceStrategy?: string;
namespaceValidationError?: boolean;
disabled?: boolean;
};
const NamespaceInput: FC<Props> = ({
namespace,
handleNamespaceChange,
namespaceStrategy,
namespaceValidationError,
disabled
}) => {
const [t] = useTranslation("repos");
const loadNamespaceSuggestions = useNamespaceSuggestions();
let informationMessage = undefined;
if (namespace?.indexOf(" ") > 0) {
informationMessage = t("validation.namespaceSpaceWarningText");
}
const repositorySelectValue = namespace ? { value: { id: namespace, displayName: "" }, label: namespace } : undefined;
const props = {
label: t("repository.namespace"),
helpText: t("help.namespaceHelpText"),
value: namespace,
onChange: handleNamespaceChange,
errorMessage: namespaceValidationError ? t("validation.namespace-invalid") : "",
informationMessage: informationMessage,
validationError: namespaceValidationError,
disabled: disabled
};
if (namespaceStrategy === CUSTOM_NAMESPACE_STRATEGY) {
return (
<Autocomplete
{...props}
loadSuggestions={loadNamespaceSuggestions}
value={repositorySelectValue}
valueSelected={namespaceValue => handleNamespaceChange(namespaceValue.value.id)}
placeholder={""}
creatable={true}
/>
);
}
return <ExtensionPoint name="repos.create.namespace" props={props} renderAll={false} />;
};
export default NamespaceInput;

View File

@@ -23,13 +23,18 @@
*/ */
import React, { FC, useState } from "react"; import React, { FC, useState } from "react";
import { CUSTOM_NAMESPACE_STRATEGY, Repository } from "@scm-manager/ui-types"; import { Repository } from "@scm-manager/ui-types";
import { Button, ButtonGroup, ErrorNotification, InputField, Level, Loading, Modal } from "@scm-manager/ui-components"; import { Button, ButtonGroup, ErrorNotification, InputField, Level, Loading, Modal } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom"; import { Redirect } from "react-router-dom";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import * as validator from "../components/form/repositoryValidation"; import * as validator from "../components/form/repositoryValidation";
import { useNamespaceStrategies, useRenameRepository } from "@scm-manager/ui-api"; import { useNamespaceStrategies, useRenameRepository } from "@scm-manager/ui-api";
import NamespaceInput from "../components/NamespaceInput";
import styled from "styled-components";
const WithOverflow = styled.div`
overflow: visible;
`;
type Props = { type Props = {
repository: Repository; repository: Repository;
@@ -76,25 +81,8 @@ const RenameRepository: FC<Props> = ({ repository }) => {
setName(name); setName(name);
}; };
const renderNamespaceField = () => {
const props = {
label: t("repository.namespace"),
helpText: t("help.namespaceHelpText"),
value: namespace,
onChange: handleNamespaceChange,
errorMessage: t("validation.namespace-invalid"),
validationError: namespaceValidationError
};
if (namespaceStrategies?.current === CUSTOM_NAMESPACE_STRATEGY) {
return <InputField {...props} />;
}
return <ExtensionPoint name="repos.create.namespace" props={props} renderAll={false} />;
};
const modalBody = ( const modalBody = (
<div> <WithOverflow>
{renamingError ? <ErrorNotification error={renamingError} /> : null} {renamingError ? <ErrorNotification error={renamingError} /> : null}
<InputField <InputField
label={t("renameRepo.modal.label.repoName")} label={t("renameRepo.modal.label.repoName")}
@@ -105,8 +93,13 @@ const RenameRepository: FC<Props> = ({ repository }) => {
value={name} value={name}
onChange={handleNameChange} onChange={handleNameChange}
/> />
{renderNamespaceField()} <NamespaceInput
</div> namespace={namespace}
handleNamespaceChange={handleNamespaceChange}
namespaceValidationError={namespaceValidationError}
namespaceStrategy={namespaceStrategies?.current}
/>
</WithOverflow>
); );
const footer = ( const footer = (
@@ -137,6 +130,7 @@ const RenameRepository: FC<Props> = ({ repository }) => {
footer={footer} footer={footer}
body={modalBody} body={modalBody}
closeFunction={() => setShowModal(false)} closeFunction={() => setShowModal(false)}
overflowVisible={true}
/> />
); );

View File

@@ -32,7 +32,6 @@ import java.util.Optional;
import java.util.function.Function; import java.util.function.Function;
import static java.util.Optional.ofNullable; import static java.util.Optional.ofNullable;
import static sonia.scm.group.DisplayGroup.from;
public abstract class GenericDisplayManager<D, T extends ReducedModelObject> implements DisplayManager<T> { public abstract class GenericDisplayManager<D, T extends ReducedModelObject> implements DisplayManager<T> {

View File

@@ -32,6 +32,9 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import sonia.scm.ReducedModelObject; import sonia.scm.ReducedModelObject;
import sonia.scm.group.GroupDisplayManager; import sonia.scm.group.GroupDisplayManager;
import sonia.scm.repository.NamespaceManager;
import sonia.scm.search.SearchRequest;
import sonia.scm.search.SearchUtil;
import sonia.scm.user.UserDisplayManager; import sonia.scm.user.UserDisplayManager;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -46,6 +49,8 @@ import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static sonia.scm.DisplayManager.DEFAULT_LIMIT;
@OpenAPIDefinition(tags = { @OpenAPIDefinition(tags = {
@Tag(name = "Autocomplete", description = "Autocomplete related endpoints") @Tag(name = "Autocomplete", description = "Autocomplete related endpoints")
}) })
@@ -58,16 +63,18 @@ public class AutoCompleteResource {
public static final String INVALID_PARAMETER_LENGTH = "Invalid parameter length."; public static final String INVALID_PARAMETER_LENGTH = "Invalid parameter length.";
private ReducedObjectModelToDtoMapper mapper; private final ReducedObjectModelToDtoMapper mapper;
private UserDisplayManager userDisplayManager; private final UserDisplayManager userDisplayManager;
private GroupDisplayManager groupDisplayManager; private final GroupDisplayManager groupDisplayManager;
private final NamespaceManager namespaceManager;
@Inject @Inject
public AutoCompleteResource(ReducedObjectModelToDtoMapper mapper, UserDisplayManager userDisplayManager, GroupDisplayManager groupDisplayManager) { public AutoCompleteResource(ReducedObjectModelToDtoMapper mapper, UserDisplayManager userDisplayManager, GroupDisplayManager groupDisplayManager, NamespaceManager namespaceManager) {
this.mapper = mapper; this.mapper = mapper;
this.userDisplayManager = userDisplayManager; this.userDisplayManager = userDisplayManager;
this.groupDisplayManager = groupDisplayManager; this.groupDisplayManager = groupDisplayManager;
this.namespaceManager = namespaceManager;
} }
@GET @GET
@@ -123,12 +130,50 @@ public class AutoCompleteResource {
return map(groupDisplayManager.autocomplete(filter)); return map(groupDisplayManager.autocomplete(filter));
} }
@GET
@Path("namespaces")
@Produces(VndMediaType.AUTOCOMPLETE)
@Operation(summary = "Search namespaces", description = "Returns matching namespaces.", tags = "Autocomplete")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.AUTOCOMPLETE,
schema = @Schema(implementation = ReducedObjectModelDto.class)
))
@ApiResponse(responseCode = "400", description = "if the searched string contains less than 2 characters")
@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 List<ReducedObjectModelDto> searchNamespace(@NotEmpty(message = PARAMETER_IS_REQUIRED) @Size(min = MIN_SEARCHED_CHARS, message = INVALID_PARAMETER_LENGTH) @QueryParam("q") String filter) {
SearchRequest searchRequest = new SearchRequest(filter, true, DEFAULT_LIMIT);
return map(SearchUtil.search(
searchRequest,
namespaceManager.getAll(),
namespace -> SearchUtil.matchesOne(searchRequest, namespace.getNamespace()) ? new ReducedModelObject() {
@Override
public String getId() {
return namespace.getId();
}
@Override
public String getDisplayName() {
return null;
}
} : null
));
}
private <T extends ReducedModelObject> List<ReducedObjectModelDto> map(Collection<T> autocomplete) { private <T extends ReducedModelObject> List<ReducedObjectModelDto> map(Collection<T> autocomplete) {
return autocomplete return autocomplete
.stream() .stream()
.map(mapper::map) .map(mapper::map)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
} }

View File

@@ -121,6 +121,7 @@ public class IndexDtoGenerator extends HalAppenderMapper {
if (GroupPermissions.autocomplete().isPermitted()) { if (GroupPermissions.autocomplete().isPermitted()) {
autoCompleteLinks.add(Link.linkBuilder("autocomplete", resourceLinks.autoComplete().groups()).withName("groups").build()); autoCompleteLinks.add(Link.linkBuilder("autocomplete", resourceLinks.autoComplete().groups()).withName("groups").build());
} }
autoCompleteLinks.add(Link.linkBuilder("autocomplete", resourceLinks.autoComplete().namespaces()).withName("namespaces").build());
builder.array(autoCompleteLinks); builder.array(autoCompleteLinks);
if (GroupPermissions.list().isPermitted()) { if (GroupPermissions.list().isPermitted()) {
builder.single(link("groups", resourceLinks.groupCollection().self())); builder.single(link("groups", resourceLinks.groupCollection().self()));

View File

@@ -317,6 +317,10 @@ class ResourceLinks {
String groups() { String groups() {
return linkBuilder.method("searchGroup").parameters().href(); return linkBuilder.method("searchGroup").parameters().href();
} }
String namespaces() {
return linkBuilder.method("searchNamespace").parameters().href();
}
} }
ConfigLinks config() { ConfigLinks config() {

View File

@@ -36,11 +36,14 @@ import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner; import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.DisplayManager; import sonia.scm.DisplayManager;
import sonia.scm.group.DefaultGroupDisplayManager; import sonia.scm.group.DefaultGroupDisplayManager;
import sonia.scm.group.Group; import sonia.scm.group.Group;
import sonia.scm.group.xml.XmlGroupDAO; import sonia.scm.group.xml.XmlGroupDAO;
import sonia.scm.repository.Namespace;
import sonia.scm.repository.NamespaceManager;
import sonia.scm.store.ConfigurationStore; import sonia.scm.store.ConfigurationStore;
import sonia.scm.store.ConfigurationStoreFactory; import sonia.scm.store.ConfigurationStoreFactory;
import sonia.scm.user.DefaultUserDisplayManager; import sonia.scm.user.DefaultUserDisplayManager;
@@ -56,6 +59,7 @@ import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.IntStream; import java.util.stream.IntStream;
import static java.util.Arrays.asList;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@@ -78,6 +82,8 @@ public class AutoCompleteResourceTest {
private XmlUserDAO userDao; private XmlUserDAO userDao;
private XmlGroupDAO groupDao; private XmlGroupDAO groupDao;
@Mock
private NamespaceManager namespaceManager;
private XmlDatabase xmlDB; private XmlDatabase xmlDB;
private ObjectMapper jsonObjectMapper = new ObjectMapper(); private ObjectMapper jsonObjectMapper = new ObjectMapper();
@@ -97,7 +103,7 @@ public class AutoCompleteResourceTest {
ReducedObjectModelToDtoMapperImpl mapper = new ReducedObjectModelToDtoMapperImpl(); ReducedObjectModelToDtoMapperImpl mapper = new ReducedObjectModelToDtoMapperImpl();
DefaultUserDisplayManager userManager = new DefaultUserDisplayManager(this.userDao); DefaultUserDisplayManager userManager = new DefaultUserDisplayManager(this.userDao);
DefaultGroupDisplayManager groupManager = new DefaultGroupDisplayManager(groupDao); DefaultGroupDisplayManager groupManager = new DefaultGroupDisplayManager(groupDao);
AutoCompleteResource autoCompleteResource = new AutoCompleteResource(mapper, userManager, groupManager); AutoCompleteResource autoCompleteResource = new AutoCompleteResource(mapper, userManager, groupManager, namespaceManager);
dispatcher.addSingletonResource(autoCompleteResource); dispatcher.addSingletonResource(autoCompleteResource);
} }
@@ -269,6 +275,24 @@ public class AutoCompleteResourceTest {
assertResultSize(response, defaultLimit); assertResultSize(response, defaultLimit);
} }
@Test
@SubjectAware(username = "user_without_autocomplete_permission", password = "secret")
public void shouldSearchNamespacesForAllUsers() throws Exception {
when(namespaceManager.getAll()).thenReturn(asList(new Namespace("hog"), new Namespace("hitchhiker")));
MockHttpRequest request = MockHttpRequest
.get("/" + AutoCompleteResource.PATH + "namespaces?q=hi")
.contentType(VndMediaType.AUTOCOMPLETE)
.accept(VndMediaType.AUTOCOMPLETE);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
assertResultSize(response, 1);
assertTrue(response.getContentAsString().contains("\"id\":\"hitchhiker\""));
}
private User createMockUser(String id, String name) { private User createMockUser(String id, String name) {
return new User(id, name, "em@l.de"); return new User(id, name, "em@l.de");
} }