diff --git a/docs/en/development/ui-extensions.md b/docs/en/development/ui-extensions.md
index 7f0821550f..b1490a7126 100644
--- a/docs/en/development/ui-extensions.md
+++ b/docs/en/development/ui-extensions.md
@@ -105,7 +105,7 @@ binder.bind("repo.avatar", GitAvatar, (props) => props.type === "git");
```
```javascript
-
+
```
### Typings
@@ -141,3 +141,75 @@ Negative Example:
This code for example, would lead to a compile time type error because we made a typo in the `name` of the extension when binding it.
If we had used the `bind` method without the type parameter, we would not have gotten an error but run into problems at runtime.
+
+### Children
+
+If an extension point defines children those children are propagated to the extensions as children prop e.g:
+
+```tsx
+const MyExtension:FC = ({children}) => (
+
{children}
+)
+const App = () => {
+ binder.bind("box", MyExtension);
+ return (
+
+ Box Content
+
+ );
+}
+```
+
+The example above renders the following html code:
+
+```html
+
+```
+
+An exception is when the extension already has a children property, this could be the case if jsx is directly bind.
+This exception applies not only to the children property it applies to every property.
+The example below renders `Ahoi`, because the property of the jsx overwrites the one from the extension point.
+
+```tsx
+type Props = {
+ greeting: string;
+}
+
+const GreetingExtension:FC = ({greeting}) => (
+ <>{greeting}>
+);
+
+const App = () => {
+ binder.bind("greet", );
+ return ;
+};
+```
+
+### Wrapper
+
+Sometimes it can be useful to allow plugin developers to wrap an existing component.
+The `wrapper` property is exactly for this case, it allows to wrap an existing component with multiple extensions e.g.:
+
+```tsx
+const Outer: FC = ({ children }) => (
+ <>Outer -> {children}>
+);
+
+const Inner: FC = ({ children }) => (
+ <>Outer -> {children}>
+);
+
+const App = () => {
+ binder.bind("wrapped", Outer);
+ binder.bind("wrapped", Inner);
+ return (
+
+ Children
+
+ );
+}
+```
+
+The example above renders `Outer -> Inner -> Children`, because each extension is passed as children to the parent extension.
diff --git a/gradle/changelog/ep_history_download.yaml b/gradle/changelog/ep_history_download.yaml
new file mode 100644
index 0000000000..c35635baf6
--- /dev/null
+++ b/gradle/changelog/ep_history_download.yaml
@@ -0,0 +1,2 @@
+- type: Added
+ description: Extension points for source tree ([#1816](https://github.com/scm-manager/scm-manager/pull/1816))
diff --git a/scm-ui/ui-extensions/src/ExtensionPoint.test.tsx b/scm-ui/ui-extensions/src/ExtensionPoint.test.tsx
index 0f627374bb..0794322c39 100644
--- a/scm-ui/ui-extensions/src/ExtensionPoint.test.tsx
+++ b/scm-ui/ui-extensions/src/ExtensionPoint.test.tsx
@@ -21,9 +21,10 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-import React from "react";
+import React, { FC } from "react";
import ExtensionPoint from "./ExtensionPoint";
import { shallow, mount } from "enzyme";
+// eslint-disable-next-line no-restricted-imports
import "@scm-manager/ui-tests/enzyme";
import binder from "./binder";
@@ -89,7 +90,7 @@ describe("ExtensionPoint test", () => {
);
@@ -126,7 +127,7 @@ describe("ExtensionPoint test", () => {
it("should pass the context of the parent component", () => {
const UserContext = React.createContext({
- name: "anonymous"
+ name: "anonymous",
});
type HelloProps = {
@@ -148,7 +149,7 @@ describe("ExtensionPoint test", () => {
return (
@@ -187,7 +188,7 @@ describe("ExtensionPoint test", () => {
};
mockedBinder.hasExtension.mockReturnValue(true);
- mockedBinder.getExtension.mockReturnValue( );
+ mockedBinder.getExtension.mockReturnValue(Label);
const rendered = mount( );
expect(rendered.text()).toBe("Extension Two");
@@ -203,11 +204,79 @@ describe("ExtensionPoint test", () => {
const transformer = (props: object) => {
return {
...props,
- name: "Two"
+ name: "Two",
};
};
const rendered = mount( );
expect(rendered.text()).toBe("Extension Two");
});
+
+ it("should pass children as props", () => {
+ const label: FC = ({ children }) => {
+ return (
+ <>
+ Bound Extension
+ {children}
+ >
+ );
+ };
+ mockedBinder.hasExtension.mockReturnValue(true);
+ mockedBinder.getExtension.mockReturnValue(label);
+
+ const rendered = mount(
+
+ Cool stuff
+
+ );
+ const text = rendered.text();
+ expect(text).toContain("Bound Extension");
+ expect(text).toContain("Cool stuff");
+ });
+
+ it("should wrap children with multiple extensions", () => {
+ const w1: FC = ({ children }) => (
+ <>
+ Outer {"-> "}
+ {children}
+ >
+ );
+
+ const w2: FC = ({ children }) => (
+ <>
+ Inner {"-> "}
+ {children}
+ >
+ );
+
+ mockedBinder.hasExtension.mockReturnValue(true);
+ mockedBinder.getExtensions.mockReturnValue([w1, w2]);
+
+ const rendered = mount(
+
+ Children
+
+ );
+ const text = rendered.text();
+ expect(text).toEqual("Outer -> Inner -> Children");
+ });
+
+ it("should render children of non fc", () => {
+ const nonfc = (
+
+ Non fc with children
+
+ );
+
+ mockedBinder.hasExtension.mockReturnValue(true);
+ mockedBinder.getExtension.mockReturnValue(nonfc);
+
+ const rendered = mount(
+
+ Children
+
+ );
+ const text = rendered.text();
+ expect(text).toEqual("Non fc with children");
+ });
});
diff --git a/scm-ui/ui-extensions/src/ExtensionPoint.tsx b/scm-ui/ui-extensions/src/ExtensionPoint.tsx
index f6a1dde719..83622f0918 100644
--- a/scm-ui/ui-extensions/src/ExtensionPoint.tsx
+++ b/scm-ui/ui-extensions/src/ExtensionPoint.tsx
@@ -21,9 +21,8 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-import * as React from "react";
+import React, { FC, ReactNode } from "react";
import { Binder } from "./binder";
-import { Component, FC, ReactNode } from "react";
import useBinder from "./useBinder";
type PropTransformer = (props: object) => object;
@@ -33,12 +32,14 @@ type Props = {
renderAll?: boolean;
props?: object;
propTransformer?: PropTransformer;
+ wrapper?: boolean;
};
const createInstance = (Component: any, props: object, key?: number) => {
const instanceProps = {
...props,
- key
+ ...(Component.props || {}),
+ key,
};
if (React.isValidElement(Component)) {
return React.cloneElement(Component, instanceProps);
@@ -51,12 +52,28 @@ const renderAllExtensions = (binder: Binder, name: string, props: object) => {
return <>{extensions.map((cmp, index) => createInstance(cmp, props, index))}>;
};
+const renderWrapperExtensions = (binder: Binder, name: string, props: object) => {
+ const extensions = [...(binder.getExtensions(name, props) || [])];
+ extensions.reverse();
+
+ let instance: any = null;
+ extensions.forEach((cmp, index) => {
+ let instanceProps = props;
+ if (instance) {
+ instanceProps = { ...props, children: instance };
+ }
+ instance = createInstance(cmp, instanceProps, index);
+ });
+
+ return instance;
+};
+
const renderSingleExtension = (binder: Binder, name: string, props: object) => {
const cmp = binder.getExtension(name, props);
if (!cmp) {
return null;
}
- return createInstance(cmp, props, undefined);
+ return createInstance(cmp, props);
};
const renderDefault = (children: ReactNode) => {
@@ -67,11 +84,11 @@ const renderDefault = (children: ReactNode) => {
};
const createRenderProps = (propTransformer?: PropTransformer, props?: object) => {
- const transform = (props: object) => {
+ const transform = (untransformedProps: object) => {
if (!propTransformer) {
- return props;
+ return untransformedProps;
}
- return propTransformer(props);
+ return propTransformer(untransformedProps);
};
return transform(props || {});
@@ -80,12 +97,15 @@ const createRenderProps = (propTransformer?: PropTransformer, props?: object) =>
/**
* ExtensionPoint renders components which are bound to an extension point.
*/
-const ExtensionPoint: FC = ({ name, propTransformer, props, renderAll, children }) => {
+const ExtensionPoint: FC = ({ name, propTransformer, props, renderAll, wrapper, children }) => {
const binder = useBinder();
- const renderProps = createRenderProps(propTransformer, props);
+ const renderProps = createRenderProps(propTransformer, { ...(props || {}), children });
if (!binder.hasExtension(name, renderProps)) {
return renderDefault(children);
} else if (renderAll) {
+ if (wrapper) {
+ return renderWrapperExtensions(binder, name, renderProps);
+ }
return renderAllExtensions(binder, name, renderProps);
}
return renderSingleExtension(binder, name, renderProps);
diff --git a/scm-ui/ui-extensions/src/extensionPoints.ts b/scm-ui/ui-extensions/src/extensionPoints.ts
index 9e2c9cdd30..817b4f2563 100644
--- a/scm-ui/ui-extensions/src/extensionPoints.ts
+++ b/scm-ui/ui-extensions/src/extensionPoints.ts
@@ -24,6 +24,7 @@
import React from "react";
import {
+ File,
Branch,
IndexResources,
Links,
@@ -83,6 +84,31 @@ export type ReposSourcesEmptyActionbar = ExtensionPointDefinition<
ReposSourcesEmptyActionbarExtension
>;
+export type ReposSourcesTreeWrapperProps = {
+ repository: Repository;
+ directory: File;
+ baseUrl: string;
+ revision: string;
+};
+
+export type ReposSourcesTreeWrapperExtension = ExtensionPointDefinition<
+ "repos.source.tree.wrapper",
+ React.ComponentType
+>;
+
+export type ReposSourcesTreeRowProps = {
+ file: File;
+};
+
+export type ReposSourcesTreeRowRightExtension = ExtensionPointDefinition<
+ "repos.sources.tree.row.right",
+ React.ComponentType
+>;
+export type ReposSourcesTreeRowAfterExtension = ExtensionPointDefinition<
+ "repos.sources.tree.row.after",
+ React.ComponentType
+>;
+
export type PrimaryNavigationLoginButtonProps = {
links: Links;
label: string;
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 89d1a3f968..87e2367315 100644
--- a/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx
+++ b/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx
@@ -25,14 +25,15 @@
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
-import { binder } from "@scm-manager/ui-extensions";
-import { File } from "@scm-manager/ui-types";
-import { Notification } from "@scm-manager/ui-components";
+import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
+import { File, Repository } from "@scm-manager/ui-types";
import FileTreeLeaf from "./FileTreeLeaf";
import TruncatedNotification from "./TruncatedNotification";
-import {isRootPath} from "../utils/files";
+import { isRootPath } from "../utils/files";
+import { extensionPoints } from "@scm-manager/ui-extensions";
type Props = {
+ repository: Repository;
directory: File;
baseUrl: string;
revision: string;
@@ -56,7 +57,7 @@ export function findParent(path: string) {
return "";
}
-const FileTree: FC = ({ directory, baseUrl, revision, fetchNextPage, isFetchingNextPage }) => {
+const FileTree: FC = ({ repository, directory, baseUrl, revision, fetchNextPage, isFetchingNextPage }) => {
const [t] = useTranslation("repos");
const { path } = directory;
const files: File[] = [];
@@ -69,8 +70,8 @@ const FileTree: FC = ({ directory, baseUrl, revision, fetchNextPage, isFe
revision,
_links: {},
_embedded: {
- children: []
- }
+ children: [],
+ },
});
}
@@ -78,30 +79,39 @@ const FileTree: FC = ({ directory, baseUrl, revision, fetchNextPage, isFe
const baseUrlWithRevision = baseUrl + "/" + encodeURIComponent(revision);
+ const extProps: extensionPoints.ReposSourcesTreeWrapperProps = {
+ repository,
+ directory,
+ baseUrl,
+ revision,
+ };
+
return (
-
-
-
-
- {t("sources.fileTree.name")}
- {t("sources.fileTree.length")}
- {t("sources.fileTree.commitDate")}
- {t("sources.fileTree.description")}
- {binder.hasExtension("repos.sources.tree.row.right") && }
-
-
-
- {files.map((file: File) => (
-
- ))}
-
-
-
+
+
+
+
+
+ {t("sources.fileTree.name")}
+ {t("sources.fileTree.length")}
+ {t("sources.fileTree.commitDate")}
+ {t("sources.fileTree.description")}
+ {binder.hasExtension("repos.sources.tree.row.right") && }
+
+
+
+ {files.map((file: File) => (
+
+ ))}
+
+
+
+
);
};
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 33c566ef2c..6793289d34 100644
--- a/scm-ui/ui-webapp/src/repos/sources/components/FileTreeLeaf.tsx
+++ b/scm-ui/ui-webapp/src/repos/sources/components/FileTreeLeaf.tsx
@@ -25,7 +25,7 @@ import * as React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import classNames from "classnames";
import styled from "styled-components";
-import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
+import { binder, ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions";
import { File } from "@scm-manager/ui-types";
import { DateFromNow, FileSize, Tooltip, Icon } from "@scm-manager/ui-components";
import FileIcon from "./FileIcon";
@@ -44,6 +44,14 @@ const NoWrapTd = styled.td`
white-space: nowrap;
`;
+const ExtensionTd = styled.td`
+ white-space: nowrap;
+
+ > *:not(:last-child) {
+ margin-right: 0.5rem;
+ }
+`;
+
class FileTreeLeaf extends React.Component {
createFileIcon = (file: File) => {
return (
@@ -88,31 +96,32 @@ class FileTreeLeaf extends React.Component {
const renderFileSize = (file: File) => ;
const renderCommitDate = (file: File) => ;
+ const extProps: extensionPoints.ReposSourcesTreeRowProps = {
+ file,
+ };
+
return (
-
- {this.createFileIcon(file)}
- {this.createFileName(file)}
-
- {file.directory ? "" : this.contentIfPresent(file, "length", renderFileSize)}
-
- {this.contentIfPresent(file, "commitDate", renderCommitDate)}
-
- {this.contentIfPresent(file, "description", file => file.description)}
-
- {binder.hasExtension("repos.sources.tree.row.right") && (
-
- {!file.directory && (
-
- )}
-
- )}
-
+ <>
+
+ {this.createFileIcon(file)}
+ {this.createFileName(file)}
+
+ {file.directory ? "" : this.contentIfPresent(file, "length", renderFileSize)}
+
+ {this.contentIfPresent(file, "commitDate", renderCommitDate)}
+
+ {this.contentIfPresent(file, "description", (file) => file.description)}
+
+ {binder.hasExtension("repos.sources.tree.row.right") && (
+
+ {!file.directory && (
+
+ )}
+
+ )}
+
+
+ >
);
}
}
diff --git a/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx b/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx
index da8c42ef98..0b51b56d67 100644
--- a/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx
+++ b/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx
@@ -149,6 +149,7 @@ const Sources: FC = ({ repository, branches, selectedBranch, baseUrl }) =
} else {
body = (