mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-18 03:01:05 +01:00
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:
@@ -36,6 +36,7 @@ export * from "./users";
|
||||
export * from "./suggestions";
|
||||
export * from "./userSuggestions";
|
||||
export * from "./groupSuggestions";
|
||||
export * from "./namespaceSuggestions";
|
||||
export * from "./repositories";
|
||||
export * from "./namespaces";
|
||||
export * from "./branches";
|
||||
|
||||
32
scm-ui/ui-api/src/namespaceSuggestions.ts
Normal file
32
scm-ui/ui-api/src/namespaceSuggestions.ts
Normal 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);
|
||||
};
|
||||
@@ -37,8 +37,11 @@ type Props = {
|
||||
placeholder: string;
|
||||
loadingMessage: string;
|
||||
noOptionsMessage: string;
|
||||
errorMessage?: string;
|
||||
informationMessage?: string;
|
||||
creatable?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type State = {};
|
||||
@@ -78,8 +81,11 @@ class Autocomplete extends React.Component<Props, State> {
|
||||
loadingMessage,
|
||||
noOptionsMessage,
|
||||
loadSuggestions,
|
||||
errorMessage,
|
||||
informationMessage,
|
||||
creatable,
|
||||
className
|
||||
className,
|
||||
disabled
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
@@ -108,6 +114,7 @@ class Autocomplete extends React.Component<Props, State> {
|
||||
});
|
||||
}}
|
||||
aria-label={helpText || label}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
) : (
|
||||
<Async
|
||||
@@ -121,9 +128,12 @@ class Autocomplete extends React.Component<Props, State> {
|
||||
loadingMessage={() => loadingMessage}
|
||||
noOptionsMessage={() => noOptionsMessage}
|
||||
aria-label={helpText || label}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{errorMessage ? <p className="help is-danger">{errorMessage}</p> : null}
|
||||
{informationMessage ? <p className="help is-info">{informationMessage}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 overflow 1`] = `null`;
|
||||
|
||||
exports[`Storyshots Modal/Modal With overflow and footer 1`] = `null`;
|
||||
|
||||
exports[`Storyshots Notification Closeable 1`] = `
|
||||
<div
|
||||
className="Notificationstories__Wrapper-sc-8fx7tr-0 gJPbVB"
|
||||
|
||||
@@ -32,6 +32,8 @@ import ExternalLink from "../navigation/ExternalLink";
|
||||
import { Radio, Textarea, InputField } from "../forms";
|
||||
import { ButtonGroup, Button } from "../buttons";
|
||||
import Notification from "../Notification";
|
||||
import { Autocomplete } from "../index";
|
||||
import { SelectValue } from "@scm-manager/ui-types";
|
||||
|
||||
const TopAndBottomMargin = styled.div`
|
||||
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.`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const doNothing = () => {};
|
||||
const doNothing = () => {
|
||||
// nothing to do
|
||||
};
|
||||
const withFormElementsBody = (
|
||||
<>
|
||||
<RadioList>
|
||||
@@ -71,8 +75,21 @@ const withFormElementsFooter = (
|
||||
</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)
|
||||
.addDecorator((story) => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
|
||||
.addDecorator(story => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
|
||||
.add("Default", () => (
|
||||
<NonCloseableModal>
|
||||
<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.
|
||||
</Notification>
|
||||
<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 />
|
||||
<TopAndBottomMargin>
|
||||
<Checkbox label="Checkbox" checked={true} helpText={text} />
|
||||
@@ -211,10 +228,47 @@ storiesOf("Modal/Modal", module)
|
||||
</p>
|
||||
</div>
|
||||
</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 }) => {
|
||||
return <Modal body={children} closeFunction={doNothing} active={true} title={"Hitchhiker Modal"} />;
|
||||
type NonCloseableModalProps = { overflowVisible?: boolean; footer?: any };
|
||||
|
||||
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 }) => {
|
||||
|
||||
@@ -42,10 +42,24 @@ type Props = {
|
||||
headColor?: string;
|
||||
headTextColor?: string;
|
||||
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")};
|
||||
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> = ({
|
||||
@@ -57,7 +71,8 @@ export const Modal: FC<Props> = ({
|
||||
className,
|
||||
headColor = "secondary-less",
|
||||
headTextColor = "secondary-most",
|
||||
size
|
||||
size,
|
||||
overflowVisible
|
||||
}) => {
|
||||
const portalRootElement = usePortalRootElement("modalsRoot");
|
||||
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 = (
|
||||
<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} />
|
||||
<SizedModal className="modal-card" size={size}>
|
||||
<SizedModal className="modal-card" size={size} overflow={overflowAttribute}>
|
||||
<header className={classNames("modal-card-head", `has-background-${headColor}`)}>
|
||||
<h2 className={`modal-card-title m-0 has-text-${headTextColor}`}>{title}</h2>
|
||||
<button className="delete" aria-label="close" onClick={closeFunction} ref={initialFocusRef} autoFocus />
|
||||
</header>
|
||||
<section className="modal-card-body">{body}</section>
|
||||
<SectionWithOptionalOverflow className="modal-card-body" overflow={overflowAttribute} borderBottomRadius={borderBottomRadiusAttribute}>
|
||||
{body}
|
||||
</SectionWithOptionalOverflow>
|
||||
{showFooter}
|
||||
</SizedModal>
|
||||
</div>
|
||||
</DivWithOptionalOverflow>
|
||||
);
|
||||
|
||||
return ReactDOM.createPortal(modalElement, portalRootElement);
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
export type AutocompleteObject = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
export type SelectValue = {
|
||||
|
||||
@@ -23,12 +23,12 @@
|
||||
*/
|
||||
|
||||
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 { InputField } from "@scm-manager/ui-components";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
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 = {
|
||||
repository: RepositoryCreation;
|
||||
@@ -82,30 +82,6 @@ const NamespaceAndNameFields: FC<Props> = ({ repository, onChange, setValid, dis
|
||||
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
|
||||
if (namespaceStrategy === "") {
|
||||
return null;
|
||||
@@ -113,7 +89,13 @@ const NamespaceAndNameFields: FC<Props> = ({ repository, onChange, setValid, dis
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderNamespaceField()}
|
||||
<NamespaceInput
|
||||
namespace={repository?.namespace}
|
||||
handleNamespaceChange={handleNamespaceChange}
|
||||
namespaceStrategy={namespaceStrategy}
|
||||
namespaceValidationError={namespaceValidationError}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<InputField
|
||||
label={t("repository.name")}
|
||||
onChange={handleNameChange}
|
||||
|
||||
83
scm-ui/ui-webapp/src/repos/components/NamespaceInput.tsx
Normal file
83
scm-ui/ui-webapp/src/repos/components/NamespaceInput.tsx
Normal 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;
|
||||
@@ -23,13 +23,18 @@
|
||||
*/
|
||||
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
import * as validator from "../components/form/repositoryValidation";
|
||||
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 = {
|
||||
repository: Repository;
|
||||
@@ -76,25 +81,8 @@ const RenameRepository: FC<Props> = ({ repository }) => {
|
||||
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 = (
|
||||
<div>
|
||||
<WithOverflow>
|
||||
{renamingError ? <ErrorNotification error={renamingError} /> : null}
|
||||
<InputField
|
||||
label={t("renameRepo.modal.label.repoName")}
|
||||
@@ -105,8 +93,13 @@ const RenameRepository: FC<Props> = ({ repository }) => {
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
/>
|
||||
{renderNamespaceField()}
|
||||
</div>
|
||||
<NamespaceInput
|
||||
namespace={namespace}
|
||||
handleNamespaceChange={handleNamespaceChange}
|
||||
namespaceValidationError={namespaceValidationError}
|
||||
namespaceStrategy={namespaceStrategies?.current}
|
||||
/>
|
||||
</WithOverflow>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
@@ -137,6 +130,7 @@ const RenameRepository: FC<Props> = ({ repository }) => {
|
||||
footer={footer}
|
||||
body={modalBody}
|
||||
closeFunction={() => setShowModal(false)}
|
||||
overflowVisible={true}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user