diff --git a/gradle/changelog/extension_point_repository_creator.yaml b/gradle/changelog/extension_point_repository_creator.yaml new file mode 100644 index 0000000000..ce478ef35e --- /dev/null +++ b/gradle/changelog/extension_point_repository_creator.yaml @@ -0,0 +1,2 @@ +- type: added + description: Extension Point for repository creators ([#1657](https://github.com/scm-manager/scm-manager/pull/1657)) diff --git a/scm-ui/ui-extensions/package.json b/scm-ui/ui-extensions/package.json index 2b99a2835a..957f9bdc52 100644 --- a/scm-ui/ui-extensions/package.json +++ b/scm-ui/ui-extensions/package.json @@ -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", diff --git a/scm-ui/ui-extensions/src/binder.ts b/scm-ui/ui-extensions/src/binder.ts index 978de8f633..ef80db9746 100644 --- a/scm-ui/ui-extensions/src/binder.ts +++ b/scm-ui/ui-extensions/src/binder.ts @@ -30,7 +30,7 @@ type ExtensionRegistration = { extensionName: string; }; -export type ExtensionPointDefinition = { +export type ExtensionPointDefinition = { name: N; type: T; props: P; diff --git a/scm-ui/ui-extensions/src/extensionPoints.ts b/scm-ui/ui-extensions/src/extensionPoints.ts new file mode 100644 index 0000000000..2eb9cafe4b --- /dev/null +++ b/scm-ui/ui-extensions/src/extensionPoints.ts @@ -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; + informationForm: React.ComponentType; +}; + +export type RepositoryCreatorExtension = { + subtitle: string; + path: string; + icon: string; + label: string; + component: React.ComponentType; +}; + +export type RepositoryCreator = ExtensionPointDefinition<"repos.creator", RepositoryCreatorExtension>; diff --git a/scm-ui/ui-extensions/src/index.ts b/scm-ui/ui-extensions/src/index.ts index e6f7646601..746a089f6e 100644 --- a/scm-ui/ui-extensions/src/index.ts +++ b/scm-ui/ui-extensions/src/index.ts @@ -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"; diff --git a/scm-ui/ui-types/src/Repositories.ts b/scm-ui/ui-types/src/Repositories.ts index 2985350977..4f5fab167c 100644 --- a/scm-ui/ui-types/src/Repositories.ts +++ b/scm-ui/ui-types/src/Repositories.ts @@ -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; diff --git a/scm-ui/ui-webapp/src/containers/Main.tsx b/scm-ui/ui-webapp/src/containers/Main.tsx index 3cffe27d01..f8bf785922 100644 --- a/scm-ui/ui-webapp/src/containers/Main.tsx +++ b/scm-ui/ui-webapp/src/containers/Main.tsx @@ -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 { - - + + diff --git a/scm-ui/ui-webapp/src/repos/components/ImportFullRepository.tsx b/scm-ui/ui-webapp/src/repos/components/ImportFullRepository.tsx index fe55407fef..6a6123b6b1 100644 --- a/scm-ui/ui-webapp/src/repos/components/ImportFullRepository.tsx +++ b/scm-ui/ui-webapp/src/repos/components/ImportFullRepository.tsx @@ -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; + informationForm: React.ComponentType; }; -const ImportFullRepository: FC = ({ url, repositoryType, setImportPending }) => { - const [repo, setRepo] = useState({ +const ImportFullRepository: FC = ({ + url, + repositoryType, + setImportPending, + nameForm: NameForm, + informationForm: InformationForm +}) => { + const [repo, setRepo] = useState({ 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 = ({ url, repositoryType, setImportPending setValid={(file: boolean) => setValid({ ...valid, file })} />
- >} + onChange={setRepo as React.Dispatch>} setValid={(namespaceAndName: boolean) => setValid({ ...valid, namespaceAndName })} disabled={loading} /> - >} + onChange={setRepo as React.Dispatch>} disabled={loading} setValid={(contact: boolean) => setValid({ ...valid, contact })} /> diff --git a/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx index 0f449605d6..341eac8827 100644 --- a/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx +++ b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx @@ -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; + informationForm: React.ComponentType; }; -const ImportRepositoryFromBundle: FC = ({ url, repositoryType, setImportPending }) => { - const [repo, setRepo] = useState({ +const ImportRepositoryFromBundle: FC = ({ + url, + repositoryType, + setImportPending, + nameForm: NameForm, + informationForm: InformationForm +}) => { + const [repo, setRepo] = useState({ 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 = ({ url, repositoryType, setImportP disabled={loading} />
- >} + onChange={setRepo as React.Dispatch>} setValid={(namespaceAndName: boolean) => setValid({ ...valid, namespaceAndName })} disabled={loading} /> - >} + onChange={setRepo as React.Dispatch>} disabled={loading} setValid={(contact: boolean) => setValid({ ...valid, contact })} /> diff --git a/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromUrl.tsx b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromUrl.tsx index 30dade297e..3b9a485701 100644 --- a/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromUrl.tsx +++ b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromUrl.tsx @@ -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; + informationForm: React.ComponentType; }; -const ImportRepositoryFromUrl: FC = ({ url, repositoryType, setImportPending }) => { +const ImportRepositoryFromUrl: FC = ({ + url, + repositoryType, + setImportPending, + nameForm: NameForm, + informationForm: InformationForm +}) => { const [repo, setRepo] = useState({ name: "", namespace: "", @@ -46,7 +53,7 @@ const ImportRepositoryFromUrl: FC = ({ 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 = ({ url, repositoryType, setImportPend disabled={loading} />
- >} + onChange={setRepo as React.Dispatch>} setValid={(namespaceAndName: boolean) => setValid({ ...valid, namespaceAndName })} disabled={loading} /> - >} + onChange={setRepo as React.Dispatch>} disabled={loading} setValid={(contact: boolean) => setValid({ ...valid, contact })} /> diff --git a/scm-ui/ui-webapp/src/repos/components/NamespaceAndNameFields.tsx b/scm-ui/ui-webapp/src/repos/components/NamespaceAndNameFields.tsx index 85f2d93848..51ec213280 100644 --- a/scm-ui/ui-webapp/src/repos/components/NamespaceAndNameFields.tsx +++ b/scm-ui/ui-webapp/src/repos/components/NamespaceAndNameFields.tsx @@ -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 = ({ 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"), diff --git a/scm-ui/ui-webapp/src/repos/components/RepositoryInformationForm.tsx b/scm-ui/ui-webapp/src/repos/components/RepositoryInformationForm.tsx index 7de1c9201a..32f40ce766 100644 --- a/scm-ui/ui-webapp/src/repos/components/RepositoryInformationForm.tsx +++ b/scm-ui/ui-webapp/src/repos/components/RepositoryInformationForm.tsx @@ -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 = ({ repository, onChange, disabled, setValid }) => { diff --git a/scm-ui/ui-webapp/src/repos/components/form/RepositoryFormSwitcher.tsx b/scm-ui/ui-webapp/src/repos/components/form/RepositoryFormSwitcher.tsx index 81b8377935..4edff0992c 100644 --- a/scm-ui/ui-webapp/src/repos/components/form/RepositoryFormSwitcher.tsx +++ b/scm-ui/ui-webapp/src/repos/components/form/RepositoryFormSwitcher.tsx @@ -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 = ({ 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 = ({ path, icon, label }) => { + const location = useLocation(); + const href = urls.concat("/repos/create", path); + const isSelected = href === location.pathname; return ( - - - -

{t("repositoryForm.createButton")}

-
- - -

{t("repositoryForm.importButton")}

-
- - } - /> + + +

{label}

+
); }; +type Props = { + forms: RepositoryForm[]; +}; + +const RepositoryFormSwitcher: FC = ({ forms }) => ( + + {(forms || []).map(form => ( + + ))} + + } + /> +); + export default RepositoryFormSwitcher; diff --git a/scm-ui/ui-webapp/src/repos/containers/CreateRepository.tsx b/scm-ui/ui-webapp/src/repos/containers/CreateRepository.tsx index 270870dea4..ee9b90c231 100644 --- a/scm-ui/ui-webapp/src/repos/containers/CreateRepository.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/CreateRepository.tsx @@ -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 = ({ repositoryTypes, namespaceStrategies, index }) => { const { isLoading, error, repository, create } = useCreateRepository(); - const [t] = useTranslation("repos"); if (repository) { return ; } return ( - } - loading={isPageLoading} - error={pageLoadingError || error || undefined} - showContentOnError={true} - > - {namespaceStrategies && repositoryTypes ? ( - - ) : null} - + <> + + + ); }; diff --git a/scm-ui/ui-webapp/src/repos/containers/CreateRepositoryRoot.tsx b/scm-ui/ui-webapp/src/repos/containers/CreateRepositoryRoot.tsx new file mode 100644 index 0000000000..641409bd96 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/containers/CreateRepositoryRoot.tsx @@ -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 = ({ creator, creators }) => { + const { isPageLoading, pageLoadingError, namespaceStrategies, repositoryTypes, index } = useCreateRepositoryData(); + const [t] = useTranslation(["repos", "plugins"]); + + const Component = creator.component; + + return ( + } + loading={isPageLoading} + error={pageLoadingError} + > + {namespaceStrategies && repositoryTypes && index ? ( + + ) : null} + + ); +}; + +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("repos.creator"); + if (extCreators) { + creators.push(...extCreators); + } + + return ( + + {creators.map(creator => ( + + + + ))} + + ); +}; + +export default CreateRepositoryRoot; diff --git a/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx b/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx index 07041ec76a..844311d86d 100644 --- a/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx @@ -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 = ({ repositoryTypes, nameForm, informationForm }) => { const [importPending, setImportPending] = useState(false); const [repositoryType, setRepositoryType] = useState(); 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 ( - } - loading={isLoading} - error={error || undefined} - showContentOnError={true} - > + <> { )} {importType && renderImportComponent()} - + ); }; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeDto.java index f28cdeb948..3fbd5c41ae 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeDto.java @@ -21,26 +21,28 @@ * 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.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); } } + + diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java index cc8e9d8be9..6b34efe051 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java @@ -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 { + 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()); } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java index fd528a2e8a..3997895536 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java @@ -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 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()); + } } }