From 4d203ff36fca13e434248e4f9b78b68eb2ed579d Mon Sep 17 00:00:00 2001 From: Konstantin Schaper Date: Tue, 29 Mar 2022 15:04:14 +0200 Subject: [PATCH] codify extension points docs (#1947) This pull request converts the current incomplete textual documentation of the available frontend extension points to in-code definitions that act both as documentation and as type helpers for improving overall code quality. All extension points available in the SCM-Manager core are now available, but no plugin was updated and only those parts of the core codebase had the new types added that did not require runtime changes. The only exception to this is the breadcrumbs, which was a simple change that is fully backwards-compatible. --- .../development/plugins/extension-points.md | 121 +--- .../scm-git-plugin/src/main/js/index.ts | 15 +- .../scm-hg-plugin/src/main/js/index.ts | 17 +- .../scm-svn-plugin/src/main/js/index.ts | 8 +- scm-ui/ui-components/src/Breadcrumb.tsx | 20 +- scm-ui/ui-components/src/avatar/Avatar.ts | 3 +- .../src/avatar/AvatarWrapper.tsx | 4 +- .../src/layout/Footer.stories.tsx | 9 +- scm-ui/ui-components/src/layout/Footer.tsx | 10 +- .../src/markdown/MarkdownCodeRenderer.tsx | 11 +- .../src/markdown/MarkdownView.stories.tsx | 16 +- .../src/markdown/markdownExtensions.ts | 19 +- .../src/navigation/PrimaryNavigation.tsx | 17 +- .../src/repos/RepositoryAvatar.tsx | 6 +- .../src/repos/RepositoryEntry.tsx | 9 +- .../src/repos/RepositoryFlags.tsx | 8 +- .../src/repos/changesets/ChangesetAuthor.tsx | 8 +- .../repos/changesets/ChangesetDescription.tsx | 19 +- .../src/repos/changesets/ChangesetRow.tsx | 4 +- .../src/repos/changesets/SingleChangeset.tsx | 4 +- scm-ui/ui-extensions/src/ExtensionPoint.tsx | 100 +++- .../src/{binder.test.ts => binder.test.tsx} | 55 ++ scm-ui/ui-extensions/src/binder.ts | 47 +- scm-ui/ui-extensions/src/extensionPoints.ts | 563 ++++++++++++++---- scm-ui/ui-extensions/src/extractProps.ts | 28 + scm-ui/ui-extensions/src/index.ts | 1 + .../ui-webapp/src/admin/containers/Admin.tsx | 16 +- .../admin/plugins/components/PluginAvatar.tsx | 4 +- .../components/PermissionRoleDetails.tsx | 4 +- .../roles/containers/SingleRepositoryRole.tsx | 4 +- scm-ui/ui-webapp/src/containers/Index.tsx | 9 +- .../ui-webapp/src/containers/LoginButton.tsx | 13 +- .../ui-webapp/src/containers/LogoutButton.tsx | 13 +- scm-ui/ui-webapp/src/containers/Main.tsx | 6 +- scm-ui/ui-webapp/src/containers/Profile.tsx | 8 +- .../src/groups/containers/SingleGroup.tsx | 16 +- scm-ui/ui-webapp/src/index.tsx | 4 +- .../repos/branches/components/BranchView.tsx | 4 +- .../src/repos/components/NamespaceInput.tsx | 10 +- .../repos/components/RepositoryDetails.tsx | 4 +- .../changesets/ChangesetDetails.tsx | 6 +- .../changesets/ContributorTable.tsx | 6 +- .../repos/components/list/RepositoryList.tsx | 4 +- .../src/repos/containers/EditRepo.tsx | 14 +- .../src/repos/containers/Overview.tsx | 14 +- .../src/repos/containers/RepositoryRoot.tsx | 28 +- .../namespaces/containers/NamespaceRoot.tsx | 24 +- .../src/repos/sources/components/FileTree.tsx | 7 +- .../repos/sources/components/FileTreeLeaf.tsx | 15 +- .../components/content/DownloadViewer.tsx | 7 +- .../src/repos/sources/containers/Content.tsx | 6 +- .../repos/sources/containers/SourcesView.tsx | 4 +- .../src/repos/tags/components/TagView.tsx | 4 +- scm-ui/ui-webapp/src/search/Hits.tsx | 6 +- .../src/users/containers/SingleUser.tsx | 10 +- 55 files changed, 962 insertions(+), 430 deletions(-) rename scm-ui/ui-extensions/src/{binder.test.ts => binder.test.tsx} (80%) create mode 100644 scm-ui/ui-extensions/src/extractProps.ts diff --git a/docs/en/development/plugins/extension-points.md b/docs/en/development/plugins/extension-points.md index 2430327e9d..5ffe7fbc04 100644 --- a/docs/en/development/plugins/extension-points.md +++ b/docs/en/development/plugins/extension-points.md @@ -2,123 +2,6 @@ title: Extension Points --- -The following extension points are provided for the frontend: +The available extension points are now maintained in-code, providing typescript types for improved developer experience and code quality assurance. -### admin.navigation -### admin.route -### admin.setting -### changeset.description.tokens -- Can be used to replace parts of a changeset description with components -- Has to be bound with a funktion taking the changeset and the (partial) description and returning `Replacement` objects with the following attributes: - - textToReplace: The text part of the description that should be replaced by a component - - 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`) -### changeset.right -### changesets.author.suffix -### group.navigation -### group.route -### group.setting -### main.route -- Add a new Route to the main Route (scm/) -- Props: authenticated?: boolean, links: Links - -### plugins.plugin-avatar -### primary-navigation -### primary-navigation.first-menu -- A placeholder for the first navigation menu. -- 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. - -### primary-navigation.logout -### profile.route -### profile.setting -### repo-config.route -### repo-config.details -### repos.branch-details.information -### repos.content.metadata -- Location: At meta data view for file -- can be used to render additional meta data line -- Props: file: string, repository: Repository, revision: string - -### repos.create.namespace -### repos.sources.content.actionbar -### repository.navigation -### repository.navigation.topLevel -### repositoryRole.role-details.information -### repository.setting -### repos.repository-avatar -### repos.repository-avatar.primary -- Location: At each repository in repository overview -- can be used to add avatar for each repository (e.g., to mark repository type) - -### repos.repository-details.information -- Location: At bottom of a single repository view -- can be used to show detailed information about the repository (how to clone, e.g.) -### repos.sources.view -### roles.route -### user.route -### user.setting -### markdown-renderer.code.{language} -- Dynamic extension point for custom language-specific renderers -- Overrides the default Syntax Highlighter -- Used by the Markdown Plantuml Plugin -### markdown-renderer.link.protocol -- Define custom protocols and their renderers for links in markdown - -Example: -```markdown -[description](myprotocol:somelink) -``` - -```typescript -binder.bind("markdown-renderer.link.protocol", { protocol: "myprotocol", renderer: MyProtocolRenderer }) -``` - -# Deprecated - -### changeset.description -- can be used to replace the whole description of a changeset - -**Deprecated:** Use `changeset.description.tokens` instead - -### changeset.avatar-factory -- Location: At every changeset (detailed view as well as changeset overview) -- can be used to add avatar (such as gravatar) for each changeset -- expects a function: `(Changeset) => void` - -### repos.sources.view -- Location: At sources viewer -- can be used to render a special source that is not an image or a source code - -### main.redirect -- Extension Point for a link factory that provide the Redirect Link -- Actually used from the activity plugin: binder.bind("main.redirect", () => "/activity"); - -### markdown-renderer-factory -- A Factory function to create markdown [renderer](https://github.com/rexxars/react-markdown#node-types) -- The factory function will be called with a renderContext parameter of type Object. this parameter is given as a prop for the MarkdownView component. - -**example:** - - -```javascript -let MarkdownFactory = (renderContext) => { - - let Heading= (props) => { - return React.createElement(`h${props.level}`, - props['data-sourcepos'] ? {'data-sourcepos': props['data-sourcepos']} : {}, - props.children); - }; - return {heading : Heading}; -}; - -binder.bind("markdown-renderer-factory", MarkdownFactory); -``` - -```javascript - -``` +You can browse and import them directly in your frontend code from the `@scm-manager/ui-extensions` package. diff --git a/scm-plugins/scm-git-plugin/src/main/js/index.ts b/scm-plugins/scm-git-plugin/src/main/js/index.ts index dc0a82613d..c32e969aa9 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/index.ts +++ b/scm-plugins/scm-git-plugin/src/main/js/index.ts @@ -22,7 +22,7 @@ * SOFTWARE. */ -import { binder } from "@scm-manager/ui-extensions"; +import { binder, extensionPoints } from "@scm-manager/ui-extensions"; import ProtocolInformation from "./ProtocolInformation"; import GitAvatar from "./GitAvatar"; @@ -40,9 +40,18 @@ export const gitPredicate = (props: any) => { return !!(props && props.repository && props.repository.type === "git"); }; -binder.bind("repos.repository-details.information", ProtocolInformation, gitPredicate); +binder.bind( + "repos.repository-details.information", + ProtocolInformation, + gitPredicate +); binder.bind("repos.branch-details.information", GitBranchInformation, { priority: 100, predicate: gitPredicate }); -binder.bind("repos.tag-details.information", GitTagInformation, gitPredicate); + +binder.bind( + "repos.tag-details.information", + GitTagInformation, + gitPredicate +); binder.bind("repos.repository-merge.information", GitMergeInformation, gitPredicate); binder.bind("repos.repository-avatar", GitAvatar, gitPredicate); diff --git a/scm-plugins/scm-hg-plugin/src/main/js/index.ts b/scm-plugins/scm-hg-plugin/src/main/js/index.ts index d961af2327..9083e6e4f3 100644 --- a/scm-plugins/scm-hg-plugin/src/main/js/index.ts +++ b/scm-plugins/scm-hg-plugin/src/main/js/index.ts @@ -22,7 +22,7 @@ * SOFTWARE. */ -import { binder } from "@scm-manager/ui-extensions"; +import { binder, extensionPoints } from "@scm-manager/ui-extensions"; import ProtocolInformation from "./ProtocolInformation"; import HgAvatar from "./HgAvatar"; import { ConfigurationBinder as cfgBinder } from "@scm-manager/ui-components"; @@ -35,14 +35,23 @@ const hgPredicate = (props: any) => { return props.repository && props.repository.type === "hg"; }; -binder.bind("repos.repository-details.information", ProtocolInformation, hgPredicate); +binder.bind( + "repos.repository-details.information", + ProtocolInformation, + hgPredicate +); binder.bind("repos.branch-details.information", HgBranchInformation, { priority: 100, predicate: hgPredicate }); -binder.bind("repos.tag-details.information", HgTagInformation, hgPredicate); + +binder.bind( + "repos.tag-details.information", + HgTagInformation, + hgPredicate +); binder.bind("repos.repository-avatar", HgAvatar, hgPredicate); // bind repository specific configuration -binder.bind("repo-config.route", HgRepositoryConfigurationForm, hgPredicate); +binder.bind("repo-config.route", HgRepositoryConfigurationForm, hgPredicate); // bind global configuration diff --git a/scm-plugins/scm-svn-plugin/src/main/js/index.ts b/scm-plugins/scm-svn-plugin/src/main/js/index.ts index 628f037a51..64b9213ab1 100644 --- a/scm-plugins/scm-svn-plugin/src/main/js/index.ts +++ b/scm-plugins/scm-svn-plugin/src/main/js/index.ts @@ -22,7 +22,7 @@ * SOFTWARE. */ -import { binder } from "@scm-manager/ui-extensions"; +import { binder, extensionPoints } from "@scm-manager/ui-extensions"; import { ConfigurationBinder as cfgBinder } from "@scm-manager/ui-components"; import ProtocolInformation from "./ProtocolInformation"; import SvnAvatar from "./SvnAvatar"; @@ -32,7 +32,11 @@ const svnPredicate = (props: any) => { return props.repository && props.repository.type === "svn"; }; -binder.bind("repos.repository-details.information", ProtocolInformation, svnPredicate); +binder.bind( + "repos.repository-details.information", + ProtocolInformation, + svnPredicate +); binder.bind("repos.repository-avatar", SvnAvatar, svnPredicate); // bind global configuration diff --git a/scm-ui/ui-components/src/Breadcrumb.tsx b/scm-ui/ui-components/src/Breadcrumb.tsx index d05517549c..aeac6504ef 100644 --- a/scm-ui/ui-components/src/Breadcrumb.tsx +++ b/scm-ui/ui-components/src/Breadcrumb.tsx @@ -255,13 +255,25 @@ const Breadcrumb: FC = ({ const renderExtensionPoints = () => { if ( - binder.hasExtension("repos.sources.empty.actionbar") && + binder.hasExtension("repos.sources.empty.actionbar", extProps) && sources?._embedded?.children?.length === 0 ) { - return ; + return ( + + name="repos.sources.empty.actionbar" + props={{ repository, sources }} + renderAll={true} + /> + ); } - if (binder.hasExtension("repos.sources.actionbar")) { - return ; + if (binder.hasExtension("repos.sources.actionbar", extProps)) { + return ( + + name="repos.sources.actionbar" + props={extProps} + renderAll={true} + /> + ); } return null; }; diff --git a/scm-ui/ui-components/src/avatar/Avatar.ts b/scm-ui/ui-components/src/avatar/Avatar.ts index 9690c37ad6..a6d6cd92cd 100644 --- a/scm-ui/ui-components/src/avatar/Avatar.ts +++ b/scm-ui/ui-components/src/avatar/Avatar.ts @@ -23,9 +23,10 @@ */ import { Person } from "@scm-manager/ui-types"; +import { extensionPoints } from "@scm-manager/ui-extensions"; // re export type to avoid breaking changes, // after the type was moved to ui-types export { Person }; -export const EXTENSION_POINT = "avatar.factory"; +export const EXTENSION_POINT: extensionPoints.AvatarFactory["name"] = "avatar.factory"; diff --git a/scm-ui/ui-components/src/avatar/AvatarWrapper.tsx b/scm-ui/ui-components/src/avatar/AvatarWrapper.tsx index b92ae5c71b..2d35c3e460 100644 --- a/scm-ui/ui-components/src/avatar/AvatarWrapper.tsx +++ b/scm-ui/ui-components/src/avatar/AvatarWrapper.tsx @@ -22,12 +22,12 @@ * SOFTWARE. */ import React, { FC } from "react"; -import { useBinder } from "@scm-manager/ui-extensions"; +import { extensionPoints, useBinder } from "@scm-manager/ui-extensions"; import { EXTENSION_POINT } from "./Avatar"; const AvatarWrapper: FC = ({ children }) => { const binder = useBinder(); - if (binder.hasExtension(EXTENSION_POINT)) { + if (binder.hasExtension(EXTENSION_POINT)) { return <>{children}; } return null; diff --git a/scm-ui/ui-components/src/layout/Footer.stories.tsx b/scm-ui/ui-components/src/layout/Footer.stories.tsx index 41cc2048bd..60c0b1c8ee 100644 --- a/scm-ui/ui-components/src/layout/Footer.stories.tsx +++ b/scm-ui/ui-components/src/layout/Footer.stories.tsx @@ -24,7 +24,7 @@ import React from "react"; import { storiesOf } from "@storybook/react"; import Footer from "./Footer"; -import { Binder, BinderContext } from "@scm-manager/ui-extensions"; +import { Binder, BinderContext, extensionPoints } from "@scm-manager/ui-extensions"; import { Me } from "@scm-manager/ui-types"; import { EXTENSION_POINT } from "../avatar/Avatar"; // @ts-ignore ignore unknown png @@ -43,11 +43,8 @@ const trillian: Me = { _links: {}, }; -const bindAvatar = (binder: Binder, avatar: string) => { - binder.bind(EXTENSION_POINT, () => { - return avatar; - }); -}; +const bindAvatar = (binder: Binder, avatar: string) => + binder.bind(EXTENSION_POINT, () => avatar); const bindLinks = (binder: Binder) => { binder.bind("footer.information", () => ); diff --git a/scm-ui/ui-components/src/layout/Footer.tsx b/scm-ui/ui-components/src/layout/Footer.tsx index b603c5b453..c5f154407d 100644 --- a/scm-ui/ui-components/src/layout/Footer.tsx +++ b/scm-ui/ui-components/src/layout/Footer.tsx @@ -23,7 +23,7 @@ */ import React, { FC } from "react"; import { Links, Me } from "@scm-manager/ui-types"; -import { ExtensionPoint, useBinder } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints, useBinder } from "@scm-manager/ui-extensions"; import { AvatarImage } from "../avatar"; import NavLink from "../navigation/NavLink"; import FooterSection from "./FooterSection"; @@ -84,7 +84,7 @@ const Footer: FC = ({ me, version, links }) => { const extensionProps = { me, url: "/me", links }; let meSectionTile; if (me) { - if (binder.hasExtension(EXTENSION_POINT)) { + if (binder.hasExtension(EXTENSION_POINT)) { meSectionTile = ; } else { meSectionTile = ; @@ -105,7 +105,11 @@ const Footer: FC = ({ me, version, links }) => { )} {me?._links?.apiKeys && } - + + name="profile.setting" + props={extensionProps} + renderAll={true} + /> ) : null} }> diff --git a/scm-ui/ui-components/src/markdown/MarkdownCodeRenderer.tsx b/scm-ui/ui-components/src/markdown/MarkdownCodeRenderer.tsx index a1f7710fe4..f85a4cd037 100644 --- a/scm-ui/ui-components/src/markdown/MarkdownCodeRenderer.tsx +++ b/scm-ui/ui-components/src/markdown/MarkdownCodeRenderer.tsx @@ -24,7 +24,7 @@ import React, { FC } from "react"; import SyntaxHighlighter from "../SyntaxHighlighter"; -import { ExtensionPoint, useBinder } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints, useBinder } from "@scm-manager/ui-extensions"; import { useIndexLinks } from "@scm-manager/ui-api"; type Props = { @@ -32,14 +32,15 @@ type Props = { value: string; }; -const MarkdownCodeRenderer: FC = (props) => { +const MarkdownCodeRenderer: FC = props => { const binder = useBinder(); const indexLinks = useIndexLinks(); const { language } = props; - const extensionKey = `markdown-renderer.code.${language}`; - if (binder.hasExtension(extensionKey, props)) { - return ; + const extensionProps = { ...props, indexLinks }; + const extensionKey = `markdown-renderer.code.${language}` as const; + if (binder.hasExtension(extensionKey, extensionProps)) { + return name={extensionKey} props={extensionProps} />; } return ; }; diff --git a/scm-ui/ui-components/src/markdown/MarkdownView.stories.tsx b/scm-ui/ui-components/src/markdown/MarkdownView.stories.tsx index 82b0e40f55..326164fa05 100644 --- a/scm-ui/ui-components/src/markdown/MarkdownView.stories.tsx +++ b/scm-ui/ui-components/src/markdown/MarkdownView.stories.tsx @@ -38,8 +38,8 @@ import MarkdownChangelog from "../__resources__/markdown-changelog.md"; import Title from "../layout/Title"; import { Subtitle } from "../layout"; import { MemoryRouter } from "react-router-dom"; -import { Binder, BinderContext } from "@scm-manager/ui-extensions"; -import { ProtocolLinkRendererExtension, ProtocolLinkRendererProps } from "./markdownExtensions"; +import { Binder, BinderContext, extensionPoints } from "@scm-manager/ui-extensions"; +import { ProtocolLinkRendererProps } from "./markdownExtensions"; const Spacing = styled.div` padding: 2em; @@ -64,10 +64,10 @@ storiesOf("MarkdownView", module) )) .add("Links", () => { const binder = new Binder("custom protocol link renderer"); - binder.bind("markdown-renderer.link.protocol", { + binder.bind>("markdown-renderer.link.protocol", { protocol: "scw", renderer: ProtocolLinkRenderer - } as ProtocolLinkRendererExtension); + }); return ( @@ -76,10 +76,10 @@ storiesOf("MarkdownView", module) }) .add("Links without Base Path", () => { const binder = new Binder("custom protocol link renderer"); - binder.bind("markdown-renderer.link.protocol", { + binder.bind>("markdown-renderer.link.protocol", { protocol: "scw", renderer: ProtocolLinkRenderer - } as ProtocolLinkRendererExtension); + }); return ( @@ -107,7 +107,7 @@ storiesOf("MarkdownView", module) ); }; - binder.bind("markdown-renderer.code.uml", Container); + binder.bind>("markdown-renderer.code.uml", Container); return ( @@ -116,7 +116,7 @@ storiesOf("MarkdownView", module) }) .add("XSS Prevention", () => ); -export const ProtocolLinkRenderer: FC = ({ protocol, href, children }) => { +export const ProtocolLinkRenderer: FC> = ({ protocol, href, children }) => { return (

diff --git a/scm-ui/ui-components/src/markdown/markdownExtensions.ts b/scm-ui/ui-components/src/markdown/markdownExtensions.ts index 4d3a0758a8..dfe2f34fd6 100644 --- a/scm-ui/ui-components/src/markdown/markdownExtensions.ts +++ b/scm-ui/ui-components/src/markdown/markdownExtensions.ts @@ -21,18 +21,17 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import { FC } from "react"; +import { extensionPoints, ExtractProps } from "@scm-manager/ui-extensions"; -export type ProtocolLinkRendererProps = { - protocol: string; - href: string; -}; +export type ProtocolLinkRendererProps = ExtractProps< + extensionPoints.MarkdownLinkProtocolRenderer["type"]["renderer"] +>; -export type ProtocolLinkRendererExtension = { - protocol: string; - renderer: FC; -}; +/** + * @deprecated use {@link MarkdownLinkProtocolRenderer}`["type"]` instead + */ +export type ProtocolLinkRendererExtension = extensionPoints.MarkdownLinkProtocolRenderer["type"]; export type ProtocolLinkRendererExtensionMap = { - [protocol: string]: FC; + [protocol: string]: extensionPoints.MarkdownLinkProtocolRenderer["type"]["renderer"] | undefined; }; diff --git a/scm-ui/ui-components/src/navigation/PrimaryNavigation.tsx b/scm-ui/ui-components/src/navigation/PrimaryNavigation.tsx index 7662ac1485..dcd3fb1be3 100644 --- a/scm-ui/ui-components/src/navigation/PrimaryNavigation.tsx +++ b/scm-ui/ui-components/src/navigation/PrimaryNavigation.tsx @@ -24,9 +24,7 @@ import React, { FC, ReactNode } from "react"; import PrimaryNavigationLink from "./PrimaryNavigationLink"; import { Links } from "@scm-manager/ui-types"; -import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; -import { urls } from "@scm-manager/ui-api"; -import { useLocation } from "react-router-dom"; +import { binder, ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { useTranslation } from "react-i18next"; type Props = { @@ -37,7 +35,6 @@ type Appender = (to: string, match: string, label: string, linkName: string) => const PrimaryNavigation: FC = ({ links }) => { const [t] = useTranslation("commons"); - const location = useLocation(); const createNavigationAppender = (navItems: ReactNode[]): Appender => { return (to: string, match: string, label: string, linkName: string) => { @@ -63,13 +60,15 @@ const PrimaryNavigation: FC = ({ links }) => { const extensionProps = { links, - label: t("primary-navigation.first-menu"), + label: t("primary-navigation.first-menu") }; const append = createNavigationAppender(navItems); - if (binder.hasExtension("primary-navigation.first-menu", extensionProps)) { + if ( + binder.hasExtension("primary-navigation.first-menu", extensionProps) + ) { navItems.push( - key="primary-navigation.first-menu" name="primary-navigation.first-menu" props={extensionProps} @@ -82,12 +81,12 @@ const PrimaryNavigation: FC = ({ links }) => { append("/admin", "/admin", "primary-navigation.admin", "config"); navItems.push( - key="primary-navigation" name="primary-navigation" renderAll={true} props={{ - links, + links }} /> ); diff --git a/scm-ui/ui-components/src/repos/RepositoryAvatar.tsx b/scm-ui/ui-components/src/repos/RepositoryAvatar.tsx index 113fa4d5ad..2a9a5c4fe9 100644 --- a/scm-ui/ui-components/src/repos/RepositoryAvatar.tsx +++ b/scm-ui/ui-components/src/repos/RepositoryAvatar.tsx @@ -22,7 +22,7 @@ * SOFTWARE. */ import React, { FC } from "react"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { Repository } from "@scm-manager/ui-types"; import { Image } from "@scm-manager/ui-components"; import styled from "styled-components"; @@ -38,13 +38,13 @@ type Props = { const renderExtensionPoint = (repository: Repository) => { return ( - name="repos.repository-avatar.primary" props={{ repository, }} > - name="repos.repository-avatar" props={{ repository, diff --git a/scm-ui/ui-components/src/repos/RepositoryEntry.tsx b/scm-ui/ui-components/src/repos/RepositoryEntry.tsx index 30a0daca8e..778b0a9c47 100644 --- a/scm-ui/ui-components/src/repos/RepositoryEntry.tsx +++ b/scm-ui/ui-components/src/repos/RepositoryEntry.tsx @@ -25,7 +25,7 @@ import React, { FC, useState } from "react"; import { Repository } from "@scm-manager/ui-types"; import { DateFromNow, Modal } from "@scm-manager/ui-components"; import RepositoryAvatar from "./RepositoryAvatar"; -import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; +import { binder, ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import GroupEntry from "../layout/GroupEntry"; import RepositoryFlags from "./RepositoryFlags"; import styled from "styled-components"; @@ -103,7 +103,7 @@ const RepositoryEntry: FC = ({ repository, baseDate }) => { active={openCloneModal} title={t("overview.clone")} body={ - name="repos.repository-details.information" renderAll={true} props={{ @@ -144,7 +144,10 @@ const RepositoryEntry: FC = ({ repository, baseDate }) => { const actions = createContentRight(); const name = (
- + + name="repository.card.beforeTitle" + props={{ repository }} + /> {repository.name}
); diff --git a/scm-ui/ui-components/src/repos/RepositoryFlags.tsx b/scm-ui/ui-components/src/repos/RepositoryFlags.tsx index b4ca888626..541e34e97d 100644 --- a/scm-ui/ui-components/src/repos/RepositoryFlags.tsx +++ b/scm-ui/ui-components/src/repos/RepositoryFlags.tsx @@ -26,7 +26,7 @@ import { useTranslation } from "react-i18next"; import classNames from "classnames"; import styled from "styled-components"; import { Repository } from "@scm-manager/ui-types"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { TooltipLocation } from "../Tooltip"; import RepositoryFlag from "./RepositoryFlag"; import HealthCheckFailureDetail from "./HealthCheckFailureDetail"; @@ -90,7 +90,11 @@ const RepositoryFlags: FC = ({ repository, className, tooltipLocation = " {modal} {repositoryFlags} - + + name="repository.flags" + props={{ repository, tooltipLocation }} + renderAll={true} + />

); diff --git a/scm-ui/ui-components/src/repos/changesets/ChangesetAuthor.tsx b/scm-ui/ui-components/src/repos/changesets/ChangesetAuthor.tsx index c2c513eed1..514b546cdd 100644 --- a/scm-ui/ui-components/src/repos/changesets/ChangesetAuthor.tsx +++ b/scm-ui/ui-components/src/repos/changesets/ChangesetAuthor.tsx @@ -24,7 +24,7 @@ import React, { FC } from "react"; import { Changeset, Person } from "@scm-manager/ui-types"; import { useTranslation } from "react-i18next"; -import { useBinder } from "@scm-manager/ui-extensions"; +import { extensionPoints, useBinder } from "@scm-manager/ui-extensions"; import { EXTENSION_POINT } from "../../avatar/Avatar"; import styled from "styled-components"; import CommaSeparatedList from "../../CommaSeparatedList"; @@ -42,7 +42,7 @@ type PersonProps = { const useAvatar = (person: Person): string | undefined => { const binder = useBinder(); - const factory: (person: Person) => string | undefined = binder.getExtension(EXTENSION_POINT); + const factory = binder.getExtension(EXTENSION_POINT); if (factory) { return factory(person); } @@ -171,7 +171,9 @@ const ChangesetAuthor: FC = ({ changeset }) => { } // extensions - const extensions = binder.getExtensions("changesets.author.suffix", { changeset }); + const extensions = binder.getExtensions("changesets.author.suffix", { + changeset + }); if (extensions) { authorLine.push(...extensions); } diff --git a/scm-ui/ui-components/src/repos/changesets/ChangesetDescription.tsx b/scm-ui/ui-components/src/repos/changesets/ChangesetDescription.tsx index eac2c00823..1be6f0d569 100644 --- a/scm-ui/ui-components/src/repos/changesets/ChangesetDescription.tsx +++ b/scm-ui/ui-components/src/repos/changesets/ChangesetDescription.tsx @@ -24,8 +24,8 @@ import React, { FC } from "react"; import { Changeset } from "@scm-manager/ui-types"; -import { useBinder } from "@scm-manager/ui-extensions"; -import { SplitAndReplace, Replacement } from "@scm-manager/ui-components"; +import { extensionPoints, useBinder } from "@scm-manager/ui-extensions"; +import { Replacement, SplitAndReplace } from "@scm-manager/ui-components"; type Props = { changeset: Changeset; @@ -35,15 +35,14 @@ type Props = { const ChangesetDescription: FC = ({ changeset, value }) => { const binder = useBinder(); - const replacements: ((changeset: Changeset, value: string) => Replacement[])[] = binder.getExtensions( - "changeset.description.tokens", - { - changeset, - value, - } - ); + const replacements: ((changeset: Changeset, value: string) => Replacement[])[] = binder.getExtensions< + extensionPoints.ChangesetDescriptionTokens + >("changeset.description.tokens", { + changeset, + value + }); - return r(changeset, value))} />; + return r(changeset, value))} />; }; export default ChangesetDescription; diff --git a/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx b/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx index 2b49c10af7..8aeeb41e6d 100644 --- a/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx +++ b/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx @@ -24,7 +24,7 @@ import React, { FC } from "react"; import classNames from "classnames"; import styled from "styled-components"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { Changeset, File, Repository } from "@scm-manager/ui-types"; import ChangesetButtonGroup from "./ChangesetButtonGroup"; import SingleChangeset from "./SingleChangeset"; @@ -54,7 +54,7 @@ const ChangesetRow: FC = ({ repository, changeset, file }) => {
- name="changeset.right" props={{ repository, diff --git a/scm-ui/ui-components/src/repos/changesets/SingleChangeset.tsx b/scm-ui/ui-components/src/repos/changesets/SingleChangeset.tsx index c6e90db13c..eca0831fb6 100644 --- a/scm-ui/ui-components/src/repos/changesets/SingleChangeset.tsx +++ b/scm-ui/ui-components/src/repos/changesets/SingleChangeset.tsx @@ -24,7 +24,7 @@ import React, { FC } from "react"; import classNames from "classnames"; import { AvatarImage, AvatarWrapper } from "../../avatar"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import ChangesetDescription from "./ChangesetDescription"; import { Trans } from "react-i18next"; import ChangesetAuthor from "./ChangesetAuthor"; @@ -72,7 +72,7 @@ const SingleChangeset: FC = ({ repository, changeset }) => {

- name="changeset.description" props={{ changeset, diff --git a/scm-ui/ui-extensions/src/ExtensionPoint.tsx b/scm-ui/ui-extensions/src/ExtensionPoint.tsx index 83622f0918..d0d94607b3 100644 --- a/scm-ui/ui-extensions/src/ExtensionPoint.tsx +++ b/scm-ui/ui-extensions/src/ExtensionPoint.tsx @@ -21,39 +21,70 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { FC, ReactNode } from "react"; -import { Binder } from "./binder"; +import React, { PropsWithChildren, ReactNode } from "react"; +import { Binder, ExtensionPointDefinition } from "./binder"; import useBinder from "./useBinder"; +export type Renderable

= React.ReactElement | React.ComponentType

; +export type RenderableExtensionPointDefinition< + Name extends string = string, + P = undefined +> = ExtensionPointDefinition, P>; + +export type SimpleRenderableDynamicExtensionPointDefinition< + Prefix extends string, + Suffix extends string | undefined, + Properties +> = RenderableExtensionPointDefinition; + +/** + * @deprecated Obsolete type + */ type PropTransformer = (props: object) => object; -type Props = { - name: string; +type BaseProps = { + name: E["name"]; renderAll?: boolean; - props?: object; + /** + * @deprecated Obsolete property, do not use + */ propTransformer?: PropTransformer; wrapper?: boolean; }; -const createInstance = (Component: any, props: object, key?: number) => { - const instanceProps = { - ...props, - ...(Component.props || {}), - key, - }; - if (React.isValidElement(Component)) { - return React.cloneElement(Component, instanceProps); - } - return ; -}; +type Props = BaseProps & + (E["props"] extends undefined + ? { props?: E["props"] } + : { + props: E["props"]; + }); -const renderAllExtensions = (binder: Binder, name: string, props: object) => { - const extensions = binder.getExtensions(name, props); +function createInstance

(Component: Renderable

, props: P, key?: number) { + if (React.isValidElement(Component)) { + return React.cloneElement(Component, { + ...props, + ...Component.props, + key, + }); + } + return ; +} + +const renderAllExtensions = >( + binder: Binder, + name: E["name"], + props: E["props"] +) => { + const extensions = binder.getExtensions(name, props); return <>{extensions.map((cmp, index) => createInstance(cmp, props, index))}; }; -const renderWrapperExtensions = (binder: Binder, name: string, props: object) => { - const extensions = [...(binder.getExtensions(name, props) || [])]; +const renderWrapperExtensions = >( + binder: Binder, + name: E["name"], + props: E["props"] +) => { + const extensions = binder.getExtensions(name, props); extensions.reverse(); let instance: any = null; @@ -68,8 +99,12 @@ const renderWrapperExtensions = (binder: Binder, name: string, props: object) => return instance; }; -const renderSingleExtension = (binder: Binder, name: string, props: object) => { - const cmp = binder.getExtension(name, props); +const renderSingleExtension = >( + binder: Binder, + name: E["name"], + props: E["props"] +) => { + const cmp = binder.getExtension(name, props); if (!cmp) { return null; } @@ -97,18 +132,21 @@ const createRenderProps = (propTransformer?: PropTransformer, props?: object) => /** * ExtensionPoint renders components which are bound to an extension point. */ -const ExtensionPoint: FC = ({ name, propTransformer, props, renderAll, wrapper, children }) => { +export default function ExtensionPoint< + E extends RenderableExtensionPointDefinition = RenderableExtensionPointDefinition +>({ name, propTransformer, props, renderAll, wrapper, children }: PropsWithChildren>): JSX.Element | null { const binder = useBinder(); - const renderProps = createRenderProps(propTransformer, { ...(props || {}), children }); - if (!binder.hasExtension(name, renderProps)) { + const renderProps: E["props"] | {} = createRenderProps(propTransformer, { + ...(props || {}), + children, + }); + if (!binder.hasExtension(name, renderProps)) { return renderDefault(children); } else if (renderAll) { if (wrapper) { - return renderWrapperExtensions(binder, name, renderProps); + return renderWrapperExtensions(binder, name, renderProps); } - return renderAllExtensions(binder, name, renderProps); + return renderAllExtensions(binder, name, renderProps); } - return renderSingleExtension(binder, name, renderProps); -}; - -export default ExtensionPoint; + return renderSingleExtension(binder, name, renderProps); +} diff --git a/scm-ui/ui-extensions/src/binder.test.ts b/scm-ui/ui-extensions/src/binder.test.tsx similarity index 80% rename from scm-ui/ui-extensions/src/binder.test.ts rename to scm-ui/ui-extensions/src/binder.test.tsx index c6855457b1..86a38017e6 100644 --- a/scm-ui/ui-extensions/src/binder.test.ts +++ b/scm-ui/ui-extensions/src/binder.test.tsx @@ -22,7 +22,9 @@ * SOFTWARE. */ +import React from "react"; import { Binder, ExtensionPointDefinition, SimpleDynamicExtensionPointDefinition } from "./binder"; +import ExtensionPoint, { RenderableExtensionPointDefinition } from "./ExtensionPoint"; describe("binder tests", () => { let binder: Binder; @@ -117,6 +119,59 @@ describe("binder tests", () => { expect(binderExtensionC).not.toBeNull(); }); + it("should allow typings for renderable extension points", () => { + type TestExtensionPointA = RenderableExtensionPointDefinition<"test.extension.a">; + type TestExtensionPointB = RenderableExtensionPointDefinition<"test.extension.b", { testProp: boolean[] }>; + + binder.bind( + "test.extension.a", + () =>

Hello world

, + () => false + ); + const binderExtensionA = binder.getExtension("test.extension.a"); + expect(binderExtensionA).not.toBeNull(); + binder.bind("test.extension.b", ({ testProp }) => ( +

+ {testProp.map((b) => ( + {b} + ))} +

+ )); + const binderExtensionsB = binder.getExtensions("test.extension.b", { + testProp: [true, false], + }); + expect(binderExtensionsB).toHaveLength(1); + }); + + it("should render typed extension point", () => { + type TestExtensionPointA = RenderableExtensionPointDefinition<"test.extension.a">; + type TestExtensionPointB = RenderableExtensionPointDefinition<"test.extension.b", { testProp: boolean[] }>; + + binder.bind( + "test.extension.a", + () =>

Hello world

, + () => false + ); + const binderExtensionA = name="test.extension.a" />; + expect(binderExtensionA).not.toBeNull(); + binder.bind("test.extension.b", ({ testProp }) => ( +

+ {testProp.map((b) => ( + {b} + ))} +

+ )); + const binderExtensionsB = ( + + name="test.extension.b" + props={{ + testProp: [true, false], + }} + /> + ); + expect(binderExtensionsB).not.toBeNull(); + }); + it("should allow typings for dynamic extension points", () => { type MarkdownCodeLanguageRendererProps = { language?: string; diff --git a/scm-ui/ui-extensions/src/binder.ts b/scm-ui/ui-extensions/src/binder.ts index 9d99a2de72..a41de04a14 100644 --- a/scm-ui/ui-extensions/src/binder.ts +++ b/scm-ui/ui-extensions/src/binder.ts @@ -21,8 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - -type Predicate

= Record> = (props: P) => boolean; +type Predicate

= Record> = (props: P) => unknown; type ExtensionRegistration = { predicate: Predicate

; @@ -78,15 +77,29 @@ export class Binder { * * @param extensionPoint name of extension point * @param extension provided extension - * @param predicate to decide if the extension gets rendered for the given props */ bind>(extensionPoint: E["name"], extension: E["type"]): void; + /** + * Binds an extension to the extension point. + * + * @param extensionPoint name of extension point + * @param extension provided extension + * @param predicate to decide if the extension gets rendered for the given props + * @param extensionName name used for sorting alphabetically on retrieval (ASC) + */ bind>( extensionPoint: E["name"], extension: E["type"], predicate?: Predicate, extensionName?: string ): void; + /** + * Binds an extension to the extension point. + * + * @param extensionPoint name of extension point + * @param extension provided extension + * @param options object with additional settings + */ bind>( extensionPoint: E["name"], extension: E["type"], @@ -125,13 +138,18 @@ export class Binder { this.extensionPoints[extensionPoint].push(registration); } + /** + * Returns the first extension or null for the given extension point and its props. + * + * @param extensionPoint name of extension point + */ + getExtension>(extensionPoint: E["name"]): E["type"] | null; /** * Returns the first extension or null for the given extension point and its props. * * @param extensionPoint name of extension point * @param props of the extension point */ - getExtension>(extensionPoint: E["name"]): E["type"] | null; getExtension>( extensionPoint: E["name"], props: E["props"] @@ -147,16 +165,19 @@ export class Binder { return null; } + /** + * Returns all registered extensions for the given extension point and its props. + * + * @param extensionPoint name of extension point + */ + getExtensions>(extensionPoint: E["name"]): Array; /** * Returns all registered extensions for the given extension point and its props. * * @param extensionPoint name of extension point * @param props of the extension point */ - getExtensions>( - extensionPoint: E["name"] - ): Array; - getExtensions>( + getExtensions>( extensionPoint: E["name"], props: E["props"] ): Array; @@ -175,11 +196,19 @@ export class Binder { /** * Returns true if at least one extension is bound to the extension point and its props. */ + hasExtension>(extensionPoint: E["name"]): boolean; + /** + * Returns true if at least one extension is bound to the extension point and its props. + */ + hasExtension>( + extensionPoint: E["name"], + props: E["props"] + ): boolean; hasExtension>( extensionPoint: E["name"], props?: E["props"] ): boolean { - return this.getExtensions(extensionPoint, props).length > 0; + return this.getExtensions(extensionPoint, props).length > 0; } /** diff --git a/scm-ui/ui-extensions/src/extensionPoints.ts b/scm-ui/ui-extensions/src/extensionPoints.ts index d7e1421803..d4e0c7b7dd 100644 --- a/scm-ui/ui-extensions/src/extensionPoints.ts +++ b/scm-ui/ui-extensions/src/extensionPoints.ts @@ -22,19 +22,32 @@ * SOFTWARE. */ -import React from "react"; +import React, { ReactNode } from "react"; import { Branch, - BranchDetails, + Changeset, File, + Group, + HalRepresentation, + Hit, IndexResources, Links, + Me, + Namespace, NamespaceStrategies, + Person, + Plugin, Repository, RepositoryCreation, - RepositoryTypeCollection + RepositoryRole, + RepositoryRoleBase, + RepositoryTypeCollection, + Tag, + User, } from "@scm-manager/ui-types"; import { ExtensionPointDefinition } from "./binder"; +import { RenderableExtensionPointDefinition, SimpleRenderableDynamicExtensionPointDefinition } from "./ExtensionPoint"; +import ExtractProps from "./extractProps"; type RepositoryCreatorSubFormProps = { repository: RepositoryCreation; @@ -43,128 +56,466 @@ type RepositoryCreatorSubFormProps = { disabled?: boolean; }; -export type RepositoryCreatorComponentProps = { - namespaceStrategies: NamespaceStrategies; - repositoryTypes: RepositoryTypeCollection; - index: IndexResources; +export type RepositoryCreatorComponentProps = ExtractProps; - nameForm: React.ComponentType; - informationForm: React.ComponentType; -}; +/** + * @deprecated use {@link RepositoryCreator}`["type"]` instead + */ +export type RepositoryCreatorExtension = RepositoryCreator["type"]; +export type RepositoryCreator = ExtensionPointDefinition<"repos.creator", + { + subtitle: string; + path: string; + icon: string; + label: string; + component: React.ComponentType<{ + namespaceStrategies: NamespaceStrategies; + repositoryTypes: RepositoryTypeCollection; + index: IndexResources; -export type RepositoryCreatorExtension = { - subtitle: string; - path: string; - icon: string; - label: string; - component: React.ComponentType; -}; + nameForm: React.ComponentType; + informationForm: React.ComponentType; + }>; + }>; -export type RepositoryCreator = ExtensionPointDefinition<"repos.creator", RepositoryCreatorExtension>; +export type RepositoryFlags = RenderableExtensionPointDefinition<"repository.flags", + { repository: Repository; tooltipLocation?: "bottom" | "right" | "top" | "left" }>; -export type RepositoryFlags = ExtensionPointDefinition<"repository.flags", { repository: Repository }>; +/** + * @deprecated use {@link ReposSourcesActionbar}`["props"]` instead + */ +export type ReposSourcesActionbarExtensionProps = ReposSourcesActionbar["props"]; +/** + * @deprecated use {@link ReposSourcesActionbar} instead + */ +export type ReposSourcesActionbarExtension = ReposSourcesActionbar; +export type ReposSourcesActionbar = RenderableExtensionPointDefinition<"repos.sources.actionbar", + { + baseUrl: string; + revision: string; + branch: Branch | undefined; + path: string; + sources: File; + repository: Repository; + }>; -export type ReposSourcesActionbarExtensionProps = { - baseUrl: string; - revision: string; - branch: Branch | undefined; - path: string; - sources: File; - repository: Repository; -}; -export type ReposSourcesActionbarExtension = React.ComponentType; -export type ReposSourcesActionbar = ExtensionPointDefinition<"repos.sources.actionbar", ReposSourcesActionbarExtension>; +/** + * @deprecated use {@link ReposSourcesEmptyActionbar}`["props"]` instead + */ +export type ReposSourcesEmptyActionbarExtensionProps = ReposSourcesEmptyActionbar["props"]; +/** + * @deprecated use {@link ReposSourcesEmptyActionbar} instead + */ +export type ReposSourcesEmptyActionbarExtension = ReposSourcesEmptyActionbar; +export type ReposSourcesEmptyActionbar = RenderableExtensionPointDefinition<"repos.sources.empty.actionbar", + { + sources: File; + repository: Repository; + }>; -export type ReposSourcesEmptyActionbarExtensionProps = { - sources: File; - repository: Repository; -}; -export type ReposSourcesEmptyActionbarExtension = ReposSourcesActionbarExtension; -export type ReposSourcesEmptyActionbar = ExtensionPointDefinition< - "repos.sources.empty.actionbar", - ReposSourcesEmptyActionbarExtension ->; +/** + * @deprecated use {@link ReposSourcesTreeWrapper}`["props"]` instead + */ +export type ReposSourcesTreeWrapperProps = ReposSourcesTreeWrapper["props"]; -export type ReposSourcesTreeWrapperProps = { - repository: Repository; - directory: File; - baseUrl: string; - revision: string; -}; - -export type ReposSourcesTreeWrapperExtension = ExtensionPointDefinition< - "repos.source.tree.wrapper", - React.ComponentType ->; +/** + * @deprecated use {@link ReposSourcesTreeWrapper} instead + */ +export type ReposSourcesTreeWrapperExtension = ReposSourcesTreeWrapper; +export type ReposSourcesTreeWrapper = RenderableExtensionPointDefinition<"repos.source.tree.wrapper", + { + repository: Repository; + directory: File; + baseUrl: string; + revision: string; + }>; export type ReposSourcesTreeRowProps = { repository: Repository; file: File; }; -export type ReposSourcesTreeRowRightExtension = ExtensionPointDefinition< - "repos.sources.tree.row.right", - React.ComponentType ->; -export type ReposSourcesTreeRowAfterExtension = ExtensionPointDefinition< - "repos.sources.tree.row.after", - React.ComponentType ->; +/** + * @deprecated use {@link ReposSourcesTreeRowRight} instead + */ +export type ReposSourcesTreeRowRightExtension = ReposSourcesTreeRowRight; +export type ReposSourcesTreeRowRight = RenderableExtensionPointDefinition<"repos.sources.tree.row.right", + ReposSourcesTreeRowProps>; -export type PrimaryNavigationLoginButtonProps = { - links: Links; - label: string; - loginUrl: string; - from: string; - to: string; - className: string; - content: React.ReactNode; +/** + * @deprecated use {@link ReposSourcesTreeRowAfter} instead + */ +export type ReposSourcesTreeRowAfterExtension = ReposSourcesTreeRowAfter; +export type ReposSourcesTreeRowAfter = RenderableExtensionPointDefinition<"repos.sources.tree.row.after", + ReposSourcesTreeRowProps>; + +/** + * @deprecated use {@link PrimaryNavigationLoginButton}`["props"]` instead + */ +export type PrimaryNavigationLoginButtonProps = PrimaryNavigationLoginButton["props"]; + +/** + * use {@link PrimaryNavigationLoginButton} instead + */ +export type PrimaryNavigationLoginButtonExtension = PrimaryNavigationLoginButton; +export type PrimaryNavigationLoginButton = RenderableExtensionPointDefinition<"primary-navigation.login", + { + links: Links; + label: string; + loginUrl: string; + from: string; + to: string; + className: string; + content: React.ReactNode; + }>; + +/** + * @deprecated use {@link PrimaryNavigationLogoutButtonExtension}`["props"]` instead + */ +export type PrimaryNavigationLogoutButtonProps = PrimaryNavigationLogoutButton["props"]; + +/** + * @deprecated use {@link PrimaryNavigationLogoutButton} instead + */ +export type PrimaryNavigationLogoutButtonExtension = PrimaryNavigationLogoutButton; +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", + { + repository: Repository; + baseUrl: string; + revision: string; + extension: string; + sources: File | undefined; + path: string; + }>; + +/** + * @deprecated use {@link RepositoryOverviewTop}`["props"]` instead + */ +export type RepositoryOverviewTopExtensionProps = RepositoryOverviewTop["props"]; + +/** + * @deprecated use {@link RepositoryOverviewTop} instead + */ +export type RepositoryOverviewTopExtension = RepositoryOverviewTop; +export type RepositoryOverviewTop = RenderableExtensionPointDefinition<"repository.overview.top", + { + page: number; + search: string; + namespace?: string; + }>; + +/** + * @deprecated use {@link RepositoryOverviewLeft} instead + */ +export type RepositoryOverviewLeftExtension = RepositoryOverviewLeft; +export type RepositoryOverviewLeft = ExtensionPointDefinition<"repository.overview.left", React.ComponentType>; + +/** + * @deprecated use {@link RepositoryOverviewTitle} instead + */ +export type RepositoryOverviewTitleExtension = RepositoryOverviewTitle; +export type RepositoryOverviewTitle = RenderableExtensionPointDefinition<"repository.overview.title">; + +/** + * @deprecated use {@link RepositoryOverviewSubtitle} instead + */ +export type RepositoryOverviewSubtitleExtension = RepositoryOverviewSubtitle; +export type RepositoryOverviewSubtitle = RenderableExtensionPointDefinition<"repository.overview.subtitle">; + +// From docs + +export type AdminNavigation = RenderableExtensionPointDefinition<"admin.navigation", { links: Links; url: string }>; + +export type AdminRoute = RenderableExtensionPointDefinition<"admin.route", { links: Links; url: string }>; + +export type AdminSetting = RenderableExtensionPointDefinition<"admin.setting", { links: Links; url: string }>; + +/** + * - can be used to replace the whole description of a changeset + * + * @deprecated Use `changeset.description.tokens` instead + */ +export type ChangesetDescription = RenderableExtensionPointDefinition<"changeset.description", + { changeset: Changeset; value: string }>; + +/** + * - Can be used to replace parts of a changeset description with components + * - Has to be bound with a funktion taking the changeset and the (partial) description and returning `Replacement` objects with the following attributes: + * - textToReplace: The text part of the description that should be replaced by a component + * - 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<{ + textToReplace: string; + replacement: ReactNode; + replaceAll?: boolean; + }>, + { changeset: Changeset; value: string }>; + +export type ChangesetRight = RenderableExtensionPointDefinition<"changeset.right", + { repository: Repository; changeset: Changeset }>; + +export type ChangesetsAuthorSuffix = RenderableExtensionPointDefinition<"changesets.author.suffix", + { changeset: Changeset }>; + +export type GroupNavigation = RenderableExtensionPointDefinition<"group.navigation", { group: Group; url: string }>; + +export type GroupRoute = RenderableExtensionPointDefinition<"group.route", { group: Group; url: string }>; +export type GroupSetting = RenderableExtensionPointDefinition<"group.setting", { group: Group; url: string }>; + +/** + * - Add a new Route to the main Route (scm/) + * - Props: authenticated?: boolean, links: Links + */ +export type MainRoute = RenderableExtensionPointDefinition<"main.route", + { + me: Me; + authenticated?: boolean; + }>; + +export type PluginAvatar = RenderableExtensionPointDefinition<"plugins.plugin-avatar", + { + plugin: Plugin; + }>; + +export type PrimaryNavigation = RenderableExtensionPointDefinition<"primary-navigation", { links: Links }>; + +/** + * - A placeholder for the first navigation menu. + * - 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 ProfileRoute = RenderableExtensionPointDefinition<"profile.route", { me: Me; url: string }>; +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 RepoConfigDetails = RenderableExtensionPointDefinition<"repo-config.details", + { repository: Repository; url: string }>; + +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 ReposCreateNamespace = RenderableExtensionPointDefinition<"repos.create.namespace", + { + label: string; + helpText: string; + value: string; + onChange: (namespace: string) => void; + errorMessage: string; + validationError?: boolean; + }>; + +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 RepositoryNavigationTopLevel = RenderableExtensionPointDefinition<"repository.navigation.topLevel", + { repository: Repository; url: string; indexLinks: Links }>; + +export type RepositoryRoleDetailsInformation = RenderableExtensionPointDefinition<"repositoryRole.role-details.information", + { role: RepositoryRole }>; + +export type RepositorySetting = RenderableExtensionPointDefinition<"repository.setting", + { repository: Repository; url: string; indexLinks: Links }>; + +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 }>; + +/** + * - 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 }>; + +/** + * - 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 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 }>; + +/** + * - Dynamic extension point for custom language-specific renderers + * - Overrides the default Syntax Highlighter for the given language + * - Used by the Markdown Plantuml Plugin + */ +export type MarkdownCodeRenderer = + 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 + * + * Example: + * ```markdown + * [description](myprotocol:somelink) + * ``` + * + * ```typescript + * binder.bind>("markdown-renderer.link.protocol", { protocol: "myprotocol", renderer: MyProtocolRenderer }) + * ``` + */ +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}. + * + * @see https://github.com/scm-manager/scm-gravatar-plugin + */ +export type AvatarFactory = ExtensionPointDefinition<"avatar.factory", (person: Person) => string | undefined>; + +/** + * - Location: At every changeset (detailed view as well as changeset overview) + * - can be used to add avatar (such as gravatar) for each changeset + * + * @deprecated Has no effect, use {@link AvatarFactory} instead + */ +export type ChangesetAvatarFactory = ExtensionPointDefinition<"changeset.avatar-factory", + (changeset: Changeset) => void>; + +type MainRedirectProps = { + me: Me; + authenticated?: boolean; }; -export type PrimaryNavigationLoginButtonExtension = ExtensionPointDefinition< - "primary-navigation.login", - PrimaryNavigationLoginButtonProps ->; +/** + * - 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", + (props: MainRedirectProps) => string, + MainRedirectProps>; -export type PrimaryNavigationLogoutButtonProps = { - links: Links; - label: string; - className: string; - content: React.ReactNode; -}; +/** + * - A Factory function to create markdown [renderer](https://github.com/rexxars/react-markdown#node-types) + * - The factory function will be called with a renderContext parameter of type Object. this parameter is given as a prop for the {@link MarkdownView} component. + * + * @deprecated Use {@link MarkdownCodeRenderer} or {@link MarkdownLinkProtocolRenderer} instead + */ +export type MarkdownRendererFactory = ExtensionPointDefinition<"markdown-renderer-factory", + (renderContext: unknown) => Record>>; -export type PrimaryNavigationLogoutButtonExtension = ExtensionPointDefinition< - "primary-navigation.logout", - PrimaryNavigationLogoutButtonProps ->; +export type RepositoryCardBeforeTitle = RenderableExtensionPointDefinition<"repository.card.beforeTitle", + { repository: Repository }>; -export type SourceExtensionProps = { +export type RepositoryCreationInitialization = RenderableExtensionPointDefinition<"repos.create.initialize", + { + repository: Repository; + setCreationContextEntry: (key: string, value: any) => void; + indexResources: Partial & { + links?: Links; + version?: string; + initialization?: string; + }; + }>; + +export type NamespaceTopLevelNavigation = RenderableExtensionPointDefinition<"namespace.navigation.topLevel", + { 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 RepositoryTagDetailsInformation = RenderableExtensionPointDefinition<"repos.tag-details.information", + { repository: Repository; tag: Tag }>; + +export type SearchHitRenderer = RenderableExtensionPointDefinition; + +export type RepositorySourcesContentDownloadButton = RenderableExtensionPointDefinition<"repos.sources.content.downloadButton", + { repository: Repository; file: File }>; + +export type RepositoryRoute = RenderableExtensionPointDefinition<"repository.route", + { repository: Repository; url: string; indexLinks: Links }>; + +type RepositoryRedirectProps = { + namespace: string; + name: string; repository: Repository; - baseUrl: string; - revision: string; - extension: string; - sources: File | undefined; - path: string; -}; -export type SourceExtension = ExtensionPointDefinition<"repos.sources.extensions", SourceExtensionProps>; - -export type RepositoryOverviewTopExtensionProps = { - page: number; - search: string; - namespace?: string; + loading: false; + error: null; + repoLink: string; + indexLinks: Links; + match: { + params: { + namespace: string; + name: string; + }; + isExact: boolean; + path: string; + url: string; + }; }; -export type RepositoryOverviewTopExtension = ExtensionPointDefinition< - "repository.overview.top", - React.ComponentType, - RepositoryOverviewTopExtensionProps ->; -export type RepositoryOverviewLeftExtension = ExtensionPointDefinition<"repository.overview.left", React.ComponentType>; -export type RepositoryOverviewTitleExtension = ExtensionPointDefinition< - "repository.overview.title", - React.ComponentType ->; -export type RepositoryOverviewSubtitleExtension = ExtensionPointDefinition< - "repository.overview.subtitle", - React.ComponentType ->; +export type RepositoryRedirect = ExtensionPointDefinition<"repository.redirect", + (props: RepositoryRedirectProps) => string, + RepositoryRedirectProps>; + +export type InitializationStep = + SimpleRenderableDynamicExtensionPointDefinition<"initialization.step.", + Step, + { + data: HalRepresentation; + }>; diff --git a/scm-ui/ui-extensions/src/extractProps.ts b/scm-ui/ui-extensions/src/extractProps.ts new file mode 100644 index 0000000000..3b8f42c977 --- /dev/null +++ b/scm-ui/ui-extensions/src/extractProps.ts @@ -0,0 +1,28 @@ +/* + * 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 from "react"; + +type ExtractProps = T extends React.ComponentType ? U : never; + +export default ExtractProps; diff --git a/scm-ui/ui-extensions/src/index.ts b/scm-ui/ui-extensions/src/index.ts index 746a089f6e..61520badff 100644 --- a/scm-ui/ui-extensions/src/index.ts +++ b/scm-ui/ui-extensions/src/index.ts @@ -25,6 +25,7 @@ export { default as binder, Binder, ExtensionPointDefinition } from "./binder"; export * from "./useBinder"; export { default as ExtensionPoint } from "./ExtensionPoint"; +export { default as ExtractProps } from "./extractProps"; // suppress eslint prettier warning, // because prettier does not understand "* as" diff --git a/scm-ui/ui-webapp/src/admin/containers/Admin.tsx b/scm-ui/ui-webapp/src/admin/containers/Admin.tsx index e7fccb56fe..cea38e7252 100644 --- a/scm-ui/ui-webapp/src/admin/containers/Admin.tsx +++ b/scm-ui/ui-webapp/src/admin/containers/Admin.tsx @@ -24,7 +24,7 @@ import React, { FC } from "react"; import { useTranslation } from "react-i18next"; import { Redirect, Route, RouteProps, Switch, useRouteMatch } from "react-router-dom"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { CustomQueryFlexWrappedColumns, NavLink, @@ -97,7 +97,7 @@ const Admin: FC = () => { - + name="admin.route" props={extensionProps} renderAll={true} /> @@ -142,7 +142,11 @@ const Admin: FC = () => { activeWhenMatch={matchesRoles} activeOnlyWhenExact={false} /> - + + name="admin.navigation" + props={extensionProps} + renderAll={true} + /> { label={t("admin.menu.generalNavLink")} testId="admin-settings-general-link" /> - + + name="admin.setting" + props={extensionProps} + renderAll={true} + /> diff --git a/scm-ui/ui-webapp/src/admin/plugins/components/PluginAvatar.tsx b/scm-ui/ui-webapp/src/admin/plugins/components/PluginAvatar.tsx index 5cd692798d..572193c1cf 100644 --- a/scm-ui/ui-webapp/src/admin/plugins/components/PluginAvatar.tsx +++ b/scm-ui/ui-webapp/src/admin/plugins/components/PluginAvatar.tsx @@ -23,7 +23,7 @@ */ import React from "react"; import styled from "styled-components"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { Plugin } from "@scm-manager/ui-types"; import { Image } from "@scm-manager/ui-components"; @@ -44,7 +44,7 @@ export default class PluginAvatar extends React.Component { const { plugin } = this.props; return ( - name="plugins.plugin-avatar" props={{ plugin diff --git a/scm-ui/ui-webapp/src/admin/roles/components/PermissionRoleDetails.tsx b/scm-ui/ui-webapp/src/admin/roles/components/PermissionRoleDetails.tsx index a00e5a8e86..dee63113a1 100644 --- a/scm-ui/ui-webapp/src/admin/roles/components/PermissionRoleDetails.tsx +++ b/scm-ui/ui-webapp/src/admin/roles/components/PermissionRoleDetails.tsx @@ -23,7 +23,7 @@ */ import React from "react"; import { WithTranslation, withTranslation } from "react-i18next"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { RepositoryRole } from "@scm-manager/ui-types"; import { Button, Level } from "@scm-manager/ui-components"; import PermissionRoleDetailsTable from "./PermissionRoleDetailsTable"; @@ -54,7 +54,7 @@ class PermissionRoleDetails extends React.Component { <> {this.renderEditButton()} - name="repositoryRole.role-details.information" renderAll={true} props={{ diff --git a/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx b/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx index c13eeb64a4..44887c8037 100644 --- a/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx +++ b/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx @@ -24,7 +24,7 @@ import React, { FC } from "react"; import { Route, useParams, useRouteMatch } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { ErrorPage, Loading, Title, urls } from "@scm-manager/ui-components"; import PermissionRoleDetail from "../components/PermissionRoleDetails"; import EditRepositoryRole from "./EditRepositoryRole"; @@ -62,7 +62,7 @@ const SingleRepositoryRole: FC = () => { - + name="roles.route" props={extensionProps} renderAll={true} /> ); }; diff --git a/scm-ui/ui-webapp/src/containers/Index.tsx b/scm-ui/ui-webapp/src/containers/Index.tsx index e1e2be082b..e95533cda8 100644 --- a/scm-ui/ui-webapp/src/containers/Index.tsx +++ b/scm-ui/ui-webapp/src/containers/Index.tsx @@ -23,14 +23,14 @@ */ import React, { FC, useState } from "react"; import App from "./App"; -import { ErrorBoundary, Loading, Header } from "@scm-manager/ui-components"; +import { ErrorBoundary, Header, Loading } from "@scm-manager/ui-components"; import PluginLoader from "./PluginLoader"; import ScrollToTop from "./ScrollToTop"; import IndexErrorPage from "./IndexErrorPage"; import { useIndex } from "@scm-manager/ui-api"; import { Link } from "@scm-manager/ui-types"; import i18next from "i18next"; -import { binder } from "@scm-manager/ui-extensions"; +import { binder, extensionPoints } from "@scm-manager/ui-extensions"; import InitializationAdminAccountStep from "./InitializationAdminAccountStep"; const Index: FC = () => { @@ -69,4 +69,7 @@ const Index: FC = () => { export default Index; -binder.bind("initialization.step.adminAccount", InitializationAdminAccountStep); +binder.bind>( + "initialization.step.adminAccount", + InitializationAdminAccountStep +); diff --git a/scm-ui/ui-webapp/src/containers/LoginButton.tsx b/scm-ui/ui-webapp/src/containers/LoginButton.tsx index 905a68f8cd..5831852fb3 100644 --- a/scm-ui/ui-webapp/src/containers/LoginButton.tsx +++ b/scm-ui/ui-webapp/src/containers/LoginButton.tsx @@ -27,10 +27,9 @@ import { urls } from "@scm-manager/ui-components"; import { binder, ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { useTranslation } from "react-i18next"; import { Links } from "@scm-manager/ui-types"; -import { useLocation } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; import classNames from "classnames"; import HeaderButton from "../components/HeaderButton"; -import { Link } from "react-router-dom"; import HeaderButtonContent, { headerButtonContentClassName } from "../components/HeaderButtonContent"; type Props = { @@ -63,14 +62,20 @@ const LoginButton: FC = ({ burgerMode, links, className }) => { }; if (links?.login) { - const shouldRenderExtension = binder.hasExtension("primary-navigation.login", extensionProps); + const shouldRenderExtension = binder.hasExtension( + "primary-navigation.login", + extensionProps + ); return ( {shouldRenderExtension ? ( - + + name="primary-navigation.login" + props={extensionProps} + /> ) : ( {content} diff --git a/scm-ui/ui-webapp/src/containers/LogoutButton.tsx b/scm-ui/ui-webapp/src/containers/LogoutButton.tsx index 887dddd7dd..50bbc2ccf9 100644 --- a/scm-ui/ui-webapp/src/containers/LogoutButton.tsx +++ b/scm-ui/ui-webapp/src/containers/LogoutButton.tsx @@ -43,16 +43,18 @@ const LogoutButton: FC = ({ burgerMode, links, className }) => { const label = t("primary-navigation.logout"); const content = ; - const extensionProps: extensionPoints.PrimaryNavigationLogoutButtonProps = { + const extensionProps = { links, label, - className: headerButtonContentClassName, content }; if (links?.logout) { - const shouldRenderExtension = binder.hasExtension("primary-navigation.logout", extensionProps); + const shouldRenderExtension = binder.hasExtension( + "primary-navigation.logout", + extensionProps + ); return ( = ({ burgerMode, links, className }) => { className={classNames("is-flex-start", "navbar-item", className)} > {shouldRenderExtension ? ( - + + name="primary-navigation.logout" + props={extensionProps} + /> ) : ( {content} diff --git a/scm-ui/ui-webapp/src/containers/Main.tsx b/scm-ui/ui-webapp/src/containers/Main.tsx index a9fe986f3c..4bd3870722 100644 --- a/scm-ui/ui-webapp/src/containers/Main.tsx +++ b/scm-ui/ui-webapp/src/containers/Main.tsx @@ -27,7 +27,7 @@ import { Redirect, Route, Switch } from "react-router-dom"; import { Links, Me } from "@scm-manager/ui-types"; import { ErrorBoundary, Loading, ProtectedRoute } from "@scm-manager/ui-components"; -import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; +import { binder, ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; // auth routes const Login = React.lazy(() => import("../containers/Login")); @@ -65,7 +65,7 @@ type Props = { const Main: FC = props => { const { authenticated, me } = props; - const redirectUrlFactory = binder.getExtension("main.redirect", props); + const redirectUrlFactory = binder.getExtension("main.redirect", props); let url = "/"; if (authenticated) { url = "/repos/"; @@ -110,7 +110,7 @@ const Main: FC = props => { - + name="main.route" renderAll={true} props={props} />

diff --git a/scm-ui/ui-webapp/src/containers/Profile.tsx b/scm-ui/ui-webapp/src/containers/Profile.tsx index 6c06359582..ee5f9076b8 100644 --- a/scm-ui/ui-webapp/src/containers/Profile.tsx +++ b/scm-ui/ui-webapp/src/containers/Profile.tsx @@ -38,7 +38,7 @@ import { } from "@scm-manager/ui-components"; import ChangeUserPassword from "./ChangeUserPassword"; import ProfileInfo from "./ProfileInfo"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import SetPublicKeys from "../users/components/publicKeys/SetPublicKeys"; import SetPublicKeysNavLink from "../users/components/navLinks/SetPublicKeysNavLink"; import SetApiKeys from "../users/components/apiKeys/SetApiKeys"; @@ -100,7 +100,11 @@ const Profile: FC = () => { )} - + + name="profile.route" + props={extensionProps} + renderAll={true} + /> diff --git a/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx b/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx index 0711aef0d6..4c7d9f8e89 100644 --- a/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx +++ b/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx @@ -24,7 +24,7 @@ import React, { FC } from "react"; import { Route, useParams, useRouteMatch } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { CustomQueryFlexWrappedColumns, ErrorPage, @@ -79,7 +79,7 @@ const SingleGroup: FC = () => { - + name="group.route" props={extensionProps} renderAll={true} /> @@ -89,7 +89,11 @@ const SingleGroup: FC = () => { label={t("singleGroup.menu.informationNavLink")} title={t("singleGroup.menu.informationNavLink")} /> - + + name="group.navigation" + props={extensionProps} + renderAll={true} + /> { > - + + name="group.setting" + props={extensionProps} + renderAll={true} + /> diff --git a/scm-ui/ui-webapp/src/index.tsx b/scm-ui/ui-webapp/src/index.tsx index 26a6363f3a..95cef5f44e 100644 --- a/scm-ui/ui-webapp/src/index.tsx +++ b/scm-ui/ui-webapp/src/index.tsx @@ -31,13 +31,13 @@ import i18n from "./i18n"; import { BrowserRouter as Router } from "react-router-dom"; import { urls } from "@scm-manager/ui-components"; -import { binder } from "@scm-manager/ui-extensions"; +import { binder, extensionPoints } from "@scm-manager/ui-extensions"; import ChangesetShortLink from "./repos/components/changesets/ChangesetShortLink"; import "./tokenExpired"; import { ApiProvider } from "@scm-manager/ui-api"; -binder.bind("changeset.description.tokens", ChangesetShortLink); +binder.bind("changeset.description.tokens", ChangesetShortLink); const root = document.getElementById("root"); if (!root) { diff --git a/scm-ui/ui-webapp/src/repos/branches/components/BranchView.tsx b/scm-ui/ui-webapp/src/repos/branches/components/BranchView.tsx index d7d13e43e3..03a82ddd49 100644 --- a/scm-ui/ui-webapp/src/repos/branches/components/BranchView.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/components/BranchView.tsx @@ -23,7 +23,7 @@ */ import React from "react"; import BranchDetail from "./BranchDetail"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { Branch, Repository } from "@scm-manager/ui-types"; import BranchDangerZone from "../containers/BranchDangerZone"; @@ -40,7 +40,7 @@ class BranchView extends React.Component {
- name="repos.branch-details.information" renderAll={true} props={{ diff --git a/scm-ui/ui-webapp/src/repos/components/NamespaceInput.tsx b/scm-ui/ui-webapp/src/repos/components/NamespaceInput.tsx index 91b3347ea7..3165a5f38a 100644 --- a/scm-ui/ui-webapp/src/repos/components/NamespaceInput.tsx +++ b/scm-ui/ui-webapp/src/repos/components/NamespaceInput.tsx @@ -25,7 +25,7 @@ import React, { FC } from "react"; import { CUSTOM_NAMESPACE_STRATEGY } from "@scm-manager/ui-types"; import { Autocomplete } from "@scm-manager/ui-components"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { useTranslation } from "react-i18next"; import { useNamespaceSuggestions } from "@scm-manager/ui-api"; @@ -77,7 +77,13 @@ const NamespaceInput: FC = ({ ); } - return ; + return ( + + name="repos.create.namespace" + props={props} + renderAll={false} + /> + ); }; export default NamespaceInput; diff --git a/scm-ui/ui-webapp/src/repos/components/RepositoryDetails.tsx b/scm-ui/ui-webapp/src/repos/components/RepositoryDetails.tsx index 9aa1fa2ba1..04a6a756ef 100644 --- a/scm-ui/ui-webapp/src/repos/components/RepositoryDetails.tsx +++ b/scm-ui/ui-webapp/src/repos/components/RepositoryDetails.tsx @@ -24,7 +24,7 @@ import React from "react"; import { Repository } from "@scm-manager/ui-types"; import RepositoryDetailTable from "./RepositoryDetailTable"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; type Props = { repository: Repository; @@ -38,7 +38,7 @@ class RepositoryDetails extends React.Component {
- name="repos.repository-details.information" renderAll={true} props={{ diff --git a/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx b/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx index 3695bbc7ff..d6ab8ffa0b 100644 --- a/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx +++ b/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx @@ -25,7 +25,7 @@ import React, { FC, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import classNames from "classnames"; import styled from "styled-components"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { Changeset, ParentChangeset, Repository } from "@scm-manager/ui-types"; import { AvatarImage, @@ -146,7 +146,7 @@ const ChangesetDetails: FC = ({ changeset, repository, fileControlFactory <>
- name="changeset.description" props={{ changeset, @@ -206,7 +206,7 @@ const ChangesetDetails: FC = ({ changeset, repository, fileControlFactory {description.message.split("\n").map((item, key) => { return ( - name="changeset.description" props={{ changeset, diff --git a/scm-ui/ui-webapp/src/repos/components/changesets/ContributorTable.tsx b/scm-ui/ui-webapp/src/repos/components/changesets/ContributorTable.tsx index 44d853c2dc..247ebfa27d 100644 --- a/scm-ui/ui-webapp/src/repos/components/changesets/ContributorTable.tsx +++ b/scm-ui/ui-webapp/src/repos/components/changesets/ContributorTable.tsx @@ -25,8 +25,8 @@ import React, { FC } from "react"; import { Changeset, Person } from "@scm-manager/ui-types"; import styled from "styled-components"; import { useTranslation } from "react-i18next"; -import { useBinder } from "@scm-manager/ui-extensions"; -import { ContributorAvatar, CommaSeparatedList } from "@scm-manager/ui-components"; +import { extensionPoints, useBinder } from "@scm-manager/ui-extensions"; +import { CommaSeparatedList, ContributorAvatar } from "@scm-manager/ui-components"; type Props = { changeset: Changeset; @@ -39,7 +39,7 @@ const SizedTd = styled.td` const Contributor: FC<{ person: Person }> = ({ person }) => { const [t] = useTranslation("repos"); const binder = useBinder(); - const avatarFactory = binder.getExtension("avatar.factory"); + const avatarFactory = binder.getExtension("avatar.factory"); let prefix = null; if (avatarFactory) { const avatar = avatarFactory(person); diff --git a/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx b/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx index 1115a07119..bd1c9d6e1d 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx +++ b/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx @@ -27,7 +27,7 @@ import { NamespaceCollection, Repository } from "@scm-manager/ui-types"; import groupByNamespace from "./groupByNamespace"; import RepositoryGroupEntry from "./RepositoryGroupEntry"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; type Props = { repositories: Repository[]; @@ -44,7 +44,7 @@ class RepositoryList extends React.Component { const groups = groupByNamespace(repositories, namespaces); return (
- name="repository.overview.top" renderAll={true} props={{ diff --git a/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx b/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx index 2a5827475a..68b4ffbb46 100644 --- a/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx @@ -26,7 +26,7 @@ import { useRouteMatch } from "react-router-dom"; import RepositoryForm from "../components/form"; import { Repository } from "@scm-manager/ui-types"; import { ErrorNotification, Subtitle, urls } from "@scm-manager/ui-components"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import RepositoryDangerZone from "./RepositoryDangerZone"; import { useTranslation } from "react-i18next"; import ExportRepository from "./ExportRepository"; @@ -57,9 +57,17 @@ const EditRepo: FC = ({ repository }) => { - + + name="repo-config.details" + props={extensionProps} + renderAll={true} + /> {repository._links.exportInfo && } - + + name="repo-config.route" + props={extensionProps} + renderAll={true} + /> {(repository._links.runHealthCheck || repository.healthCheckRunning) && ( )} diff --git a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx index d2cc1fb112..074cc3202d 100644 --- a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx @@ -128,7 +128,7 @@ const Overview: FC = () => { const [t] = useTranslation("repos"); const binder = useBinder(); - const extensions = binder.getExtensions("repository.overview.left"); + const extensions = binder.getExtensions("repository.overview.left"); // we keep the create permission in the state, // because it does not change during searching or paging @@ -167,8 +167,16 @@ const Overview: FC = () => { return ( {t("overview.title")}} - subtitle={{t("overview.subtitle")}} + title={ + name="repository.overview.title"> + {t("overview.title")} + + } + subtitle={ + name="repository.overview.subtitle"> + {t("overview.subtitle")} + + } loading={isLoading} error={error} > diff --git a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx index 7fcbf36b01..ec8146da26 100644 --- a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx @@ -25,7 +25,7 @@ import React, { useState } from "react"; import { match as Match } from "react-router"; import { Link as RouteLink, Redirect, Route, RouteProps, Switch, useRouteMatch } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; +import { binder, ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { Changeset, Link } from "@scm-manager/ui-types"; import { CustomQueryFlexWrappedColumns, @@ -122,7 +122,7 @@ const RepositoryRoot = () => { match }; - const redirectUrlFactory = binder.getExtension("repository.redirect", props); + const redirectUrlFactory = binder.getExtension("repository.redirect", props); let redirectedUrl; if (redirectUrlFactory) { redirectedUrl = url + redirectUrlFactory(props); @@ -295,12 +295,20 @@ const RepositoryRoot = () => { - + + name="repository.route" + props={extensionProps} + renderAll={true} + /> - + + name="repository.navigation.topLevel" + props={extensionProps} + renderAll={true} + /> { activeOnlyWhenExact={false} title={t("repositoryRoot.menu.sourcesNavLink")} /> - + + name="repository.navigation" + props={extensionProps} + renderAll={true} + /> { > - + + name="repository.setting" + props={extensionProps} + renderAll={true} + /> diff --git a/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx b/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx index 9d70f961de..0b739b9db0 100644 --- a/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx @@ -34,12 +34,12 @@ import { SecondaryNavigation, SecondaryNavigationColumn, StateMenuContextProvider, - SubNavigation + SubNavigation, + urls, } from "@scm-manager/ui-components"; import Permissions from "../../permissions/containers/Permissions"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import PermissionsNavLink from "./PermissionsNavLink"; -import { urls } from "@scm-manager/ui-components"; import { useNamespace } from "@scm-manager/ui-api"; type Params = { @@ -81,15 +81,27 @@ const NamespaceRoot: FC = () => { - - + + name="namespace.navigation.topLevel" + props={extensionProps} + renderAll={true} + /> + + name="namespace.route" + props={extensionProps} + renderAll={true} + /> - + + name="namespace.setting" + props={extensionProps} + renderAll={true} + /> diff --git a/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx b/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx index 139362c579..6036cc6796 100644 --- a/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx @@ -87,7 +87,12 @@ const FileTree: FC = ({ repository, directory, baseUrl, revision, fetchNe return (
- + + name="repos.source.tree.wrapper" + props={extProps} + renderAll={true} + wrapper={true} + > diff --git a/scm-ui/ui-webapp/src/repos/sources/components/FileTreeLeaf.tsx b/scm-ui/ui-webapp/src/repos/sources/components/FileTreeLeaf.tsx index c48281f9c9..4994f2d0a3 100644 --- a/scm-ui/ui-webapp/src/repos/sources/components/FileTreeLeaf.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/components/FileTreeLeaf.tsx @@ -116,15 +116,24 @@ class FileTreeLeaf extends React.Component { {this.contentIfPresent(file, "description", file => file.description)} - {binder.hasExtension("repos.sources.tree.row.right") && ( + + {binder.hasExtension("repos.sources.tree.row.right", extProps) && ( {!file.directory && ( - + + name="repos.sources.tree.row.right" + props={extProps} + renderAll={true} + /> )} )} - + + name="repos.sources.tree.row.after" + props={extProps} + renderAll={true} + /> ); } diff --git a/scm-ui/ui-webapp/src/repos/sources/components/content/DownloadViewer.tsx b/scm-ui/ui-webapp/src/repos/sources/components/content/DownloadViewer.tsx index 9691aeb7e1..aabd1f4cbd 100644 --- a/scm-ui/ui-webapp/src/repos/sources/components/content/DownloadViewer.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/components/content/DownloadViewer.tsx @@ -25,7 +25,7 @@ import React from "react"; import { WithTranslation, withTranslation } from "react-i18next"; import { File, Link, Repository } from "@scm-manager/ui-types"; import { DownloadButton } from "@scm-manager/ui-components"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; type Props = WithTranslation & { repository: Repository; @@ -38,7 +38,10 @@ class DownloadViewer extends React.Component { return (
- + + name="repos.sources.content.downloadButton" + props={{ repository, file }} + >
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 05364c9074..4235fd9cd8 100644 --- a/scm-ui/ui-webapp/src/repos/sources/containers/Content.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/containers/Content.tsx @@ -25,7 +25,7 @@ import React, { FC, ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import styled from "styled-components"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { File, Repository } from "@scm-manager/ui-types"; import { DateFromNow, ErrorNotification, FileSize, Icon, OpenInFullscreenButton } from "@scm-manager/ui-components"; import FileButtonAddons from "../components/content/FileButtonAddons"; @@ -136,7 +136,7 @@ const Content: FC = ({ file, repository, revision, breadcrumb, error }) = modalBody={{content}} tooltipStyle="htmlTitle" /> - name="repos.sources.content.actionbar" props={{ repository, @@ -195,7 +195,7 @@ const Content: FC = ({ file, repository, revision, breadcrumb, error }) =
- name="repos.content.metadata" renderAll={true} props={{ diff --git a/scm-ui/ui-webapp/src/repos/sources/containers/SourcesView.tsx b/scm-ui/ui-webapp/src/repos/sources/containers/SourcesView.tsx index 8579bbc278..cecde05b68 100644 --- a/scm-ui/ui-webapp/src/repos/sources/containers/SourcesView.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/containers/SourcesView.tsx @@ -26,7 +26,7 @@ import React, { FC } from "react"; import SourcecodeViewer from "../components/content/SourcecodeViewer"; import ImageViewer from "../components/content/ImageViewer"; import DownloadViewer from "../components/content/DownloadViewer"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { File, Link, Repository } from "@scm-manager/ui-types"; import { ErrorNotification, Loading, PdfViewer } from "@scm-manager/ui-components"; import SwitchableMarkdownViewer from "../components/content/SwitchableMarkdownViewer"; @@ -75,7 +75,7 @@ const SourcesView: FC = ({ file, repository, revision }) => { sources = ; } else { sources = ( - name="repos.sources.view" props={{ file, diff --git a/scm-ui/ui-webapp/src/repos/tags/components/TagView.tsx b/scm-ui/ui-webapp/src/repos/tags/components/TagView.tsx index 6c94fb72e7..54d7d1ec59 100644 --- a/scm-ui/ui-webapp/src/repos/tags/components/TagView.tsx +++ b/scm-ui/ui-webapp/src/repos/tags/components/TagView.tsx @@ -24,7 +24,7 @@ import React, { FC } from "react"; import { Repository, Tag } from "@scm-manager/ui-types"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import TagDetail from "./TagDetail"; import TagDangerZone from "../container/TagDangerZone"; @@ -39,7 +39,7 @@ const TagView: FC = ({ repository, tag }) => {
- name="repos.tag-details.information" renderAll={true} props={{ diff --git a/scm-ui/ui-webapp/src/search/Hits.tsx b/scm-ui/ui-webapp/src/search/Hits.tsx index b2f1148a9d..e6cbb431b5 100644 --- a/scm-ui/ui-webapp/src/search/Hits.tsx +++ b/scm-ui/ui-webapp/src/search/Hits.tsx @@ -28,9 +28,9 @@ import RepositoryHit from "./RepositoryHit"; import GenericHit from "./GenericHit"; import UserHit from "./UserHit"; import GroupHit from "./GroupHit"; -import { Notification, HitProps } from "@scm-manager/ui-components"; +import { HitProps, Notification } from "@scm-manager/ui-components"; import { useTranslation } from "react-i18next"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; type Props = { type: string; @@ -62,7 +62,7 @@ const InternalHitRenderer: FC = ({ type, hit }) => { }; const HitComponent: FC = ({ hit, type }) => ( - + name={`search.hit.${type}.renderer`} props={{ hit }}> ); diff --git a/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx b/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx index beddc3d838..f0c16b81b1 100644 --- a/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx +++ b/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx @@ -23,7 +23,7 @@ */ import React, { FC } from "react"; import { Route, useParams, useRouteMatch } from "react-router-dom"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { CustomQueryFlexWrappedColumns, ErrorPage, @@ -97,7 +97,7 @@ const SingleUser: FC = () => { - + name="user.route" props={extensionProps} renderAll={true} /> @@ -119,7 +119,11 @@ const SingleUser: FC = () => { - + + name="user.setting" + props={extensionProps} + renderAll={true} + />
{t("sources.content.description")} {description}