diff --git a/gradle/changelog/overflow_menu.yaml b/gradle/changelog/overflow_menu.yaml new file mode 100644 index 0000000000..77b8f2b35c --- /dev/null +++ b/gradle/changelog/overflow_menu.yaml @@ -0,0 +1,2 @@ +- type: added + description: Extension point to render file actions in overflow menu ([#2015](https://github.com/scm-manager/scm-manager/pull/2015)) diff --git a/scm-ui/ui-extensions/src/extensionPoints.ts b/scm-ui/ui-extensions/src/extensionPoints.ts index d4e0c7b7dd..0af3f6e583 100644 --- a/scm-ui/ui-extensions/src/extensionPoints.ts +++ b/scm-ui/ui-extensions/src/extensionPoints.ts @@ -22,7 +22,7 @@ * SOFTWARE. */ -import React, { ReactNode } from "react"; +import React, { ComponentType, ReactNode } from "react"; import { Branch, Changeset, @@ -48,6 +48,7 @@ import { import { ExtensionPointDefinition } from "./binder"; import { RenderableExtensionPointDefinition, SimpleRenderableDynamicExtensionPointDefinition } from "./ExtensionPoint"; import ExtractProps from "./extractProps"; +import { ContentType } from "@scm-manager/ui-api"; type RepositoryCreatorSubFormProps = { repository: RepositoryCreation; @@ -62,7 +63,8 @@ export type RepositoryCreatorComponentProps = ExtractProps; informationForm: React.ComponentType; }>; - }>; + } +>; -export type RepositoryFlags = RenderableExtensionPointDefinition<"repository.flags", - { repository: Repository; tooltipLocation?: "bottom" | "right" | "top" | "left" }>; +export type RepositoryFlags = RenderableExtensionPointDefinition< + "repository.flags", + { repository: Repository; tooltipLocation?: "bottom" | "right" | "top" | "left" } +>; /** * @deprecated use {@link ReposSourcesActionbar}`["props"]` instead @@ -89,7 +94,8 @@ export type ReposSourcesActionbarExtensionProps = ReposSourcesActionbar["props"] * @deprecated use {@link ReposSourcesActionbar} instead */ export type ReposSourcesActionbarExtension = ReposSourcesActionbar; -export type ReposSourcesActionbar = RenderableExtensionPointDefinition<"repos.sources.actionbar", +export type ReposSourcesActionbar = RenderableExtensionPointDefinition< + "repos.sources.actionbar", { baseUrl: string; revision: string; @@ -97,7 +103,8 @@ export type ReposSourcesActionbar = RenderableExtensionPointDefinition<"repos.so path: string; sources: File; repository: Repository; - }>; + } +>; /** * @deprecated use {@link ReposSourcesEmptyActionbar}`["props"]` instead @@ -107,11 +114,13 @@ export type ReposSourcesEmptyActionbarExtensionProps = ReposSourcesEmptyActionba * @deprecated use {@link ReposSourcesEmptyActionbar} instead */ export type ReposSourcesEmptyActionbarExtension = ReposSourcesEmptyActionbar; -export type ReposSourcesEmptyActionbar = RenderableExtensionPointDefinition<"repos.sources.empty.actionbar", +export type ReposSourcesEmptyActionbar = RenderableExtensionPointDefinition< + "repos.sources.empty.actionbar", { sources: File; repository: Repository; - }>; + } +>; /** * @deprecated use {@link ReposSourcesTreeWrapper}`["props"]` instead @@ -122,13 +131,15 @@ export type ReposSourcesTreeWrapperProps = ReposSourcesTreeWrapper["props"]; * @deprecated use {@link ReposSourcesTreeWrapper} instead */ export type ReposSourcesTreeWrapperExtension = ReposSourcesTreeWrapper; -export type ReposSourcesTreeWrapper = RenderableExtensionPointDefinition<"repos.source.tree.wrapper", +export type ReposSourcesTreeWrapper = RenderableExtensionPointDefinition< + "repos.source.tree.wrapper", { repository: Repository; directory: File; baseUrl: string; revision: string; - }>; + } +>; export type ReposSourcesTreeRowProps = { repository: Repository; @@ -139,15 +150,19 @@ export type ReposSourcesTreeRowProps = { * @deprecated use {@link ReposSourcesTreeRowRight} instead */ export type ReposSourcesTreeRowRightExtension = ReposSourcesTreeRowRight; -export type ReposSourcesTreeRowRight = RenderableExtensionPointDefinition<"repos.sources.tree.row.right", - ReposSourcesTreeRowProps>; +export type ReposSourcesTreeRowRight = RenderableExtensionPointDefinition< + "repos.sources.tree.row.right", + ReposSourcesTreeRowProps +>; /** * @deprecated use {@link ReposSourcesTreeRowAfter} instead */ export type ReposSourcesTreeRowAfterExtension = ReposSourcesTreeRowAfter; -export type ReposSourcesTreeRowAfter = RenderableExtensionPointDefinition<"repos.sources.tree.row.after", - ReposSourcesTreeRowProps>; +export type ReposSourcesTreeRowAfter = RenderableExtensionPointDefinition< + "repos.sources.tree.row.after", + ReposSourcesTreeRowProps +>; /** * @deprecated use {@link PrimaryNavigationLoginButton}`["props"]` instead @@ -158,7 +173,8 @@ export type PrimaryNavigationLoginButtonProps = PrimaryNavigationLoginButton["pr * use {@link PrimaryNavigationLoginButton} instead */ export type PrimaryNavigationLoginButtonExtension = PrimaryNavigationLoginButton; -export type PrimaryNavigationLoginButton = RenderableExtensionPointDefinition<"primary-navigation.login", +export type PrimaryNavigationLoginButton = RenderableExtensionPointDefinition< + "primary-navigation.login", { links: Links; label: string; @@ -167,7 +183,8 @@ export type PrimaryNavigationLoginButton = RenderableExtensionPointDefinition<"p to: string; className: string; content: React.ReactNode; - }>; + } +>; /** * @deprecated use {@link PrimaryNavigationLogoutButtonExtension}`["props"]` instead @@ -178,19 +195,22 @@ export type PrimaryNavigationLogoutButtonProps = PrimaryNavigationLogoutButton[" * @deprecated use {@link PrimaryNavigationLogoutButton} instead */ export type PrimaryNavigationLogoutButtonExtension = PrimaryNavigationLogoutButton; -export type PrimaryNavigationLogoutButton = RenderableExtensionPointDefinition<"primary-navigation.logout", +export type PrimaryNavigationLogoutButton = RenderableExtensionPointDefinition< + "primary-navigation.logout", { links: Links; label: string; className: string; content: React.ReactNode; - }>; + } +>; /** * @deprecated use {@link SourceExtension}`["props"]` instead */ export type SourceExtensionProps = SourceExtension["props"]; -export type SourceExtension = RenderableExtensionPointDefinition<"repos.sources.extensions", +export type SourceExtension = RenderableExtensionPointDefinition< + "repos.sources.extensions", { repository: Repository; baseUrl: string; @@ -198,7 +218,8 @@ export type SourceExtension = RenderableExtensionPointDefinition<"repos.sources. extension: string; sources: File | undefined; path: string; - }>; + } +>; /** * @deprecated use {@link RepositoryOverviewTop}`["props"]` instead @@ -209,12 +230,14 @@ export type RepositoryOverviewTopExtensionProps = RepositoryOverviewTop["props"] * @deprecated use {@link RepositoryOverviewTop} instead */ export type RepositoryOverviewTopExtension = RepositoryOverviewTop; -export type RepositoryOverviewTop = RenderableExtensionPointDefinition<"repository.overview.top", +export type RepositoryOverviewTop = RenderableExtensionPointDefinition< + "repository.overview.top", { page: number; search: string; namespace?: string; - }>; + } +>; /** * @deprecated use {@link RepositoryOverviewLeft} instead @@ -247,8 +270,10 @@ export type AdminSetting = RenderableExtensionPointDefinition<"admin.setting", { * * @deprecated Use `changeset.description.tokens` instead */ -export type ChangesetDescription = RenderableExtensionPointDefinition<"changeset.description", - { changeset: Changeset; value: string }>; +export type ChangesetDescription = RenderableExtensionPointDefinition< + "changeset.description", + { changeset: Changeset; value: string } +>; /** * - Can be used to replace parts of a changeset description with components @@ -257,19 +282,28 @@ export type ChangesetDescription = RenderableExtensionPointDefinition<"changeset * - replacement: The component to take instead of the text to replace * - replaceAll: Optional boolean; if set to `true`, all occurances of the text will be replaced (default: `false`) */ -export type ChangesetDescriptionTokens = ExtensionPointDefinition<"changeset.description.tokens", - (changeset: Changeset, value: string) => Array<{ +export type ChangesetDescriptionTokens = ExtensionPointDefinition< + "changeset.description.tokens", + ( + changeset: Changeset, + value: string + ) => Array<{ textToReplace: string; replacement: ReactNode; replaceAll?: boolean; }>, - { changeset: Changeset; value: string }>; + { changeset: Changeset; value: string } +>; -export type ChangesetRight = RenderableExtensionPointDefinition<"changeset.right", - { repository: Repository; changeset: Changeset }>; +export type ChangesetRight = RenderableExtensionPointDefinition< + "changeset.right", + { repository: Repository; changeset: Changeset } +>; -export type ChangesetsAuthorSuffix = RenderableExtensionPointDefinition<"changesets.author.suffix", - { changeset: Changeset }>; +export type ChangesetsAuthorSuffix = RenderableExtensionPointDefinition< + "changesets.author.suffix", + { changeset: Changeset } +>; export type GroupNavigation = RenderableExtensionPointDefinition<"group.navigation", { group: Group; url: string }>; @@ -280,16 +314,20 @@ export type GroupSetting = RenderableExtensionPointDefinition<"group.setting", { * - Add a new Route to the main Route (scm/) * - Props: authenticated?: boolean, links: Links */ -export type MainRoute = RenderableExtensionPointDefinition<"main.route", +export type MainRoute = RenderableExtensionPointDefinition< + "main.route", { me: Me; authenticated?: boolean; - }>; + } +>; -export type PluginAvatar = RenderableExtensionPointDefinition<"plugins.plugin-avatar", +export type PluginAvatar = RenderableExtensionPointDefinition< + "plugins.plugin-avatar", { plugin: Plugin; - }>; + } +>; export type PrimaryNavigation = RenderableExtensionPointDefinition<"primary-navigation", { links: Links }>; @@ -298,31 +336,44 @@ export type PrimaryNavigation = RenderableExtensionPointDefinition<"primary-navi * - A PrimaryNavigationLink Component can be used here * - Actually this Extension Point is used from the Activity Plugin to display the activities at the first Main Navigation menu. */ -export type PrimaryNavigationFirstMenu = RenderableExtensionPointDefinition<"primary-navigation.first-menu", - { links: Links; label: string }>; +export type PrimaryNavigationFirstMenu = RenderableExtensionPointDefinition< + "primary-navigation.first-menu", + { links: Links; label: string } +>; export type ProfileRoute = RenderableExtensionPointDefinition<"profile.route", { me: Me; url: string }>; -export type ProfileSetting = RenderableExtensionPointDefinition<"profile.setting", - { me?: Me; url: string; links: Links }>; +export type ProfileSetting = RenderableExtensionPointDefinition< + "profile.setting", + { me?: Me; url: string; links: Links } +>; -export type RepoConfigRoute = RenderableExtensionPointDefinition<"repo-config.route", - { repository: Repository; url: string }>; +export type RepoConfigRoute = RenderableExtensionPointDefinition< + "repo-config.route", + { repository: Repository; url: string } +>; -export type RepoConfigDetails = RenderableExtensionPointDefinition<"repo-config.details", - { repository: Repository; url: string }>; +export type RepoConfigDetails = RenderableExtensionPointDefinition< + "repo-config.details", + { repository: Repository; url: string } +>; -export type ReposBranchDetailsInformation = RenderableExtensionPointDefinition<"repos.branch-details.information", - { repository: Repository; branch: Branch }>; +export type ReposBranchDetailsInformation = RenderableExtensionPointDefinition< + "repos.branch-details.information", + { repository: Repository; branch: Branch } +>; /** * - Location: At meta data view for file * - can be used to render additional meta data line * - Props: file: string, repository: Repository, revision: string */ -export type ReposContentMetaData = RenderableExtensionPointDefinition<"repos.content.metadata", - { file: File; repository: Repository; revision: string }>; +export type ReposContentMetaData = RenderableExtensionPointDefinition< + "repos.content.metadata", + { file: File; repository: Repository; revision: string } +>; -export type ReposCreateNamespace = RenderableExtensionPointDefinition<"repos.create.namespace", +export type ReposCreateNamespace = RenderableExtensionPointDefinition< + "repos.create.namespace", { label: string; helpText: string; @@ -330,54 +381,75 @@ export type ReposCreateNamespace = RenderableExtensionPointDefinition<"repos.cre onChange: (namespace: string) => void; errorMessage: string; validationError?: boolean; - }>; + } +>; -export type ReposSourcesContentActionBar = RenderableExtensionPointDefinition<"repos.sources.content.actionbar", +export type ReposSourcesContentActionBar = RenderableExtensionPointDefinition< + "repos.sources.content.actionbar", { repository: Repository; file: File; revision: string; handleExtensionError: React.Dispatch>; - }>; + } +>; -export type RepositoryNavigation = RenderableExtensionPointDefinition<"repository.navigation", - { repository: Repository; url: string; indexLinks: Links }>; +export type RepositoryNavigation = RenderableExtensionPointDefinition< + "repository.navigation", + { repository: Repository; url: string; indexLinks: Links } +>; -export type RepositoryNavigationTopLevel = RenderableExtensionPointDefinition<"repository.navigation.topLevel", - { repository: Repository; url: string; indexLinks: Links }>; +export type RepositoryNavigationTopLevel = RenderableExtensionPointDefinition< + "repository.navigation.topLevel", + { repository: Repository; url: string; indexLinks: Links } +>; -export type RepositoryRoleDetailsInformation = RenderableExtensionPointDefinition<"repositoryRole.role-details.information", - { role: RepositoryRole }>; +export type RepositoryRoleDetailsInformation = RenderableExtensionPointDefinition< + "repositoryRole.role-details.information", + { role: RepositoryRole } +>; -export type RepositorySetting = RenderableExtensionPointDefinition<"repository.setting", - { repository: Repository; url: string; indexLinks: Links }>; +export type RepositorySetting = RenderableExtensionPointDefinition< + "repository.setting", + { repository: Repository; url: string; indexLinks: Links } +>; -export type RepositoryAvatar = RenderableExtensionPointDefinition<"repos.repository-avatar", - { repository: Repository }>; +export type RepositoryAvatar = RenderableExtensionPointDefinition< + "repos.repository-avatar", + { repository: Repository } +>; /** * - Location: At each repository in repository overview * - can be used to add avatar for each repository (e.g., to mark repository type) */ -export type PrimaryRepositoryAvatar = RenderableExtensionPointDefinition<"repos.repository-avatar.primary", - { repository: Repository }>; +export type PrimaryRepositoryAvatar = RenderableExtensionPointDefinition< + "repos.repository-avatar.primary", + { repository: Repository } +>; /** * - Location: At bottom of a single repository view * - can be used to show detailed information about the repository (how to clone, e.g.) */ -export type RepositoryDetailsInformation = RenderableExtensionPointDefinition<"repos.repository-details.information", - { repository: Repository }>; +export type RepositoryDetailsInformation = RenderableExtensionPointDefinition< + "repos.repository-details.information", + { repository: Repository } +>; /** * - Location: At sources viewer * - can be used to render a special source that is not an image or a source code */ -export type RepositorySourcesView = RenderableExtensionPointDefinition<"repos.sources.view", - { file: File; contentType: string; revision: string; basePath: string }>; +export type RepositorySourcesView = RenderableExtensionPointDefinition< + "repos.sources.view", + { file: File; contentType: string; revision: string; basePath: string } +>; -export type RolesRoute = RenderableExtensionPointDefinition<"roles.route", - { role: HalRepresentation & RepositoryRoleBase & { creationDate?: string; lastModified?: string }; url: string }>; +export type RolesRoute = RenderableExtensionPointDefinition< + "roles.route", + { role: HalRepresentation & RepositoryRoleBase & { creationDate?: string; lastModified?: string }; url: string } +>; export type UserRoute = RenderableExtensionPointDefinition<"user.route", { user: User; url: string }>; export type UserSetting = RenderableExtensionPointDefinition<"user.setting", { user: User; url: string }>; @@ -388,13 +460,15 @@ export type UserSetting = RenderableExtensionPointDefinition<"user.setting", { u * - Used by the Markdown Plantuml Plugin */ export type MarkdownCodeRenderer = - SimpleRenderableDynamicExtensionPointDefinition<"markdown-renderer.code.", + SimpleRenderableDynamicExtensionPointDefinition< + "markdown-renderer.code.", Language, { language?: Language extends string ? Language : string; value: string; indexLinks: Links; - }>; + } + >; /** * - Define custom protocols and their renderers for links in markdown @@ -408,14 +482,16 @@ export type MarkdownCodeRenderer>("markdown-renderer.link.protocol", { protocol: "myprotocol", renderer: MyProtocolRenderer }) * ``` */ -export type MarkdownLinkProtocolRenderer = ExtensionPointDefinition<"markdown-renderer.link.protocol", +export type MarkdownLinkProtocolRenderer = ExtensionPointDefinition< + "markdown-renderer.link.protocol", { protocol: Protocol extends string ? Protocol : string; renderer: React.ComponentType<{ protocol: Protocol extends string ? Protocol : string; href: string; }>; - }>; + } +>; /** * Used to determine an avatar image url from a given {@link Person}. @@ -430,8 +506,10 @@ export type AvatarFactory = ExtensionPointDefinition<"avatar.factory", (person: * * @deprecated Has no effect, use {@link AvatarFactory} instead */ -export type ChangesetAvatarFactory = ExtensionPointDefinition<"changeset.avatar-factory", - (changeset: Changeset) => void>; +export type ChangesetAvatarFactory = ExtensionPointDefinition< + "changeset.avatar-factory", + (changeset: Changeset) => void +>; type MainRedirectProps = { me: Me; @@ -442,9 +520,11 @@ type MainRedirectProps = { * - Extension Point for a link factory that provide the Redirect Link * - Actually used from the activity plugin: binder.bind("main.redirect", () => "/activity"); */ -export type MainRedirect = ExtensionPointDefinition<"main.redirect", +export type MainRedirect = ExtensionPointDefinition< + "main.redirect", (props: MainRedirectProps) => string, - MainRedirectProps>; + MainRedirectProps +>; /** * - A Factory function to create markdown [renderer](https://github.com/rexxars/react-markdown#node-types) @@ -452,13 +532,18 @@ export type MainRedirect = ExtensionPointDefinition<"main.redirect", * * @deprecated Use {@link MarkdownCodeRenderer} or {@link MarkdownLinkProtocolRenderer} instead */ -export type MarkdownRendererFactory = ExtensionPointDefinition<"markdown-renderer-factory", - (renderContext: unknown) => Record>>; +export type MarkdownRendererFactory = ExtensionPointDefinition< + "markdown-renderer-factory", + (renderContext: unknown) => Record> +>; -export type RepositoryCardBeforeTitle = RenderableExtensionPointDefinition<"repository.card.beforeTitle", - { repository: Repository }>; +export type RepositoryCardBeforeTitle = RenderableExtensionPointDefinition< + "repository.card.beforeTitle", + { repository: Repository } +>; -export type RepositoryCreationInitialization = RenderableExtensionPointDefinition<"repos.create.initialize", +export type RepositoryCreationInitialization = RenderableExtensionPointDefinition< + "repos.create.initialize", { repository: Repository; setCreationContextEntry: (key: string, value: any) => void; @@ -467,28 +552,43 @@ export type RepositoryCreationInitialization = RenderableExtensionPointDefinitio version?: string; initialization?: string; }; - }>; + } +>; -export type NamespaceTopLevelNavigation = RenderableExtensionPointDefinition<"namespace.navigation.topLevel", - { namespace: Namespace; url: string }>; +export type NamespaceTopLevelNavigation = RenderableExtensionPointDefinition< + "namespace.navigation.topLevel", + { namespace: Namespace; url: string } +>; -export type NamespaceRoute = RenderableExtensionPointDefinition<"namespace.route", - { namespace: Namespace; url: string }>; +export type NamespaceRoute = RenderableExtensionPointDefinition< + "namespace.route", + { namespace: Namespace; url: string } +>; -export type NamespaceSetting = RenderableExtensionPointDefinition<"namespace.setting", - { namespace: Namespace; url: string }>; +export type NamespaceSetting = RenderableExtensionPointDefinition< + "namespace.setting", + { namespace: Namespace; url: string } +>; -export type RepositoryTagDetailsInformation = RenderableExtensionPointDefinition<"repos.tag-details.information", - { repository: Repository; tag: Tag }>; +export type RepositoryTagDetailsInformation = RenderableExtensionPointDefinition< + "repos.tag-details.information", + { repository: Repository; tag: Tag } +>; -export type SearchHitRenderer = RenderableExtensionPointDefinition; +export type SearchHitRenderer = RenderableExtensionPointDefinition< + Type extends string ? `search.hit.${Type}.renderer` : `search.hit.${string}.renderer`, + { hit: Hit } +>; -export type RepositorySourcesContentDownloadButton = RenderableExtensionPointDefinition<"repos.sources.content.downloadButton", - { repository: Repository; file: File }>; +export type RepositorySourcesContentDownloadButton = RenderableExtensionPointDefinition< + "repos.sources.content.downloadButton", + { repository: Repository; file: File } +>; -export type RepositoryRoute = RenderableExtensionPointDefinition<"repository.route", - { repository: Repository; url: string; indexLinks: Links }>; +export type RepositoryRoute = RenderableExtensionPointDefinition< + "repository.route", + { repository: Repository; url: string; indexLinks: Links } +>; type RepositoryRedirectProps = { namespace: string; @@ -509,13 +609,44 @@ type RepositoryRedirectProps = { }; }; -export type RepositoryRedirect = ExtensionPointDefinition<"repository.redirect", +export type RepositoryRedirect = ExtensionPointDefinition< + "repository.redirect", (props: RepositoryRedirectProps) => string, - RepositoryRedirectProps>; + RepositoryRedirectProps +>; export type InitializationStep = - SimpleRenderableDynamicExtensionPointDefinition<"initialization.step.", + SimpleRenderableDynamicExtensionPointDefinition< + "initialization.step.", Step, { data: HalRepresentation; - }>; + } + >; + +export type ContentActionExtensionProps = { + repository: Repository; + file: File; + revision: string; + handleExtensionError: React.Dispatch>; + contentType?: ContentType; +}; + +type BaseActionBarOverflowMenuProps = { + category: string; + label: string; + icon: string; + props?: unknown; +}; + +export type ActionMenuProps = BaseActionBarOverflowMenuProps & { action: (props: ContentActionExtensionProps) => void }; +export type ModalMenuProps = BaseActionBarOverflowMenuProps & { + modalElement: ComponentType void }>; +}; +export type LinkMenuProps = BaseActionBarOverflowMenuProps & { link: (props: ContentActionExtensionProps) => string }; + +export type FileViewActionBarOverflowMenu = ExtensionPointDefinition< + "repos.sources.content.actionbar.menu", + ActionMenuProps | ModalMenuProps | LinkMenuProps, + ContentActionExtensionProps +>; diff --git a/scm-ui/ui-webapp/package.json b/scm-ui/ui-webapp/package.json index dec0456c9d..242fb29247 100644 --- a/scm-ui/ui-webapp/package.json +++ b/scm-ui/ui-webapp/package.json @@ -24,7 +24,8 @@ "redux": "^4.0.0", "string_score": "^0.1.22", "styled-components": "^5.3.5", - "systemjs": "0.21.6" + "systemjs": "0.21.6", + "@headlessui/react": "^1.4.3" }, "scripts": { "test": "jest", diff --git a/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/ActionMenuItem.tsx b/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/ActionMenuItem.tsx new file mode 100644 index 0000000000..2078b7da67 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/ActionMenuItem.tsx @@ -0,0 +1,60 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC } from "react"; +import { useTranslation } from "react-i18next"; +import classNames from "classnames"; +import { Icon } from "@scm-manager/ui-components"; +import { extensionPoints } from "@scm-manager/ui-extensions"; +import { MenuItemContainer } from "./ContentActionMenu"; + +const ActionMenuItem: FC< + extensionPoints.ActionMenuProps & { + active: boolean; + onClick: (event: React.MouseEvent) => void; + extensionProps: extensionPoints.ContentActionExtensionProps; + } +> = ({ action, active, label, icon, props, extensionProps, ...rest }) => { + const [t] = useTranslation("plugins"); + + return ( + { + rest.onClick(event); + action(extensionProps); + }} + > + + {t(label)} + + ); +}; + +export default ActionMenuItem; diff --git a/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/ContentActionMenu.tsx b/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/ContentActionMenu.tsx new file mode 100644 index 0000000000..f365224a7d --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/ContentActionMenu.tsx @@ -0,0 +1,145 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { binder, extensionPoints } from "@scm-manager/ui-extensions"; +import React, { FC, ReactElement, useState } from "react"; +import { Icon } from "@scm-manager/ui-components"; +import styled from "styled-components"; +import { Menu } from "@headlessui/react"; +import FallbackMenuButton from "./FallbackMenuButton"; +import MenuItem from "./MenuItem"; + +const MenuButton = styled(Menu.Button)` + background: transparent; + border: none; + font-size: 1.5rem; + height: 2.5rem; + width: 50px; + margin-bottom: 0.5rem; +`; + +const MenuItems = styled(Menu.Items)` + padding: 0.5rem; + position: absolute; + z-index: 999; + width: max-content; + border: var(--scm-border); + border-radius: 5px; + background-color: var(--scm-secondary-background); + box-shadow: 0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1), 0 0px 0 1px rgba(10, 10, 10, 0.02); +`; + +export const MenuItemContainer = styled.div` + border-radius: 5px; + padding: 0.5rem; +`; + +const HR = styled.hr` + margin: 0.25rem; + background: var(--scm-border-color); +`; + +type Props = { + extensionProps: extensionPoints.ContentActionExtensionProps; +}; + +const ContentActionMenu: FC = ({ extensionProps }) => { + const [selectedModal, setSelectedModal] = useState(); + const extensions = binder.getExtensions( + "repos.sources.content.actionbar.menu", + extensionProps + ); + const categories = extensions.reduce>( + (result, extension) => { + if (!(extension.category in result)) { + result[extension.category] = []; + } + result[extension.category].push(extension); + return result; + }, + {} + ); + + const renderMenu = () => ( + <> + + {({ open }) => ( + <> + + + + {open && ( +
+ + {Object.entries(categories).map(([_category, extensionSet], index) => ( + <> + {extensionSet.map((extension) => ( + + {({ active }) => { + return ( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore onClick prop required but gets provided implicit by the Menu.Item from headless ui + + ); + }} + + ))} + {Object.keys(categories).length > index + 1 ?
: null} + + ))} +
+
+ )} + + )} +
+ + ); + + if (extensions.length <= 0) { + return null; + } + + return ( + <> + {extensions.length === 1 ? ( + + ) : ( + renderMenu() + )} + {selectedModal || null} + + ); +}; + +export default ContentActionMenu; diff --git a/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/FallbackMenuButton.tsx b/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/FallbackMenuButton.tsx new file mode 100644 index 0000000000..45db0bc334 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/FallbackMenuButton.tsx @@ -0,0 +1,91 @@ +/* + * 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, ReactElement } from "react"; +import { Button, Icon } from "@scm-manager/ui-components"; +import styled from "styled-components"; +import { Link } from "react-router-dom"; +import { extensionPoints } from "@scm-manager/ui-extensions"; +import { useTranslation } from "react-i18next"; + +const FallbackButton = styled(Button)` + height: 2.5rem; + width: 50px; + margin-bottom: 0.5rem; + > i { + padding: 0 !important; + } + &:hover { + color: var(--scm-link-color); + } +`; + +const FallbackLink = styled(Link)` + width: 50px; + &:hover { + color: var(--scm-link-color); + } +`; + +const FallbackMenuButton: FC<{ + extension: extensionPoints.FileViewActionBarOverflowMenu["type"]; + extensionProps: extensionPoints.ContentActionExtensionProps; + setSelectedModal: (element: ReactElement | undefined) => void; +}> = ({ extension, extensionProps, setSelectedModal }) => { + const [t] = useTranslation("plugins"); + if ("action" in extension) { + return ( + extension.action(extensionProps)} + /> + ); + } + if ("link" in extension) { + return ( + + + + ); + } + if ("modalElement" in extension) { + return ( + + setSelectedModal( + React.createElement(extension.modalElement, { + ...extensionProps, + close: () => setSelectedModal(undefined), + }) + ) + } + /> + ); + } + return null; +}; +export default FallbackMenuButton; diff --git a/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/LinkMenuItem.tsx b/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/LinkMenuItem.tsx new file mode 100644 index 0000000000..c0381f2a08 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/LinkMenuItem.tsx @@ -0,0 +1,64 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC } from "react"; +import { useTranslation } from "react-i18next"; +import classNames from "classnames"; +import { Icon } from "@scm-manager/ui-components"; +import styled from "styled-components"; +import { Link } from "react-router-dom"; +import { extensionPoints } from "@scm-manager/ui-extensions"; + +const MenuItemLinkContainer = styled(Link)<{ active: boolean }>` + border-radius: 5px; + padding: 0.5rem; + color: ${(props) => (props.active ? "var(--scm-white-color)" : "inherit")}; + :hover { + color: var(--scm-white-color); + } +`; + +const LinkMenuItem: FC< + extensionPoints.LinkMenuProps & { active: boolean; extensionProps: extensionPoints.ContentActionExtensionProps } +> = ({ link, active, label, icon, props, extensionProps, ...rest }) => { + const [t] = useTranslation("plugins"); + + return ( + + + {t(label)} + + ); +}; + +export default LinkMenuItem; diff --git a/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/MenuItem.tsx b/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/MenuItem.tsx new file mode 100644 index 0000000000..77e4eb2b65 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/MenuItem.tsx @@ -0,0 +1,84 @@ +/* + * 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, ReactElement } from "react"; +import ActionMenuItem from "./ActionMenuItem"; +import LinkMenuItem from "./LinkMenuItem"; +import ModalMenuItem from "./ModalMenuItem"; +import { extensionPoints } from "@scm-manager/ui-extensions"; + +const MenuItem: FC< + extensionPoints.FileViewActionBarOverflowMenu["type"] & { + active: boolean; + onClick: (event: React.MouseEvent) => void; + setSelectedModal: (element: ReactElement | undefined) => void; + extensionProps: extensionPoints.ContentActionExtensionProps; + } +> = ({ extensionProps, label, icon, props, category, active, onClick, setSelectedModal, ...rest }) => { + if ("action" in rest) { + return ( + + ); + } + if ("link" in rest) { + return ( + + ); + } + if ("modalElement" in rest) { + return ( + + ); + } + return null; +}; + +export default MenuItem; diff --git a/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/ModalMenuItem.tsx b/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/ModalMenuItem.tsx new file mode 100644 index 0000000000..6c4df6b689 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/sources/components/content/overflowMenu/ModalMenuItem.tsx @@ -0,0 +1,63 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC, ReactElement } from "react"; +import { useTranslation } from "react-i18next"; +import classNames from "classnames"; +import { Icon } from "@scm-manager/ui-components"; +import { MenuItemContainer } from "./ContentActionMenu"; +import { extensionPoints } from "@scm-manager/ui-extensions"; + +const ModalMenuItem: FC< + extensionPoints.ModalMenuProps & { + active: boolean; + onClick: (event: React.MouseEvent) => void; + setSelectedModal: (element: ReactElement | undefined) => void; + extensionProps: extensionPoints.ContentActionExtensionProps; + } +> = ({ modalElement, active, label, icon, props, extensionProps, setSelectedModal, ...rest }) => { + const [t] = useTranslation("plugins"); + + return ( + { + setSelectedModal( + React.createElement(modalElement, { ...extensionProps, close: () => setSelectedModal(undefined) }) + ); + rest.onClick(event); + }} + > + + {t(label)} + + ); +}; + +export default ModalMenuItem; diff --git a/scm-ui/ui-webapp/src/repos/sources/containers/Content.tsx b/scm-ui/ui-webapp/src/repos/sources/containers/Content.tsx index 4235fd9cd8..516b6c2c79 100644 --- a/scm-ui/ui-webapp/src/repos/sources/containers/Content.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/containers/Content.tsx @@ -26,12 +26,14 @@ import { useTranslation } from "react-i18next"; import classNames from "classnames"; import styled from "styled-components"; import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; -import { File, Repository } from "@scm-manager/ui-types"; +import { File, Link, Repository } from "@scm-manager/ui-types"; import { DateFromNow, ErrorNotification, FileSize, Icon, OpenInFullscreenButton } from "@scm-manager/ui-components"; import FileButtonAddons from "../components/content/FileButtonAddons"; import SourcesView from "./SourcesView"; import HistoryView from "./HistoryView"; import AnnotateView from "./AnnotateView"; +import ContentActionMenu from "../components/content/overflowMenu/ContentActionMenu"; +import { useContentType } from "@scm-manager/ui-api"; type Props = { file: File; @@ -70,6 +72,7 @@ const Content: FC = ({ file, repository, revision, breadcrumb, error }) = const [collapsed, setCollapsed] = useState(true); const [selected, setSelected] = useState("source"); const [errorFromExtension, setErrorFromExtension] = useState(); + const { data: contentType } = useContentType((file._links.self as Link).href); const wrapContent = (content: ReactNode) => { return ( @@ -119,6 +122,14 @@ const Content: FC = ({ file, repository, revision, breadcrumb, error }) = /> ) : null; + const extensionProps: extensionPoints.ContentActionExtensionProps = { + repository, + file, + revision: revision ? encodeURIComponent(revision) : "", + handleExtensionError: setErrorFromExtension, + contentType, + }; + return (
@@ -138,14 +149,10 @@ const Content: FC = ({ file, repository, revision, breadcrumb, error }) = /> name="repos.sources.content.actionbar" - props={{ - repository, - file, - revision: revision ? encodeURIComponent(revision) : "", - handleExtensionError: setErrorFromExtension - }} + props={extensionProps} renderAll={true} /> +
@@ -201,7 +208,7 @@ const Content: FC = ({ file, repository, revision, breadcrumb, error }) = props={{ file, repository, - revision + revision, }} />