Add extension points for source tree (#1816)

This change will add an extension point which allows to wrap the source tree. This is required in order to use a context provider e.g. to capture a selected file. Another extension point allows to add a row between the row of a file.
In order to implement the extension points ui-extensions has now a wrapper property and passes the children of an extension point to implementing extension.
This commit is contained in:
Sebastian Sdorra
2021-09-30 16:41:04 +02:00
committed by GitHub
parent 2b85081032
commit f5d9855a24
8 changed files with 279 additions and 70 deletions

View File

@@ -105,7 +105,7 @@ binder.bind("repo.avatar", GitAvatar, (props) => props.type === "git");
``` ```
```javascript ```javascript
<ExtensionPoint name="repo.avatar" props={type: "git"} /> <ExtensionPoint name="repo.avatar" props={{type: "git"}} />
``` ```
### Typings ### 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. 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. 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}) => (
<div className="fancy-box">{children}</div>
)
const App = () => {
binder.bind("box", MyExtension);
return (
<ExtensionPoint name="box">
<p>Box Content</p>
</ExtensionPoint>
);
}
```
The example above renders the following html code:
```html
<div class="fancy-box">
<p>Box Content</p>
</div>
```
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<Props> = ({greeting}) => (
<>{greeting}</>
);
const App = () => {
binder.bind("greet", <GreetingExtension greeting="Ahoi" />);
return <ExtensionPoint name="greet" props={{greeting: "Moin"}} />;
};
```
### 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 (
<ExtensionPoint name="wrapped" renderAll={true} wrapper={true}>
Children
</ExtensionPoint>
);
}
```
The example above renders `Outer -> Inner -> Children`, because each extension is passed as children to the parent extension.

View File

@@ -0,0 +1,2 @@
- type: Added
description: Extension points for source tree ([#1816](https://github.com/scm-manager/scm-manager/pull/1816))

View File

@@ -21,9 +21,10 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React from "react"; import React, { FC } from "react";
import ExtensionPoint from "./ExtensionPoint"; import ExtensionPoint from "./ExtensionPoint";
import { shallow, mount } from "enzyme"; import { shallow, mount } from "enzyme";
// eslint-disable-next-line no-restricted-imports
import "@scm-manager/ui-tests/enzyme"; import "@scm-manager/ui-tests/enzyme";
import binder from "./binder"; import binder from "./binder";
@@ -89,7 +90,7 @@ describe("ExtensionPoint test", () => {
<ExtensionPoint <ExtensionPoint
name="something.special" name="something.special"
props={{ props={{
value: "Awesome" value: "Awesome",
}} }}
/> />
); );
@@ -126,7 +127,7 @@ describe("ExtensionPoint test", () => {
it("should pass the context of the parent component", () => { it("should pass the context of the parent component", () => {
const UserContext = React.createContext({ const UserContext = React.createContext({
name: "anonymous" name: "anonymous",
}); });
type HelloProps = { type HelloProps = {
@@ -148,7 +149,7 @@ describe("ExtensionPoint test", () => {
return ( return (
<UserContext.Provider <UserContext.Provider
value={{ value={{
name: "Trillian" name: "Trillian",
}} }}
> >
<ExtensionPoint name="hello" /> <ExtensionPoint name="hello" />
@@ -187,7 +188,7 @@ describe("ExtensionPoint test", () => {
}; };
mockedBinder.hasExtension.mockReturnValue(true); mockedBinder.hasExtension.mockReturnValue(true);
mockedBinder.getExtension.mockReturnValue(<Label name="One" />); mockedBinder.getExtension.mockReturnValue(Label);
const rendered = mount(<ExtensionPoint name="something.special" props={{ name: "Two" }} />); const rendered = mount(<ExtensionPoint name="something.special" props={{ name: "Two" }} />);
expect(rendered.text()).toBe("Extension Two"); expect(rendered.text()).toBe("Extension Two");
@@ -203,11 +204,79 @@ describe("ExtensionPoint test", () => {
const transformer = (props: object) => { const transformer = (props: object) => {
return { return {
...props, ...props,
name: "Two" name: "Two",
}; };
}; };
const rendered = mount(<ExtensionPoint name="something.special" propTransformer={transformer} />); const rendered = mount(<ExtensionPoint name="something.special" propTransformer={transformer} />);
expect(rendered.text()).toBe("Extension Two"); expect(rendered.text()).toBe("Extension Two");
}); });
it("should pass children as props", () => {
const label: FC = ({ children }) => {
return (
<>
<label>Bound Extension</label>
<details>{children}</details>
</>
);
};
mockedBinder.hasExtension.mockReturnValue(true);
mockedBinder.getExtension.mockReturnValue(label);
const rendered = mount(
<ExtensionPoint name="something.special">
<p>Cool stuff</p>
</ExtensionPoint>
);
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 }) => (
<>
<label>Outer {"-> "}</label>
<details>{children}</details>
</>
);
const w2: FC = ({ children }) => (
<>
<label>Inner {"-> "}</label>
<details>{children}</details>
</>
);
mockedBinder.hasExtension.mockReturnValue(true);
mockedBinder.getExtensions.mockReturnValue([w1, w2]);
const rendered = mount(
<ExtensionPoint name="something.special" renderAll={true} wrapper={true}>
<p>Children</p>
</ExtensionPoint>
);
const text = rendered.text();
expect(text).toEqual("Outer -> Inner -> Children");
});
it("should render children of non fc", () => {
const nonfc = (
<div>
<label>Non fc with children</label>
</div>
);
mockedBinder.hasExtension.mockReturnValue(true);
mockedBinder.getExtension.mockReturnValue(nonfc);
const rendered = mount(
<ExtensionPoint name="something.special">
<p>Children</p>
</ExtensionPoint>
);
const text = rendered.text();
expect(text).toEqual("Non fc with children");
});
}); });

View File

@@ -21,9 +21,8 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import * as React from "react"; import React, { FC, ReactNode } from "react";
import { Binder } from "./binder"; import { Binder } from "./binder";
import { Component, FC, ReactNode } from "react";
import useBinder from "./useBinder"; import useBinder from "./useBinder";
type PropTransformer = (props: object) => object; type PropTransformer = (props: object) => object;
@@ -33,12 +32,14 @@ type Props = {
renderAll?: boolean; renderAll?: boolean;
props?: object; props?: object;
propTransformer?: PropTransformer; propTransformer?: PropTransformer;
wrapper?: boolean;
}; };
const createInstance = (Component: any, props: object, key?: number) => { const createInstance = (Component: any, props: object, key?: number) => {
const instanceProps = { const instanceProps = {
...props, ...props,
key ...(Component.props || {}),
key,
}; };
if (React.isValidElement(Component)) { if (React.isValidElement(Component)) {
return React.cloneElement(Component, instanceProps); 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))}</>; 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 renderSingleExtension = (binder: Binder, name: string, props: object) => {
const cmp = binder.getExtension(name, props); const cmp = binder.getExtension(name, props);
if (!cmp) { if (!cmp) {
return null; return null;
} }
return createInstance(cmp, props, undefined); return createInstance(cmp, props);
}; };
const renderDefault = (children: ReactNode) => { const renderDefault = (children: ReactNode) => {
@@ -67,11 +84,11 @@ const renderDefault = (children: ReactNode) => {
}; };
const createRenderProps = (propTransformer?: PropTransformer, props?: object) => { const createRenderProps = (propTransformer?: PropTransformer, props?: object) => {
const transform = (props: object) => { const transform = (untransformedProps: object) => {
if (!propTransformer) { if (!propTransformer) {
return props; return untransformedProps;
} }
return propTransformer(props); return propTransformer(untransformedProps);
}; };
return transform(props || {}); return transform(props || {});
@@ -80,12 +97,15 @@ const createRenderProps = (propTransformer?: PropTransformer, props?: object) =>
/** /**
* ExtensionPoint renders components which are bound to an extension point. * ExtensionPoint renders components which are bound to an extension point.
*/ */
const ExtensionPoint: FC<Props> = ({ name, propTransformer, props, renderAll, children }) => { const ExtensionPoint: FC<Props> = ({ name, propTransformer, props, renderAll, wrapper, children }) => {
const binder = useBinder(); const binder = useBinder();
const renderProps = createRenderProps(propTransformer, props); const renderProps = createRenderProps(propTransformer, { ...(props || {}), children });
if (!binder.hasExtension(name, renderProps)) { if (!binder.hasExtension(name, renderProps)) {
return renderDefault(children); return renderDefault(children);
} else if (renderAll) { } else if (renderAll) {
if (wrapper) {
return renderWrapperExtensions(binder, name, renderProps);
}
return renderAllExtensions(binder, name, renderProps); return renderAllExtensions(binder, name, renderProps);
} }
return renderSingleExtension(binder, name, renderProps); return renderSingleExtension(binder, name, renderProps);

View File

@@ -24,6 +24,7 @@
import React from "react"; import React from "react";
import { import {
File,
Branch, Branch,
IndexResources, IndexResources,
Links, Links,
@@ -83,6 +84,31 @@ export type ReposSourcesEmptyActionbar = ExtensionPointDefinition<
ReposSourcesEmptyActionbarExtension ReposSourcesEmptyActionbarExtension
>; >;
export type ReposSourcesTreeWrapperProps = {
repository: Repository;
directory: File;
baseUrl: string;
revision: string;
};
export type ReposSourcesTreeWrapperExtension = ExtensionPointDefinition<
"repos.source.tree.wrapper",
React.ComponentType<ReposSourcesTreeWrapperProps>
>;
export type ReposSourcesTreeRowProps = {
file: File;
};
export type ReposSourcesTreeRowRightExtension = ExtensionPointDefinition<
"repos.sources.tree.row.right",
React.ComponentType<ReposSourcesTreeRowProps>
>;
export type ReposSourcesTreeRowAfterExtension = ExtensionPointDefinition<
"repos.sources.tree.row.after",
React.ComponentType<ReposSourcesTreeRowProps>
>;
export type PrimaryNavigationLoginButtonProps = { export type PrimaryNavigationLoginButtonProps = {
links: Links; links: Links;
label: string; label: string;

View File

@@ -25,14 +25,15 @@
import React, { FC } from "react"; import React, { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styled from "styled-components"; import styled from "styled-components";
import { binder } from "@scm-manager/ui-extensions"; import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
import { File } from "@scm-manager/ui-types"; import { File, Repository } from "@scm-manager/ui-types";
import { Notification } from "@scm-manager/ui-components";
import FileTreeLeaf from "./FileTreeLeaf"; import FileTreeLeaf from "./FileTreeLeaf";
import TruncatedNotification from "./TruncatedNotification"; import TruncatedNotification from "./TruncatedNotification";
import {isRootPath} from "../utils/files"; import { isRootPath } from "../utils/files";
import { extensionPoints } from "@scm-manager/ui-extensions";
type Props = { type Props = {
repository: Repository;
directory: File; directory: File;
baseUrl: string; baseUrl: string;
revision: string; revision: string;
@@ -56,7 +57,7 @@ export function findParent(path: string) {
return ""; return "";
} }
const FileTree: FC<Props> = ({ directory, baseUrl, revision, fetchNextPage, isFetchingNextPage }) => { const FileTree: FC<Props> = ({ repository, directory, baseUrl, revision, fetchNextPage, isFetchingNextPage }) => {
const [t] = useTranslation("repos"); const [t] = useTranslation("repos");
const { path } = directory; const { path } = directory;
const files: File[] = []; const files: File[] = [];
@@ -69,8 +70,8 @@ const FileTree: FC<Props> = ({ directory, baseUrl, revision, fetchNextPage, isFe
revision, revision,
_links: {}, _links: {},
_embedded: { _embedded: {
children: [] children: [],
} },
}); });
} }
@@ -78,8 +79,16 @@ const FileTree: FC<Props> = ({ directory, baseUrl, revision, fetchNextPage, isFe
const baseUrlWithRevision = baseUrl + "/" + encodeURIComponent(revision); const baseUrlWithRevision = baseUrl + "/" + encodeURIComponent(revision);
const extProps: extensionPoints.ReposSourcesTreeWrapperProps = {
repository,
directory,
baseUrl,
revision,
};
return ( return (
<div className="panel-block"> <div className="panel-block">
<ExtensionPoint name="repos.source.tree.wrapper" props={extProps} renderAll={true} wrapper={true}>
<table className="table table-hover table-sm is-fullwidth"> <table className="table table-hover table-sm is-fullwidth">
<thead> <thead>
<tr> <tr>
@@ -102,6 +111,7 @@ const FileTree: FC<Props> = ({ directory, baseUrl, revision, fetchNextPage, isFe
fetchNextPage={fetchNextPage} fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage} isFetchingNextPage={isFetchingNextPage}
/> />
</ExtensionPoint>
</div> </div>
); );
}; };

View File

@@ -25,7 +25,7 @@ import * as React from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import styled from "styled-components"; 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 { File } from "@scm-manager/ui-types";
import { DateFromNow, FileSize, Tooltip, Icon } from "@scm-manager/ui-components"; import { DateFromNow, FileSize, Tooltip, Icon } from "@scm-manager/ui-components";
import FileIcon from "./FileIcon"; import FileIcon from "./FileIcon";
@@ -44,6 +44,14 @@ const NoWrapTd = styled.td`
white-space: nowrap; white-space: nowrap;
`; `;
const ExtensionTd = styled.td`
white-space: nowrap;
> *:not(:last-child) {
margin-right: 0.5rem;
}
`;
class FileTreeLeaf extends React.Component<Props> { class FileTreeLeaf extends React.Component<Props> {
createFileIcon = (file: File) => { createFileIcon = (file: File) => {
return ( return (
@@ -88,7 +96,12 @@ class FileTreeLeaf extends React.Component<Props> {
const renderFileSize = (file: File) => <FileSize bytes={file?.length ? file.length : 0} />; const renderFileSize = (file: File) => <FileSize bytes={file?.length ? file.length : 0} />;
const renderCommitDate = (file: File) => <DateFromNow date={file.commitDate} />; const renderCommitDate = (file: File) => <DateFromNow date={file.commitDate} />;
const extProps: extensionPoints.ReposSourcesTreeRowProps = {
file,
};
return ( return (
<>
<tr> <tr>
<td>{this.createFileIcon(file)}</td> <td>{this.createFileIcon(file)}</td>
<MinWidthTd className="is-word-break">{this.createFileName(file)}</MinWidthTd> <MinWidthTd className="is-word-break">{this.createFileName(file)}</MinWidthTd>
@@ -97,22 +110,18 @@ class FileTreeLeaf extends React.Component<Props> {
</NoWrapTd> </NoWrapTd>
<td className="is-hidden-mobile">{this.contentIfPresent(file, "commitDate", renderCommitDate)}</td> <td className="is-hidden-mobile">{this.contentIfPresent(file, "commitDate", renderCommitDate)}</td>
<MinWidthTd className={classNames("is-word-break", "is-hidden-touch")}> <MinWidthTd className={classNames("is-word-break", "is-hidden-touch")}>
{this.contentIfPresent(file, "description", file => file.description)} {this.contentIfPresent(file, "description", (file) => file.description)}
</MinWidthTd> </MinWidthTd>
{binder.hasExtension("repos.sources.tree.row.right") && ( {binder.hasExtension("repos.sources.tree.row.right") && (
<td className="is-hidden-mobile"> <ExtensionTd className="is-hidden-mobile">
{!file.directory && ( {!file.directory && (
<ExtensionPoint <ExtensionPoint name="repos.sources.tree.row.right" props={extProps} renderAll={true} />
name="repos.sources.tree.row.right"
props={{
file
}}
renderAll={true}
/>
)} )}
</td> </ExtensionTd>
)} )}
</tr> </tr>
<ExtensionPoint name="repos.sources.tree.row.after" props={extProps} renderAll={true} />
</>
); );
} }
} }

View File

@@ -149,6 +149,7 @@ const Sources: FC<Props> = ({ repository, branches, selectedBranch, baseUrl }) =
} else { } else {
body = ( body = (
<FileTree <FileTree
repository={repository}
directory={file} directory={file}
revision={revision || file.revision} revision={revision || file.revision}
baseUrl={baseUrl + "/sources"} baseUrl={baseUrl + "/sources"}