diff --git a/scm-ui/ui-components/src/__resources__/avatar.png b/scm-ui/ui-components/src/__resources__/avatar.png
new file mode 100644
index 0000000000..71632a3a51
Binary files /dev/null and b/scm-ui/ui-components/src/__resources__/avatar.png differ
diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap
index 0ec9bb0c76..17a73282c2 100644
--- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap
+++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap
@@ -32389,6 +32389,272 @@ exports[`Storyshots Forms|Textarea OnSubmit 1`] = `
`;
+exports[`Storyshots Layout|Footer Default 1`] = `
+
+`;
+
+exports[`Storyshots Layout|Footer Full 1`] = `
+
+`;
+
+exports[`Storyshots Layout|Footer With Avatar 1`] = `
+
+`;
+
+exports[`Storyshots Layout|Footer With Plugin Links 1`] = `
+
+`;
+
exports[`Storyshots Loading Default 1`] = `
= ({person, representation = "rounded-border"}) => {
- const avatarFactory = binder.getExtension(EXTENSION_POINT);
- if (avatarFactory) {
- const avatar = avatarFactory(person);
+const AvatarImage: FC
= ({ person, representation = "rounded-border" }) => {
+ const binder = useBinder();
+ const avatarFactory = binder.getExtension(EXTENSION_POINT);
+ if (avatarFactory) {
+ const avatar = avatarFactory(person);
- const className = representation === "rounded" ? "is-rounded" : "has-rounded-border";
+ const className = representation === "rounded" ? "is-rounded" : "has-rounded-border";
- return ;
- }
+ return ;
+ }
- return null;
+ return null;
};
export default AvatarImage;
diff --git a/scm-ui/ui-components/src/avatar/AvatarWrapper.tsx b/scm-ui/ui-components/src/avatar/AvatarWrapper.tsx
index 09ed7391cf..695cb36064 100644
--- a/scm-ui/ui-components/src/avatar/AvatarWrapper.tsx
+++ b/scm-ui/ui-components/src/avatar/AvatarWrapper.tsx
@@ -1,18 +1,13 @@
-import React, { Component, ReactNode } from "react";
-import { binder } from "@scm-manager/ui-extensions";
+import React, { FC } from "react";
+import { useBinder } from "@scm-manager/ui-extensions";
import { EXTENSION_POINT } from "./Avatar";
-type Props = {
- children: ReactNode;
+const AvatarWrapper: FC = ({ children }) => {
+ const binder = useBinder();
+ if (binder.hasExtension(EXTENSION_POINT)) {
+ return <>{children}>;
+ }
+ return null;
};
-class AvatarWrapper extends Component {
- render() {
- if (binder.hasExtension(EXTENSION_POINT)) {
- return <>{this.props.children}>;
- }
- return null;
- }
-}
-
export default AvatarWrapper;
diff --git a/scm-ui/ui-components/src/layout/Footer.stories.tsx b/scm-ui/ui-components/src/layout/Footer.stories.tsx
new file mode 100644
index 0000000000..a4f85bc4ef
--- /dev/null
+++ b/scm-ui/ui-components/src/layout/Footer.stories.tsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { storiesOf } from "@storybook/react";
+import Footer from "./Footer";
+import { Binder, BinderContext } from "@scm-manager/ui-extensions";
+import { Me } from "@scm-manager/ui-types";
+import { EXTENSION_POINT } from "../avatar/Avatar";
+// @ts-ignore ignore unknown png
+import avatar from "../__resources__/avatar.png";
+
+const trillian: Me = {
+ name: "trillian",
+ displayName: "Trillian McMillian",
+ mail: "tricia@hitchhiker.com",
+ groups: ["crew"],
+ _links: {}
+};
+
+const bindAvatar = (binder: Binder) => {
+ binder.bind(EXTENSION_POINT, () => {
+ return avatar;
+ });
+};
+
+const bindLinks = (binder: Binder) => {
+ binder.bind("footer.links", () => REST API);
+ binder.bind("footer.links", () => CLI);
+};
+
+const withBinder = (binder: Binder) => {
+ return (
+
+
+
+ );
+};
+
+storiesOf("Layout|Footer", module)
+ .add("Default", () => {
+ return ;
+ })
+ .add("With Avatar", () => {
+ const binder = new Binder("avatar-story");
+ bindAvatar(binder);
+ return withBinder(binder);
+ })
+ .add("With Plugin Links", () => {
+ const binder = new Binder("link-story");
+ bindLinks(binder);
+ return withBinder(binder);
+ })
+ .add("Full", () => {
+ const binder = new Binder("link-story");
+ bindAvatar(binder);
+ bindLinks(binder);
+ return withBinder(binder);
+ });
diff --git a/scm-ui/ui-components/src/layout/Footer.tsx b/scm-ui/ui-components/src/layout/Footer.tsx
index a74551198a..3243134353 100644
--- a/scm-ui/ui-components/src/layout/Footer.tsx
+++ b/scm-ui/ui-components/src/layout/Footer.tsx
@@ -1,7 +1,7 @@
-import React from "react";
+import React, { FC } from "react";
import { Me, Links } from "@scm-manager/ui-types";
import { Link } from "react-router-dom";
-import { binder } from "@scm-manager/ui-extensions";
+import { useBinder } from "@scm-manager/ui-extensions";
import { AvatarWrapper, AvatarImage } from "../avatar";
type Props = {
@@ -10,56 +10,53 @@ type Props = {
links: Links;
};
-class Footer extends React.Component {
- render() {
- const { me, version, links } = this.props;
- if (!me) {
- return "";
- }
-
- const extensionProps = { me, links };
- const extensions = binder.getExtensions("footer.links", extensionProps);
-
- return (
-
- );
+const Footer: FC = ({ me, version, links }) => {
+ const binder = useBinder();
+ if (!me) {
+ return null;
}
-}
+ const extensionProps = { me, links };
+ const extensions = binder.getExtensions("footer.links", extensionProps);
+
+ return (
+
+ );
+};
export default Footer;
diff --git a/scm-ui/ui-extensions/src/ExtensionPoint.test.tsx b/scm-ui/ui-extensions/src/ExtensionPoint.test.tsx
index c40362070f..171b60b862 100644
--- a/scm-ui/ui-extensions/src/ExtensionPoint.test.tsx
+++ b/scm-ui/ui-extensions/src/ExtensionPoint.test.tsx
@@ -33,12 +33,6 @@ describe("ExtensionPoint test", () => {
expect(rendered.text()).toBe("Extension One");
});
- // We use this wrapper since Enzyme cannot handle React Fragments (see https://github.com/airbnb/enzyme/issues/1213)
- class ExtensionPointEnzymeFix extends ExtensionPoint {
- render() {
- return {super.render()}
;
- }
- }
it("should render the given components", () => {
const labelOne = () => {
return ;
@@ -50,7 +44,7 @@ describe("ExtensionPoint test", () => {
mockedBinder.hasExtension.mockReturnValue(true);
mockedBinder.getExtensions.mockReturnValue([labelOne, labelTwo]);
- const rendered = mount();
+ const rendered = mount();
const text = rendered.text();
expect(text).toContain("Extension One");
expect(text).toContain("Extension Two");
@@ -143,4 +137,12 @@ describe("ExtensionPoint test", () => {
const text = rendered.text();
expect(text).toBe("Hello Trillian");
});
+
+ it("should not render nothing without extension and without default", () => {
+ mockedBinder.hasExtension.mockReturnValue(false);
+
+ const rendered = mount();
+ const text = rendered.text();
+ expect(text).toBe("");
+ });
});
diff --git a/scm-ui/ui-extensions/src/ExtensionPoint.tsx b/scm-ui/ui-extensions/src/ExtensionPoint.tsx
index 9ae68a1224..24caef63f9 100644
--- a/scm-ui/ui-extensions/src/ExtensionPoint.tsx
+++ b/scm-ui/ui-extensions/src/ExtensionPoint.tsx
@@ -1,53 +1,51 @@
import * as React from "react";
-import binder from "./binder";
+import { Binder } from "./binder";
+import { FC, ReactNode } from "react";
+import useBinder from "./useBinder";
type Props = {
name: string;
renderAll?: boolean;
props?: object;
- children?: React.ReactNode;
+};
+
+const renderAllExtensions = (binder: Binder, name: string, props?: object) => {
+ const extensions = binder.getExtensions(name, props);
+ return (
+ <>
+ {extensions.map((Component, index) => {
+ return ;
+ })}
+ >
+ );
+};
+
+const renderSingleExtension = (binder: Binder, name: string, props?: object) => {
+ const Component = binder.getExtension(name, props);
+ if (!Component) {
+ return null;
+ }
+ return ;
+};
+
+const renderDefault = (children: ReactNode) => {
+ if (children) {
+ return <>{children}>;
+ }
+ return null;
};
/**
* ExtensionPoint renders components which are bound to an extension point.
*/
-class ExtensionPoint extends React.Component {
- renderAll(name: string, props?: object) {
- const extensions = binder.getExtensions(name, props);
- return (
- <>
- {extensions.map((Component, index) => {
- return ;
- })}
- >
- );
+const ExtensionPoint: FC = ({ name, renderAll, props, children }) => {
+ const binder = useBinder();
+ if (!binder.hasExtension(name, props)) {
+ return renderDefault(children);
+ } else if (renderAll) {
+ return renderAllExtensions(binder, name, props);
}
-
- renderSingle(name: string, props?: object) {
- const Component = binder.getExtension(name, props);
- if (!Component) {
- return null;
- }
- return ;
- }
-
- renderDefault() {
- const { children } = this.props;
- if (children) {
- return <>{children}>;
- }
- return null;
- }
-
- render() {
- const { name, renderAll, props } = this.props;
- if (!binder.hasExtension(name, props)) {
- return this.renderDefault();
- } else if (renderAll) {
- return this.renderAll(name, props);
- }
- return this.renderSingle(name, props);
- }
-}
+ return renderSingleExtension(binder, name, props);
+};
export default ExtensionPoint;
diff --git a/scm-ui/ui-extensions/src/binder.test.ts b/scm-ui/ui-extensions/src/binder.test.ts
index 2bcd302a36..6915f04282 100644
--- a/scm-ui/ui-extensions/src/binder.test.ts
+++ b/scm-ui/ui-extensions/src/binder.test.ts
@@ -4,7 +4,7 @@ describe("binder tests", () => {
let binder: Binder;
beforeEach(() => {
- binder = new Binder();
+ binder = new Binder("testing");
});
it("should return an empty array for non existing extension points", () => {
diff --git a/scm-ui/ui-extensions/src/binder.ts b/scm-ui/ui-extensions/src/binder.ts
index a359973a50..e3f2b9f120 100644
--- a/scm-ui/ui-extensions/src/binder.ts
+++ b/scm-ui/ui-extensions/src/binder.ts
@@ -10,11 +10,13 @@ type ExtensionRegistration = {
* The Binder class is mainly exported for testing, plugins should only use the default export.
*/
export class Binder {
+ name: string;
extensionPoints: {
[key: string]: Array;
};
- constructor() {
+ constructor(name: string) {
+ this.name = name;
this.extensionPoints = {};
}
@@ -73,6 +75,6 @@ export class Binder {
}
// singleton binder
-const binder = new Binder();
+const binder = new Binder("default");
export default binder;
diff --git a/scm-ui/ui-extensions/src/index.ts b/scm-ui/ui-extensions/src/index.ts
index 73267e29a3..1c632b8a38 100644
--- a/scm-ui/ui-extensions/src/index.ts
+++ b/scm-ui/ui-extensions/src/index.ts
@@ -1,2 +1,3 @@
-export { default as binder } from "./binder";
+export { default as binder, Binder } from "./binder";
+export * from "./useBinder";
export { default as ExtensionPoint } from "./ExtensionPoint";
diff --git a/scm-ui/ui-extensions/src/useBinder.test.tsx b/scm-ui/ui-extensions/src/useBinder.test.tsx
new file mode 100644
index 0000000000..4fc73974d6
--- /dev/null
+++ b/scm-ui/ui-extensions/src/useBinder.test.tsx
@@ -0,0 +1,29 @@
+import useBinder, { BinderContext } from "./useBinder";
+import { Binder } from "./binder";
+import { mount } from "enzyme";
+import "@scm-manager/ui-tests/enzyme";
+import React from "react";
+
+describe("useBinder tests", () => {
+ const BinderName = () => {
+ const binder = useBinder();
+ return <>{binder.name}>;
+ };
+
+ it("should return default binder", () => {
+ const rendered = mount();
+ expect(rendered.text()).toBe("default");
+ });
+
+ it("should return binder from context", () => {
+ const binder = new Binder("from-context");
+ const app = (
+
+
+
+ );
+
+ const rendered = mount(app);
+ expect(rendered.text()).toBe("from-context");
+ });
+});
diff --git a/scm-ui/ui-extensions/src/useBinder.ts b/scm-ui/ui-extensions/src/useBinder.ts
new file mode 100644
index 0000000000..1d63dd7e8d
--- /dev/null
+++ b/scm-ui/ui-extensions/src/useBinder.ts
@@ -0,0 +1,16 @@
+import { createContext, useContext } from "react";
+import defaultBinder from "./binder";
+
+/**
+ * The BinderContext should only be used to override the default binder for testing purposes.
+ */
+export const BinderContext = createContext(defaultBinder);
+
+/**
+ * Hook to get the binder from context.
+ */
+export const useBinder = () => {
+ return useContext(BinderContext);
+};
+
+export default useBinder;
diff --git a/scm-ui/ui-types/src/Me.ts b/scm-ui/ui-types/src/Me.ts
index d43932d9b3..9f478a9ccc 100644
--- a/scm-ui/ui-types/src/Me.ts
+++ b/scm-ui/ui-types/src/Me.ts
@@ -4,6 +4,6 @@ export type Me = {
name: string;
displayName: string;
mail: string;
- groups: [];
+ groups: string[];
_links: Links;
};