Add extension point for repository creators (#1657)

Adds an extension point for repository creator such as repository create, repository import or repository mirror.
This commit is contained in:
Sebastian Sdorra
2021-05-14 09:15:35 +02:00
committed by GitHub
parent 640a270e1d
commit 8e16fa11c9
19 changed files with 428 additions and 223 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: Extension Point for repository creators ([#1657](https://github.com/scm-manager/scm-manager/pull/1657))

View File

@@ -10,7 +10,8 @@
"test": "jest"
},
"dependencies": {
"react": "^17.0.1"
"react": "^17.0.1",
"@scm-manager/ui-types": "^2.18.1-SNAPSHOT"
},
"devDependencies": {
"@scm-manager/babel-preset": "^2.12.0",

View File

@@ -30,7 +30,7 @@ type ExtensionRegistration<P, T> = {
extensionName: string;
};
export type ExtensionPointDefinition<N extends string, T, P> = {
export type ExtensionPointDefinition<N extends string, T, P = undefined> = {
name: N;
type: T;
props: P;

View File

@@ -0,0 +1,57 @@
/*
* 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 { ExtensionPointDefinition } from "./binder";
import {
IndexResources,
NamespaceStrategies,
RepositoryCreation,
RepositoryTypeCollection
} from "@scm-manager/ui-types";
type RepositoryCreatorSubFormProps = {
repository: RepositoryCreation;
onChange: (repository: RepositoryCreation) => void;
setValid: (valid: boolean) => void;
disabled?: boolean;
};
export type RepositoryCreatorComponentProps = {
namespaceStrategies: NamespaceStrategies;
repositoryTypes: RepositoryTypeCollection;
index: IndexResources;
nameForm: React.ComponentType<RepositoryCreatorSubFormProps>;
informationForm: React.ComponentType<RepositoryCreatorSubFormProps>;
};
export type RepositoryCreatorExtension = {
subtitle: string;
path: string;
icon: string;
label: string;
component: React.ComponentType<RepositoryCreatorComponentProps>;
};
export type RepositoryCreator = ExtensionPointDefinition<"repos.creator", RepositoryCreatorExtension>;

View File

@@ -25,3 +25,8 @@
export { default as binder, Binder, ExtensionPointDefinition } from "./binder";
export * from "./useBinder";
export { default as ExtensionPoint } from "./ExtensionPoint";
// suppress eslint prettier warning,
// because prettier does not understand "* as"
// eslint-disable-next-line prettier/prettier
export * as extensionPoints from "./extensionPoints";

View File

@@ -56,7 +56,7 @@ export type RepositoryCreation = RepositoryBase & {
contextEntries: { [key: string]: any };
};
export type RepositoryUrlImport = Repository & {
export type RepositoryUrlImport = RepositoryCreation & {
importUrl: string;
username?: string;
password?: string;

View File

@@ -37,7 +37,6 @@ import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
import CreateUser from "../users/containers/CreateUser";
import SingleUser from "../users/containers/SingleUser";
import RepositoryRoot from "../repos/containers/RepositoryRoot";
import CreateRepository from "../repos/containers/CreateRepository";
import Groups from "../groups/containers/Groups";
import SingleGroup from "../groups/containers/SingleGroup";
@@ -47,8 +46,8 @@ import Admin from "../admin/containers/Admin";
import Profile from "./Profile";
import NamespaceRoot from "../repos/namespaces/containers/NamespaceRoot";
import ImportRepository from "../repos/containers/ImportRepository";
import ImportLog from "../repos/importlog/ImportLog";
import CreateRepositoryRoot from "../repos/containers/CreateRepositoryRoot";
type Props = {
me: Me;
@@ -79,8 +78,8 @@ class Main extends React.Component<Props> {
<Route path="/logout" component={Logout} />
<Redirect exact strict from="/repos" to="/repos/" />
<ProtectedRoute exact path="/repos/" component={Overview} authenticated={authenticated} />
<ProtectedRoute exact path="/repos/create" component={CreateRepository} authenticated={authenticated} />
<ProtectedRoute exact path="/repos/import" component={ImportRepository} authenticated={authenticated} />
<ProtectedRoute path="/repos/create" component={CreateRepositoryRoot} authenticated={authenticated} />
<Redirect exact strict from="/repos/import" to="/repos/create/import" />
<ProtectedRoute exact path="/repos/:namespace" component={Overview} authenticated={authenticated} />
<ProtectedRoute exact path="/repos/:namespace/:page" component={Overview} authenticated={authenticated} />
<ProtectedRoute path="/repo/:namespace/:name" component={RepositoryRoot} authenticated={authenticated} />

View File

@@ -22,28 +22,35 @@
* SOFTWARE.
*/
import React, { FC, FormEvent, useState } from "react";
import NamespaceAndNameFields from "./NamespaceAndNameFields";
import { File, Repository } from "@scm-manager/ui-types";
import RepositoryInformationForm from "./RepositoryInformationForm";
import { File, RepositoryCreation } from "@scm-manager/ui-types";
import { apiClient, ErrorNotification, Level, SubmitButton } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import ImportFullRepositoryForm from "./ImportFullRepositoryForm";
import { SubFormProps } from "../types";
type Props = {
url: string;
repositoryType: string;
setImportPending: (pending: boolean) => void;
nameForm: React.ComponentType<SubFormProps>;
informationForm: React.ComponentType<SubFormProps>;
};
const ImportFullRepository: FC<Props> = ({ url, repositoryType, setImportPending }) => {
const [repo, setRepo] = useState<Repository>({
const ImportFullRepository: FC<Props> = ({
url,
repositoryType,
setImportPending,
nameForm: NameForm,
informationForm: InformationForm
}) => {
const [repo, setRepo] = useState<RepositoryCreation>({
name: "",
namespace: "",
type: repositoryType,
contact: "",
description: "",
_links: {}
contextEntries: []
});
const [password, setPassword] = useState("");
const [valid, setValid] = useState({ namespaceAndName: false, contact: true, file: false });
@@ -97,15 +104,15 @@ const ImportFullRepository: FC<Props> = ({ url, repositoryType, setImportPending
setValid={(file: boolean) => setValid({ ...valid, file })}
/>
<hr />
<NamespaceAndNameFields
<NameForm
repository={repo}
onChange={setRepo as React.Dispatch<React.SetStateAction<Repository>>}
onChange={setRepo as React.Dispatch<React.SetStateAction<RepositoryCreation>>}
setValid={(namespaceAndName: boolean) => setValid({ ...valid, namespaceAndName })}
disabled={loading}
/>
<RepositoryInformationForm
<InformationForm
repository={repo}
onChange={setRepo as React.Dispatch<React.SetStateAction<Repository>>}
onChange={setRepo as React.Dispatch<React.SetStateAction<RepositoryCreation>>}
disabled={loading}
setValid={(contact: boolean) => setValid({ ...valid, contact })}
/>

View File

@@ -22,28 +22,35 @@
* SOFTWARE.
*/
import React, { FC, FormEvent, useState } from "react";
import NamespaceAndNameFields from "./NamespaceAndNameFields";
import { File, Repository } from "@scm-manager/ui-types";
import RepositoryInformationForm from "./RepositoryInformationForm";
import { File, RepositoryCreation } from "@scm-manager/ui-types";
import { apiClient, ErrorNotification, Level, SubmitButton } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import ImportFromBundleForm from "./ImportFromBundleForm";
import { SubFormProps } from "../types";
type Props = {
url: string;
repositoryType: string;
setImportPending: (pending: boolean) => void;
nameForm: React.ComponentType<SubFormProps>;
informationForm: React.ComponentType<SubFormProps>;
};
const ImportRepositoryFromBundle: FC<Props> = ({ url, repositoryType, setImportPending }) => {
const [repo, setRepo] = useState<Repository>({
const ImportRepositoryFromBundle: FC<Props> = ({
url,
repositoryType,
setImportPending,
nameForm: NameForm,
informationForm: InformationForm
}) => {
const [repo, setRepo] = useState<RepositoryCreation>({
name: "",
namespace: "",
type: repositoryType,
contact: "",
description: "",
_links: {}
contextEntries: []
});
const [password, setPassword] = useState("");
const [valid, setValid] = useState({ namespaceAndName: false, contact: true, file: false });
@@ -101,15 +108,15 @@ const ImportRepositoryFromBundle: FC<Props> = ({ url, repositoryType, setImportP
disabled={loading}
/>
<hr />
<NamespaceAndNameFields
<NameForm
repository={repo}
onChange={setRepo as React.Dispatch<React.SetStateAction<Repository>>}
onChange={setRepo as React.Dispatch<React.SetStateAction<RepositoryCreation>>}
setValid={(namespaceAndName: boolean) => setValid({ ...valid, namespaceAndName })}
disabled={loading}
/>
<RepositoryInformationForm
<InformationForm
repository={repo}
onChange={setRepo as React.Dispatch<React.SetStateAction<Repository>>}
onChange={setRepo as React.Dispatch<React.SetStateAction<RepositoryCreation>>}
disabled={loading}
setValid={(contact: boolean) => setValid({ ...valid, contact })}
/>

View File

@@ -22,21 +22,28 @@
* SOFTWARE.
*/
import React, { FC, FormEvent, useState } from "react";
import NamespaceAndNameFields from "./NamespaceAndNameFields";
import { Repository, RepositoryUrlImport } from "@scm-manager/ui-types";
import { RepositoryCreation, RepositoryUrlImport } from "@scm-manager/ui-types";
import ImportFromUrlForm from "./ImportFromUrlForm";
import RepositoryInformationForm from "./RepositoryInformationForm";
import { apiClient, ErrorNotification, Level, SubmitButton } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { SubFormProps } from "../types";
type Props = {
url: string;
repositoryType: string;
setImportPending: (pending: boolean) => void;
nameForm: React.ComponentType<SubFormProps>;
informationForm: React.ComponentType<SubFormProps>;
};
const ImportRepositoryFromUrl: FC<Props> = ({ url, repositoryType, setImportPending }) => {
const ImportRepositoryFromUrl: FC<Props> = ({
url,
repositoryType,
setImportPending,
nameForm: NameForm,
informationForm: InformationForm
}) => {
const [repo, setRepo] = useState<RepositoryUrlImport>({
name: "",
namespace: "",
@@ -46,7 +53,7 @@ const ImportRepositoryFromUrl: FC<Props> = ({ url, repositoryType, setImportPend
importUrl: "",
username: "",
password: "",
_links: {}
contextEntries: []
});
const [valid, setValid] = useState({ namespaceAndName: false, contact: true, importUrl: false });
@@ -96,15 +103,15 @@ const ImportRepositoryFromUrl: FC<Props> = ({ url, repositoryType, setImportPend
disabled={loading}
/>
<hr />
<NamespaceAndNameFields
<NameForm
repository={repo}
onChange={setRepo as React.Dispatch<React.SetStateAction<Repository>>}
onChange={setRepo as React.Dispatch<React.SetStateAction<RepositoryCreation>>}
setValid={(namespaceAndName: boolean) => setValid({ ...valid, namespaceAndName })}
disabled={loading}
/>
<RepositoryInformationForm
<InformationForm
repository={repo}
onChange={setRepo as React.Dispatch<React.SetStateAction<Repository>>}
onChange={setRepo as React.Dispatch<React.SetStateAction<RepositoryCreation>>}
disabled={loading}
setValid={(contact: boolean) => setValid({ ...valid, contact })}
/>

View File

@@ -23,7 +23,7 @@
*/
import React, { FC, useEffect, useState } from "react";
import { Repository, CUSTOM_NAMESPACE_STRATEGY } 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";
@@ -31,8 +31,8 @@ import * as validator from "./form/repositoryValidation";
import { useNamespaceStrategies } from "@scm-manager/ui-api";
type Props = {
repository: Repository;
onChange: (repository: Repository) => void;
repository: RepositoryCreation;
onChange: (repository: RepositoryCreation) => void;
setValid: (valid: boolean) => void;
disabled?: boolean;
};
@@ -83,10 +83,10 @@ const NamespaceAndNameFields: FC<Props> = ({ repository, onChange, setValid, dis
};
const renderNamespaceField = () => {
let informationMessage = undefined;
if (repository?.namespace?.indexOf(" ") > 0) {
informationMessage = t("validation.namespaceSpaceWarningText");
}
let informationMessage = undefined;
if (repository?.namespace?.indexOf(" ") > 0) {
informationMessage = t("validation.namespaceSpaceWarningText");
}
const props = {
label: t("repository.namespace"),

View File

@@ -23,15 +23,15 @@
*/
import React, { FC, useState } from "react";
import { InputField, Textarea } from "@scm-manager/ui-components";
import { Repository } from "@scm-manager/ui-types";
import { RepositoryCreation } from "@scm-manager/ui-types";
import { useTranslation } from "react-i18next";
import * as validator from "./form/repositoryValidation";
type Props = {
repository: Repository;
onChange: (repository: Repository) => void;
disabled: boolean;
repository: RepositoryCreation;
onChange: (repository: RepositoryCreation) => void;
setValid: (valid: boolean) => void;
disabled?: boolean;
};
const RepositoryInformationForm: FC<Props> = ({ repository, onChange, disabled, setValid }) => {

View File

@@ -24,12 +24,8 @@
import React, { FC } from "react";
import styled from "styled-components";
import { useTranslation } from "react-i18next";
import { Button, ButtonAddons, Icon, Level } from "@scm-manager/ui-components";
type Props = {
creationMode: "CREATE" | "IMPORT";
};
import { Button, ButtonAddons, Icon, Level, urls } from "@scm-manager/ui-components";
import { useLocation } from "react-router-dom";
const MarginIcon = styled(Icon)`
padding-right: 0.5rem;
@@ -52,40 +48,39 @@ const TopLevel = styled(Level)`
}
`;
const RepositoryFormSwitcher: FC<Props> = ({ creationMode }) => {
const [t] = useTranslation("repos");
type RepositoryForm = {
path: string;
icon: string;
label: string;
};
const isImportMode = () => {
return creationMode === "IMPORT";
};
const isCreateMode = () => {
return creationMode === "CREATE";
};
const RepositoryFormButton: FC<RepositoryForm> = ({ path, icon, label }) => {
const location = useLocation();
const href = urls.concat("/repos/create", path);
const isSelected = href === location.pathname;
return (
<TopLevel
right={
<ButtonAddons>
<SmallButton
color={isCreateMode() ? "link is-selected" : undefined}
link={isImportMode() ? "/repos/create" : undefined}
>
<MarginIcon name="fa fa-plus" color={isCreateMode() ? "white" : "default"} />
<p className="is-hidden-mobile is-hidden-tablet-only">{t("repositoryForm.createButton")}</p>
</SmallButton>
<SmallButton
color={isImportMode() ? "link is-selected" : undefined}
link={isCreateMode() ? "/repos/import" : undefined}
className="has-text-left-desktop"
>
<MarginIcon name="fa fa-file-upload" color={isImportMode() ? "white" : "default"} />
<p className="is-hidden-mobile is-hidden-tablet-only">{t("repositoryForm.importButton")}</p>
</SmallButton>
</ButtonAddons>
}
/>
<SmallButton color={isSelected ? "link is-selected" : undefined} link={!isSelected ? href : undefined}>
<MarginIcon name={icon} color={isSelected ? "white" : "default"} />
<p className="is-hidden-mobile is-hidden-tablet-only">{label}</p>
</SmallButton>
);
};
type Props = {
forms: RepositoryForm[];
};
const RepositoryFormSwitcher: FC<Props> = ({ forms }) => (
<TopLevel
right={
<ButtonAddons>
{(forms || []).map(form => (
<RepositoryFormButton key={form.path} {...form} />
))}
</ButtonAddons>
}
/>
);
export default RepositoryFormSwitcher;

View File

@@ -22,54 +22,30 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Page } from "@scm-manager/ui-components";
import { ErrorNotification } from "@scm-manager/ui-components";
import RepositoryForm from "../components/form";
import RepositoryFormSwitcher from "../components/form/RepositoryFormSwitcher";
import { Redirect } from "react-router-dom";
import { useCreateRepository, useIndex, useNamespaceStrategies, useRepositoryTypes } from "@scm-manager/ui-api";
import { useCreateRepository } from "@scm-manager/ui-api";
import { CreatorComponentProps } from "../types";
const useCreateRepositoryData = () => {
const { isLoading: isLoadingNS, error: errorNS, data: namespaceStrategies } = useNamespaceStrategies();
const { isLoading: isLoadingRT, error: errorRT, data: repositoryTypes } = useRepositoryTypes();
const { isLoading: isLoadingIdx, error: errorIdx, data: index } = useIndex();
return {
isPageLoading: isLoadingNS || isLoadingRT || isLoadingIdx,
pageLoadingError: errorNS || errorRT || errorIdx || undefined,
namespaceStrategies,
repositoryTypes,
index
};
};
const CreateRepository: FC = () => {
const { isPageLoading, pageLoadingError, namespaceStrategies, repositoryTypes, index } = useCreateRepositoryData();
const CreateRepository: FC<CreatorComponentProps> = ({ repositoryTypes, namespaceStrategies, index }) => {
const { isLoading, error, repository, create } = useCreateRepository();
const [t] = useTranslation("repos");
if (repository) {
return <Redirect to={`/repo/${repository.namespace}/${repository.name}`} />;
}
return (
<Page
title={t("create.title")}
subtitle={t("create.subtitle")}
afterTitle={<RepositoryFormSwitcher creationMode={"CREATE"} />}
loading={isPageLoading}
error={pageLoadingError || error || undefined}
showContentOnError={true}
>
{namespaceStrategies && repositoryTypes ? (
<RepositoryForm
repositoryTypes={repositoryTypes._embedded.repositoryTypes}
loading={isLoading}
namespaceStrategy={namespaceStrategies!.current}
createRepository={create}
indexResources={index}
/>
) : null}
</Page>
<>
<ErrorNotification error={error} />
<RepositoryForm
repositoryTypes={repositoryTypes._embedded.repositoryTypes}
loading={isLoading}
namespaceStrategy={namespaceStrategies.current}
createRepository={create}
indexResources={index}
/>
</>
);
};

View File

@@ -0,0 +1,120 @@
/*
* 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 { Route, Switch } from "react-router-dom";
import CreateRepository from "./CreateRepository";
import ImportRepository from "./ImportRepository";
import { useBinder } from "@scm-manager/ui-extensions";
import { useTranslation } from "react-i18next";
import { Page, urls } from "@scm-manager/ui-components";
import RepositoryFormSwitcher from "../components/form/RepositoryFormSwitcher";
import { useIndex, useNamespaceStrategies, useRepositoryTypes } from "@scm-manager/ui-api";
import NamespaceAndNameFields from "../components/NamespaceAndNameFields";
import RepositoryInformationForm from "../components/RepositoryInformationForm";
import { extensionPoints } from "@scm-manager/ui-extensions/";
type CreatorRouteProps = {
creator: extensionPoints.RepositoryCreatorExtension;
creators: extensionPoints.RepositoryCreatorExtension[];
};
const useCreateRepositoryData = () => {
const { isLoading: isLoadingNS, error: errorNS, data: namespaceStrategies } = useNamespaceStrategies();
const { isLoading: isLoadingRT, error: errorRT, data: repositoryTypes } = useRepositoryTypes();
const { isLoading: isLoadingIdx, error: errorIdx, data: index } = useIndex();
return {
isPageLoading: isLoadingNS || isLoadingRT || isLoadingIdx,
pageLoadingError: errorNS || errorRT || errorIdx || undefined,
namespaceStrategies,
repositoryTypes,
index
};
};
const CreatorRoute: FC<CreatorRouteProps> = ({ creator, creators }) => {
const { isPageLoading, pageLoadingError, namespaceStrategies, repositoryTypes, index } = useCreateRepositoryData();
const [t] = useTranslation(["repos", "plugins"]);
const Component = creator.component;
return (
<Page
title={t("repos:create.title")}
subtitle={t(`plugins:${creator.subtitle}`, creator.subtitle)}
afterTitle={<RepositoryFormSwitcher forms={creators} />}
loading={isPageLoading}
error={pageLoadingError}
>
{namespaceStrategies && repositoryTypes && index ? (
<Component
namespaceStrategies={namespaceStrategies}
repositoryTypes={repositoryTypes}
index={index}
nameForm={NamespaceAndNameFields}
informationForm={RepositoryInformationForm}
/>
) : null}
</Page>
);
};
const CreateRepositoryRoot: FC = () => {
const [t] = useTranslation("repos");
const binder = useBinder();
const creators: extensionPoints.RepositoryCreatorExtension[] = [
{
subtitle: t("create.subtitle"),
path: "",
icon: "plus",
label: t("repositoryForm.createButton"),
component: CreateRepository
},
{
subtitle: t("import.subtitle"),
path: "import",
icon: "file-upload",
label: t("repositoryForm.importButton"),
component: ImportRepository
}
];
const extCreators = binder.getExtensions<extensionPoints.RepositoryCreator>("repos.creator");
if (extCreators) {
creators.push(...extCreators);
}
return (
<Switch>
{creators.map(creator => (
<Route key={creator.path} exact path={urls.concat("/repos/create", creator.path)}>
<CreatorRoute creator={creator} creators={creators} />
</Route>
))}
</Switch>
);
};
export default CreateRepositoryRoot;

View File

@@ -29,13 +29,12 @@ import { useTranslation } from "react-i18next";
import ImportRepositoryTypeSelect from "../components/ImportRepositoryTypeSelect";
import ImportTypeSelect from "../components/ImportTypeSelect";
import ImportRepositoryFromUrl from "../components/ImportRepositoryFromUrl";
import { Loading, Notification, Page, useNavigationLock } from "@scm-manager/ui-components";
import RepositoryFormSwitcher from "../components/form/RepositoryFormSwitcher";
import { Loading, Notification, useNavigationLock } from "@scm-manager/ui-components";
import ImportRepositoryFromBundle from "../components/ImportRepositoryFromBundle";
import ImportFullRepository from "../components/ImportFullRepository";
import { useRepositoryTypes } from "@scm-manager/ui-api";
import { Prompt } from "react-router-dom";
import { CreatorComponentProps } from "../types";
const ImportPendingLoading = ({ importPending }: { importPending: boolean }) => {
const [t] = useTranslation("repos");
@@ -51,8 +50,7 @@ const ImportPendingLoading = ({ importPending }: { importPending: boolean }) =>
);
};
const ImportRepository: FC = () => {
const { data: repositoryTypes, error, isLoading } = useRepositoryTypes();
const ImportRepository: FC<CreatorComponentProps> = ({ repositoryTypes, nameForm, informationForm }) => {
const [importPending, setImportPending] = useState(false);
const [repositoryType, setRepositoryType] = useState<RepositoryType | undefined>();
const [importType, setImportType] = useState("");
@@ -72,6 +70,8 @@ const ImportRepository: FC = () => {
url={((repositoryType!._links.import as Link[])!.find((link: Link) => link.name === "url") as Link).href}
repositoryType={repositoryType!.name}
setImportPending={setImportPending}
nameForm={nameForm}
informationForm={informationForm}
/>
);
}
@@ -82,6 +82,8 @@ const ImportRepository: FC = () => {
url={((repositoryType!._links.import as Link[])!.find((link: Link) => link.name === "bundle") as Link).href}
repositoryType={repositoryType!.name}
setImportPending={setImportPending}
nameForm={nameForm}
informationForm={informationForm}
/>
);
}
@@ -94,6 +96,8 @@ const ImportRepository: FC = () => {
}
repositoryType={repositoryType!.name}
setImportPending={setImportPending}
nameForm={nameForm}
informationForm={informationForm}
/>
);
}
@@ -102,14 +106,7 @@ const ImportRepository: FC = () => {
};
return (
<Page
title={t("create.title")}
subtitle={t("import.subtitle")}
afterTitle={<RepositoryFormSwitcher creationMode={"IMPORT"} />}
loading={isLoading}
error={error || undefined}
showContentOnError={true}
>
<>
<Prompt when={importPending} message={t("import.navigationWarning")} />
<ImportPendingLoading importPending={importPending} />
<ImportRepositoryTypeSelect
@@ -131,7 +128,7 @@ const ImportRepository: FC = () => {
</>
)}
{importType && renderImportComponent()}
</Page>
</>
);
};

View File

@@ -24,23 +24,25 @@
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@NoArgsConstructor
@Getter
@Setter
@NoArgsConstructor
@SuppressWarnings("java:S2160") // we need no equals for dtos
public class RepositoryTypeDto extends HalRepresentation {
private String name;
private String displayName;
@Override
@SuppressWarnings("squid:S1185") // We want to have this method available in this package
protected HalRepresentation add(Links links) {
return super.add(links);
public RepositoryTypeDto(Links links, Embedded embedded) {
super(links, embedded);
}
}

View File

@@ -24,40 +24,48 @@
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.Link;
import de.otto.edison.hal.Links;
import org.mapstruct.AfterMapping;
import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;
import org.mapstruct.ObjectFactory;
import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.repository.RepositoryType;
import sonia.scm.repository.api.Command;
import sonia.scm.web.EdisonHalAppender;
import javax.inject.Inject;
import static de.otto.edison.hal.Embedded.embeddedBuilder;
import static de.otto.edison.hal.Links.linkingTo;
@Mapper
public abstract class RepositoryTypeToRepositoryTypeDtoMapper extends BaseMapper<RepositoryType, RepositoryTypeDto> {
private static final String REL_IMPORT = "import";
@Inject
private ResourceLinks resourceLinks;
@AfterMapping
void appendLinks(RepositoryType repositoryType, @MappingTarget RepositoryTypeDto target) {
@ObjectFactory
RepositoryTypeDto create(RepositoryType repositoryType) {
Links.Builder linksBuilder = linkingTo().self(resourceLinks.repositoryType().self(repositoryType.getName()));
if (RepositoryPermissions.create().isPermitted()) {
if (repositoryType.getSupportedCommands().contains(Command.PULL)) {
linksBuilder.array(Link.linkBuilder("import", resourceLinks.repository().importFromUrl(repositoryType.getName())).withName("url").build());
linksBuilder.array(Link.linkBuilder(REL_IMPORT, resourceLinks.repository().importFromUrl(repositoryType.getName())).withName("url").build());
}
if (repositoryType.getSupportedCommands().contains(Command.UNBUNDLE)) {
linksBuilder.array(Link.linkBuilder("import", resourceLinks.repository().importFromBundle(repositoryType.getName())).withName("bundle").build());
linksBuilder.array(Link.linkBuilder("import", resourceLinks.repository().fullImport(repositoryType.getName())).withName("fullImport").build());
linksBuilder.array(Link.linkBuilder(REL_IMPORT, resourceLinks.repository().importFromBundle(repositoryType.getName())).withName("bundle").build());
linksBuilder.array(Link.linkBuilder(REL_IMPORT, resourceLinks.repository().fullImport(repositoryType.getName())).withName("fullImport").build());
}
}
target.add(linksBuilder.build());
Embedded.Builder embeddedBuilder = embeddedBuilder();
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), repositoryType);
return new RepositoryTypeDto(linksBuilder.build(), embeddedBuilder.build());
}
}

View File

@@ -26,28 +26,27 @@ package sonia.scm.api.v2.resources;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import de.otto.edison.hal.Link;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ThreadContext;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import de.otto.edison.hal.HalRepresentation;
import org.assertj.core.data.Index;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.RepositoryType;
import sonia.scm.repository.api.Command;
import java.net.URI;
import java.util.List;
import java.util.Collections;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.Silent.class)
public class RepositoryTypeToRepositoryTypeDtoMapperTest {
@SubjectAware("slarti")
@ExtendWith({MockitoExtension.class, ShiroExtension.class})
class RepositoryTypeToRepositoryTypeDtoMapperTest {
private final URI baseUri = URI.create("https://scm-manager.org/scm/");
@@ -55,96 +54,119 @@ public class RepositoryTypeToRepositoryTypeDtoMapperTest {
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
@Mock
private Subject subject;
private HalEnricherRegistry registry;
@InjectMocks
private RepositoryTypeToRepositoryTypeDtoMapperImpl mapper;
private final RepositoryType type = new RepositoryType("hk", "Hitchhiker", Sets.newHashSet());
@Before
public void init() {
ThreadContext.bind(subject);
}
@After
public void tearDown() {
ThreadContext.unbindSubject();
@Test
void shouldMapSimpleProperties() {
RepositoryTypeDto dto = mapper.map(type);
assertThat(dto.getName()).isEqualTo("hk");
assertThat(dto.getDisplayName()).isEqualTo("Hitchhiker");
}
@Test
public void shouldMapSimpleProperties() {
void shouldAppendSelfLink() {
RepositoryTypeDto dto = mapper.map(type);
assertEquals("hk", dto.getName());
assertEquals("Hitchhiker", dto.getDisplayName());
assertLink(dto, "self", "https://scm-manager.org/scm/v2/repositoryTypes/hk");
}
@Test
public void shouldAppendSelfLink() {
RepositoryTypeDto dto = mapper.map(type);
assertEquals(
"https://scm-manager.org/scm/v2/repositoryTypes/hk",
dto.getLinks().getLinkBy("self").get().getHref()
private void assertLink(RepositoryTypeDto dto, String rel, String href) {
assertThat(dto.getLinks().getLinkBy(rel)).hasValueSatisfying(
link -> assertThat(link.getHref()).isEqualTo(href)
);
}
@Test
public void shouldAppendImportFromUrlLink() {
RepositoryType type = new RepositoryType("hk", "Hitchhiker", ImmutableSet.of(Command.PULL));
when(subject.isPermitted("repository:create")).thenReturn(true);
RepositoryTypeDto dto = mapper.map(type);
assertEquals(
"https://scm-manager.org/scm/v2/repositories/import/hk/url",
dto.getLinks().getLinkBy("import").get().getHref()
);
}
@Test
public void shouldNotAppendImportFromUrlLinkIfCommandNotSupported() {
when(subject.isPermitted("repository:create")).thenReturn(true);
RepositoryTypeDto dto = mapper.map(type);
assertFalse(dto.getLinks().getLinkBy("import").isPresent());
}
@Test
public void shouldNotAppendImportFromUrlLinkIfNotPermitted() {
@SubjectAware(value = "trillian", permissions = "repository:create")
void shouldAppendImportFromUrlLink() {
RepositoryType type = new RepositoryType("hk", "Hitchhiker", ImmutableSet.of(Command.PULL));
RepositoryTypeDto dto = mapper.map(type);
assertFalse(dto.getLinks().getLinkBy("import").isPresent());
assertLink(dto, "import", "https://scm-manager.org/scm/v2/repositories/import/hk/url");
}
@Test
public void shouldAppendImportFromBundleLinkAndFullImportLink() {
RepositoryType type = new RepositoryType("hk", "Hitchhiker", ImmutableSet.of(Command.UNBUNDLE));
when(subject.isPermitted("repository:create")).thenReturn(true);
@SubjectAware(value = "trillian", permissions = "repository:create")
void shouldNotAppendImportFromUrlLinkIfCommandNotSupported() {
RepositoryTypeDto dto = mapper.map(type);
assertThat(dto.getLinks().getLinkBy("import")).isEmpty();
}
@Test
void shouldNotAppendImportFromUrlLinkIfNotPermitted() {
RepositoryType type = new RepositoryType("hk", "Hitchhiker", ImmutableSet.of(Command.PULL));
RepositoryTypeDto dto = mapper.map(type);
List<Link> links = dto.getLinks().getLinksBy("import");
assertEquals(2, links.size());
assertEquals(
"https://scm-manager.org/scm/v2/repositories/import/hk/bundle",
links.get(0).getHref()
);
assertEquals(
"https://scm-manager.org/scm/v2/repositories/import/hk/full",
links.get(1).getHref()
);
assertThat(dto.getLinks().getLinkBy("import")).isEmpty();
}
@Test
public void shouldNotAppendImportFromBundleLinkOrFullImportLinkIfCommandNotSupported() {
when(subject.isPermitted("repository:create")).thenReturn(true);
RepositoryTypeDto dto = mapper.map(type);
assertFalse(dto.getLinks().getLinkBy("import").isPresent());
}
@Test
public void shouldNotAppendImportFromBundleLinkIfNotPermitted() {
@SubjectAware(value = "trillian", permissions = "repository:create")
void shouldAppendImportFromBundleLinkAndFullImportLink() {
RepositoryType type = new RepositoryType("hk", "Hitchhiker", ImmutableSet.of(Command.UNBUNDLE));
RepositoryTypeDto dto = mapper.map(type);
assertFalse(dto.getLinks().getLinkBy("import").isPresent());
assertThat(dto.getLinks().getLinksBy("import"))
.hasSize(2)
.satisfies(link -> {
assertThat(link.getHref()).isEqualTo("https://scm-manager.org/scm/v2/repositories/import/hk/bundle");
}, Index.atIndex(0)
)
.satisfies(link -> {
assertThat(link.getHref()).isEqualTo("https://scm-manager.org/scm/v2/repositories/import/hk/full");
}, Index.atIndex(1)
);
}
@Test
@SubjectAware(value = "trillian", permissions = "repository:create")
void shouldNotAppendImportFromBundleLinkOrFullImportLinkIfCommandNotSupported() {
RepositoryTypeDto dto = mapper.map(type);
assertThat(dto.getLinks().getLinkBy("import")).isEmpty();
}
@Test
void shouldNotAppendImportFromBundleLinkIfNotPermitted() {
RepositoryType type = new RepositoryType("hk", "Hitchhiker", ImmutableSet.of(Command.UNBUNDLE));
RepositoryTypeDto dto = mapper.map(type);
assertThat(dto.getLinks().getLinkBy("import")).isEmpty();
}
@Test
void shouldEnrichLinks() {
when(registry.allByType(RepositoryType.class)).thenReturn(Collections.singleton(new LinkEnricher()));
RepositoryTypeDto dto = mapper.map(type);
assertLink(dto, "spaceship", "/spaceships/heart-of-gold");
}
@Test
void shouldEnrichEmbedded() {
when(registry.allByType(RepositoryType.class)).thenReturn(Collections.singleton(new EmbeddedEnricher()));
RepositoryTypeDto dto = mapper.map(type);
assertThat(dto.getEmbedded().getItemsBy("spaceship")).hasSize(1);
}
private static class LinkEnricher implements HalEnricher {
@Override
public void enrich(HalEnricherContext context, HalAppender appender) {
appender.appendLink("spaceship", "/spaceships/heart-of-gold");
}
}
private static class EmbeddedEnricher implements HalEnricher {
@Override
public void enrich(HalEnricherContext context, HalAppender appender) {
appender.appendEmbedded("spaceship", new HalRepresentation());
}
}
}