mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-06-25 03:29:34 +02:00
New extension points for repository overview (#1828)
The landing-page-plugin is being reworked and integrated into the repository overview. This requires new extension points and slightly adjusted components to better match the repository overview page visually. Also, binder options can now be passed as an object which offer a new priority option that causes sorting in descending order.
This commit is contained in:
committed by
GitHub
parent
35f4cb3e61
commit
57aacba03a
@@ -213,3 +213,28 @@ const App = () => {
|
||||
```
|
||||
|
||||
The example above renders `Outer -> Inner -> Children`, because each extension is passed as children to the parent extension.
|
||||
|
||||
### Sorting
|
||||
|
||||
Extensions are automatically sorted on retrieval based on either their `extensionName` (ASC) and/or their `priority` (DESC),
|
||||
which can be passed upon binding an extension.
|
||||
|
||||
Example:
|
||||
|
||||
```tsx
|
||||
binder.bind("extension.point.example", <div>Hello World the fourth</div>, { priority: 10, extensionName: "ignore" });
|
||||
binder.bind("extension.point.example", <div>Hello World the third</div>, { priority: 50 });
|
||||
binder.bind("extension.point.example", <div>Hello World the first</div>, { priority: 100, extensionName: "me" });
|
||||
binder.bind("extension.point.example", <div>Hello World the second</div>, { priority: 75 });
|
||||
|
||||
const extensions = binder.getExtensions("extension.point.example");
|
||||
|
||||
/**
|
||||
* Output =>
|
||||
*
|
||||
* Hello World the first
|
||||
* Hello World the second
|
||||
* Hello World the third
|
||||
* Hello World the fourth
|
||||
*/
|
||||
```
|
||||
|
||||
4
gradle/changelog/repooverviewextensionpoints.yaml
Normal file
4
gradle/changelog/repooverviewextensionpoints.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
- type: Added
|
||||
description: Extension points for repository overview ([#1828](https://github.com/scm-manager/scm-manager/pull/1828))
|
||||
- type: Added
|
||||
description: Binder option to sort by priority ([#1828](https://github.com/scm-manager/scm-manager/pull/1828))
|
||||
@@ -27,6 +27,7 @@ import { storiesOf } from "@storybook/react";
|
||||
import CardColumnSmall from "./CardColumnSmall";
|
||||
import Icon from "./Icon";
|
||||
import styled from "styled-components";
|
||||
import DateFromNow from "./DateFromNow";
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin: 2rem;
|
||||
@@ -39,9 +40,22 @@ const contentLeft = <strong className="m-0">main content</strong>;
|
||||
const contentRight = <small>more text</small>;
|
||||
|
||||
storiesOf("CardColumnSmall", module)
|
||||
.addDecorator(story => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
|
||||
.addDecorator(storyFn => <Wrapper>{storyFn()}</Wrapper>)
|
||||
.addDecorator((story) => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
|
||||
.addDecorator((storyFn) => <Wrapper>{storyFn()}</Wrapper>)
|
||||
.add("Default", () => (
|
||||
<CardColumnSmall link={link} avatar={icon} contentLeft={contentLeft} contentRight={contentRight} />
|
||||
))
|
||||
.add("Minimal", () => <CardColumnSmall link={link} contentLeft={contentLeft} contentRight={contentRight} />);
|
||||
.add("Minimal", () => <CardColumnSmall link={link} contentLeft={contentLeft} contentRight={contentRight} />)
|
||||
.add("Task", () => (
|
||||
<CardColumnSmall
|
||||
link={link}
|
||||
avatar={<Icon name="exchange-alt" className="fa-fw fa-lg" color="inherit" />}
|
||||
contentLeft={<strong>Repository created</strong>}
|
||||
contentRight={
|
||||
<small>
|
||||
<DateFromNow date={new Date(1634287231)} />
|
||||
</small>
|
||||
}
|
||||
footer="New: scmadmin/spaceship"
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -37,18 +37,15 @@ type Props = {
|
||||
|
||||
const StyledLink = styled(Link)`
|
||||
color: inherit;
|
||||
:hover {
|
||||
color: #33b2e8 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const CardColumnSmall: FC<Props> = ({ link, avatar, contentLeft, contentRight, footer }) => {
|
||||
const renderAvatar = avatar ? <figure className={classNames("media-left", "mr-2")}>{avatar}</figure> : null;
|
||||
const renderAvatar = avatar ? <figure className="media-left mr-2 mt-1">{avatar}</figure> : null;
|
||||
const renderFooter = footer ? <small>{footer}</small> : null;
|
||||
|
||||
return (
|
||||
<StyledLink to={link}>
|
||||
<div className="media">
|
||||
<div className="p-2 media has-hover-background-blue">
|
||||
{renderAvatar}
|
||||
<div
|
||||
className={classNames(
|
||||
@@ -60,9 +57,9 @@ const CardColumnSmall: FC<Props> = ({ link, avatar, contentLeft, contentRight, f
|
||||
"is-align-self-stretch"
|
||||
)}
|
||||
>
|
||||
<div className={classNames("is-flex", "is-align-items-center")}>
|
||||
<div className={classNames("is-clipped", "mb-0")}>{contentLeft}</div>
|
||||
<div className={classNames("is-align-items-start", "ml-auto")}>{contentRight}</div>
|
||||
<div className="is-flex is-flex-direction-column is-flex-align-items-start">
|
||||
<div className="is-clipped">{contentLeft}</div>
|
||||
<div>{contentRight}</div>
|
||||
</div>
|
||||
{renderFooter}
|
||||
</div>
|
||||
|
||||
@@ -2640,15 +2640,15 @@ exports[`Storyshots CardColumnSmall Default 1`] = `
|
||||
className="CardColumnSmallstories__Wrapper-ofr817-0 hUFZIW"
|
||||
>
|
||||
<a
|
||||
className="CardColumnSmall__StyledLink-tk9h0o-0 eXYQYs"
|
||||
className="CardColumnSmall__StyledLink-tk9h0o-0 gNZuIT"
|
||||
href="/foo/bar"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<div
|
||||
className="media"
|
||||
className="p-2 media has-hover-background-blue"
|
||||
>
|
||||
<figure
|
||||
className="media-left mr-2"
|
||||
className="media-left mr-2 mt-1"
|
||||
>
|
||||
<i
|
||||
className="fas fa-icons fa-2x fa-fw has-text-grey-light"
|
||||
@@ -2658,10 +2658,10 @@ exports[`Storyshots CardColumnSmall Default 1`] = `
|
||||
className="media-content text-box is-flex is-flex-direction-column is-justify-content-space-around is-align-self-stretch"
|
||||
>
|
||||
<div
|
||||
className="is-flex is-align-items-center"
|
||||
className="is-flex is-flex-direction-column is-flex-align-items-start"
|
||||
>
|
||||
<div
|
||||
className="is-clipped mb-0"
|
||||
className="is-clipped"
|
||||
>
|
||||
<strong
|
||||
className="m-0"
|
||||
@@ -2669,9 +2669,7 @@ exports[`Storyshots CardColumnSmall Default 1`] = `
|
||||
main content
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
className="is-align-items-start ml-auto"
|
||||
>
|
||||
<div>
|
||||
<small>
|
||||
more text
|
||||
</small>
|
||||
@@ -2688,21 +2686,21 @@ exports[`Storyshots CardColumnSmall Minimal 1`] = `
|
||||
className="CardColumnSmallstories__Wrapper-ofr817-0 hUFZIW"
|
||||
>
|
||||
<a
|
||||
className="CardColumnSmall__StyledLink-tk9h0o-0 eXYQYs"
|
||||
className="CardColumnSmall__StyledLink-tk9h0o-0 gNZuIT"
|
||||
href="/foo/bar"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<div
|
||||
className="media"
|
||||
className="p-2 media has-hover-background-blue"
|
||||
>
|
||||
<div
|
||||
className="media-content text-box is-flex is-flex-direction-column is-justify-content-space-around is-align-self-stretch"
|
||||
>
|
||||
<div
|
||||
className="is-flex is-align-items-center"
|
||||
className="is-flex is-flex-direction-column is-flex-align-items-start"
|
||||
>
|
||||
<div
|
||||
className="is-clipped mb-0"
|
||||
className="is-clipped"
|
||||
>
|
||||
<strong
|
||||
className="m-0"
|
||||
@@ -2710,9 +2708,7 @@ exports[`Storyshots CardColumnSmall Minimal 1`] = `
|
||||
main content
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
className="is-align-items-start ml-auto"
|
||||
>
|
||||
<div>
|
||||
<small>
|
||||
more text
|
||||
</small>
|
||||
@@ -2724,6 +2720,58 @@ exports[`Storyshots CardColumnSmall Minimal 1`] = `
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Storyshots CardColumnSmall Task 1`] = `
|
||||
<div
|
||||
className="CardColumnSmallstories__Wrapper-ofr817-0 hUFZIW"
|
||||
>
|
||||
<a
|
||||
className="CardColumnSmall__StyledLink-tk9h0o-0 gNZuIT"
|
||||
href="/foo/bar"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<div
|
||||
className="p-2 media has-hover-background-blue"
|
||||
>
|
||||
<figure
|
||||
className="media-left mr-2 mt-1"
|
||||
>
|
||||
<i
|
||||
className="fas fa-exchange-alt has-text-inherit fa-fw fa-lg"
|
||||
/>
|
||||
</figure>
|
||||
<div
|
||||
className="media-content text-box is-flex is-flex-direction-column is-justify-content-space-around is-align-self-stretch"
|
||||
>
|
||||
<div
|
||||
className="is-flex is-flex-direction-column is-flex-align-items-start"
|
||||
>
|
||||
<div
|
||||
className="is-clipped"
|
||||
>
|
||||
<strong>
|
||||
Repository created
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>
|
||||
<time
|
||||
className="DateElement-sc-1schp8c-0 cinWxa"
|
||||
title="1970-01-19 22:58:07"
|
||||
>
|
||||
over 51 years ago
|
||||
</time>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<small>
|
||||
New: scmadmin/spaceship
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Storyshots Changesets Co-Authors with avatar 1`] = `
|
||||
<div
|
||||
className="Changesetsstories__Wrapper-sc-122npan-0 iywkPp box box-link-shadow"
|
||||
@@ -57275,7 +57323,7 @@ exports[`Storyshots GroupEntry Default 1`] = `
|
||||
className="is-relative"
|
||||
>
|
||||
<a
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 RhDcq"
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 dVnuiR has-hover-background-blue"
|
||||
href="/foo/bar"
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -57352,7 +57400,7 @@ exports[`Storyshots GroupEntry With long texts 1`] = `
|
||||
className="is-relative"
|
||||
>
|
||||
<a
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 RhDcq"
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 dVnuiR has-hover-background-blue"
|
||||
href="/foo/bar"
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -72779,7 +72827,7 @@ exports[`Storyshots RepositoryEntry Archived 1`] = `
|
||||
className="is-relative"
|
||||
>
|
||||
<a
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 RhDcq"
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 dVnuiR has-hover-background-blue"
|
||||
href="/repo/hitchhiker/heartOfGold/"
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -72883,7 +72931,7 @@ exports[`Storyshots RepositoryEntry Avatar EP 1`] = `
|
||||
className="is-relative"
|
||||
>
|
||||
<a
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 RhDcq"
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 dVnuiR has-hover-background-blue"
|
||||
href="/repo/hitchhiker/heartOfGold/"
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -72976,7 +73024,7 @@ exports[`Storyshots RepositoryEntry Before Title EP 1`] = `
|
||||
className="is-relative"
|
||||
>
|
||||
<a
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 RhDcq"
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 dVnuiR has-hover-background-blue"
|
||||
href="/repo/hitchhiker/heartOfGold/"
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -73072,7 +73120,7 @@ exports[`Storyshots RepositoryEntry Default 1`] = `
|
||||
className="is-relative"
|
||||
>
|
||||
<a
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 RhDcq"
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 dVnuiR has-hover-background-blue"
|
||||
href="/repo/hitchhiker/heartOfGold/"
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -73165,7 +73213,7 @@ exports[`Storyshots RepositoryEntry Exporting 1`] = `
|
||||
className="is-relative"
|
||||
>
|
||||
<a
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 RhDcq"
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 dVnuiR has-hover-background-blue"
|
||||
href="/repo/hitchhiker/heartOfGold/"
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -73269,7 +73317,7 @@ exports[`Storyshots RepositoryEntry HealthCheck Failure 1`] = `
|
||||
className="is-relative"
|
||||
>
|
||||
<a
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 RhDcq"
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 dVnuiR has-hover-background-blue"
|
||||
href="/repo/hitchhiker/heartOfGold/"
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -73374,7 +73422,7 @@ exports[`Storyshots RepositoryEntry MultiRepositoryTags 1`] = `
|
||||
className="is-relative"
|
||||
>
|
||||
<a
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 RhDcq"
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 dVnuiR has-hover-background-blue"
|
||||
href="/repo/hitchhiker/heartOfGold/"
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -73488,7 +73536,7 @@ exports[`Storyshots RepositoryEntry RepositoryFlag EP 1`] = `
|
||||
className="is-relative"
|
||||
>
|
||||
<a
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 RhDcq"
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 dVnuiR has-hover-background-blue"
|
||||
href="/repo/hitchhiker/heartOfGold/"
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -73593,7 +73641,7 @@ exports[`Storyshots RepositoryEntry With long texts 1`] = `
|
||||
className="is-relative"
|
||||
>
|
||||
<a
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 RhDcq"
|
||||
className="GroupEntry__OverlayLink-sc-1f902yu-1 dVnuiR has-hover-background-blue"
|
||||
href="/repo/hitchhiker/veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeery-loooooooooooooooooooooooooooooooooooooooooooooooooooong-repooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo-naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaame/"
|
||||
onClick={[Function]}
|
||||
/>
|
||||
|
||||
@@ -39,7 +39,6 @@ const OverlayLink = styled(Link)`
|
||||
pointer-events: all;
|
||||
border-radius: 4px;
|
||||
:hover {
|
||||
background-color: rgb(51, 178, 232, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
@@ -82,7 +81,7 @@ type Props = {
|
||||
const GroupEntry: FC<Props> = ({ link, avatar, title, name, description, contentRight }) => {
|
||||
return (
|
||||
<div className="is-relative">
|
||||
<OverlayLink to={link} />
|
||||
<OverlayLink to={link} className="has-hover-background-blue" />
|
||||
<StyledGroupEntry
|
||||
className={classNames("is-flex", "is-justify-content-space-between", "is-align-items-center", "p-2")}
|
||||
title={title}
|
||||
|
||||
@@ -34,3 +34,4 @@ export { default as Title } from "./Title";
|
||||
export { default as CustomQueryFlexWrappedColumns } from "./CustomQueryFlexWrappedColumns";
|
||||
export { default as PrimaryContentColumn } from "./PrimaryContentColumn";
|
||||
export { default as SecondaryNavigationColumn } from "./SecondaryNavigationColumn";
|
||||
export { default as GroupEntries } from "./GroupEntries";
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("binder tests", () => {
|
||||
binder.bind("hitchhiker.trillian", "earth2", (props: Props) => props.category === "a");
|
||||
|
||||
const extensions = binder.getExtensions("hitchhiker.trillian", {
|
||||
category: "b"
|
||||
category: "b",
|
||||
});
|
||||
expect(extensions).toEqual(["earth"]);
|
||||
});
|
||||
@@ -109,7 +109,7 @@ describe("binder tests", () => {
|
||||
expect(binderExtensionA).not.toBeNull();
|
||||
binder.bind<TestExtensionPointB>("test.extension.b", 2);
|
||||
const binderExtensionsB = binder.getExtensions<TestExtensionPointB>("test.extension.b", {
|
||||
testProp: [true, false]
|
||||
testProp: [true, false],
|
||||
});
|
||||
expect(binderExtensionsB).toHaveLength(1);
|
||||
binder.bind("test.extension.c", 2, () => false);
|
||||
@@ -123,24 +123,78 @@ describe("binder tests", () => {
|
||||
value: string;
|
||||
};
|
||||
|
||||
type MarkdownCodeLanguageRendererExtensionPoint<
|
||||
S extends string | undefined = undefined
|
||||
> = SimpleDynamicExtensionPointDefinition<
|
||||
"markdown-renderer.code.",
|
||||
(props: any) => any,
|
||||
MarkdownCodeLanguageRendererProps,
|
||||
S
|
||||
>;
|
||||
type MarkdownCodeLanguageRendererExtensionPoint<S extends string | undefined = undefined> =
|
||||
SimpleDynamicExtensionPointDefinition<
|
||||
"markdown-renderer.code.",
|
||||
(props: any) => any,
|
||||
MarkdownCodeLanguageRendererProps,
|
||||
S
|
||||
>;
|
||||
type UmlExtensionPoint = MarkdownCodeLanguageRendererExtensionPoint<"uml">;
|
||||
|
||||
binder.bind<UmlExtensionPoint>("markdown-renderer.code.uml", props => props.value);
|
||||
binder.bind<UmlExtensionPoint>("markdown-renderer.code.uml", (props) => props.value);
|
||||
|
||||
const language = "uml";
|
||||
const extensionPointName = `markdown-renderer.code.${language}` as const;
|
||||
const dynamicExtension = binder.getExtension<MarkdownCodeLanguageRendererExtensionPoint>(extensionPointName, {
|
||||
language: "uml",
|
||||
value: "const a = 2;"
|
||||
value: "const a = 2;",
|
||||
});
|
||||
expect(dynamicExtension).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should allow options parameter", () => {
|
||||
binder.bind("hitchhiker.trillian", "planetA", {
|
||||
predicate: () => true,
|
||||
extensionName: "zeroWaste",
|
||||
});
|
||||
|
||||
const extensions = binder.getExtensions("hitchhiker.trillian");
|
||||
expect(extensions).toEqual(["planetA"]);
|
||||
});
|
||||
|
||||
it("should allow empty options parameter", () => {
|
||||
binder.bind("hitchhiker.trillian", "planetA", {});
|
||||
|
||||
const extensions = binder.getExtensions("hitchhiker.trillian");
|
||||
expect(extensions).toEqual(["planetA"]);
|
||||
});
|
||||
|
||||
it("should allow options parameter with only predicate", () => {
|
||||
binder.bind("hitchhiker.trillian", "planetA", {
|
||||
predicate: () => true,
|
||||
});
|
||||
|
||||
const extensions = binder.getExtensions("hitchhiker.trillian");
|
||||
expect(extensions).toEqual(["planetA"]);
|
||||
});
|
||||
|
||||
it("should allow options parameter with only extensionName", () => {
|
||||
binder.bind("hitchhiker.trillian", "planetA", {
|
||||
extensionName: "zeroWaste",
|
||||
});
|
||||
|
||||
const extensions = binder.getExtensions("hitchhiker.trillian");
|
||||
expect(extensions).toEqual(["planetA"]);
|
||||
});
|
||||
|
||||
it("should order by priority in descending order", () => {
|
||||
binder.bind("hitchhiker.trillian", "planetA", { priority: 10 });
|
||||
binder.bind("hitchhiker.trillian", "planetB", { priority: 50 });
|
||||
binder.bind("hitchhiker.trillian", "planetC", { priority: 100 });
|
||||
binder.bind("hitchhiker.trillian", "planetD", { priority: 75 });
|
||||
|
||||
const extensions = binder.getExtensions("hitchhiker.trillian");
|
||||
expect(extensions).toEqual(["planetC", "planetD", "planetB", "planetA"]);
|
||||
});
|
||||
|
||||
it("should order by priority over ordering by name", () => {
|
||||
binder.bind("hitchhiker.trillian", "planetA", { priority: 10, extensionName: "ignore" });
|
||||
binder.bind("hitchhiker.trillian", "planetB", { priority: 50 });
|
||||
binder.bind("hitchhiker.trillian", "planetC", { priority: 100, extensionName: "me" });
|
||||
binder.bind("hitchhiker.trillian", "planetD", { priority: 75 });
|
||||
|
||||
const extensions = binder.getExtensions("hitchhiker.trillian");
|
||||
expect(extensions).toEqual(["planetC", "planetD", "planetB", "planetA"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ type ExtensionRegistration<P, T> = {
|
||||
predicate: Predicate<P>;
|
||||
extension: T;
|
||||
extensionName: string;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
export type ExtensionPointDefinition<N extends string, T, P = undefined> = {
|
||||
@@ -36,7 +37,26 @@ export type ExtensionPointDefinition<N extends string, T, P = undefined> = {
|
||||
props: P;
|
||||
};
|
||||
|
||||
export type SimpleDynamicExtensionPointDefinition<P extends string, T, Props, S extends string | undefined> = ExtensionPointDefinition<S extends string ? `${P}${S}` : `${P}${string}`, T, Props>;
|
||||
export type SimpleDynamicExtensionPointDefinition<P extends string, T, Props, S extends string | undefined> =
|
||||
ExtensionPointDefinition<S extends string ? `${P}${S}` : `${P}${string}`, T, Props>;
|
||||
|
||||
export type BindOptions<Props> = {
|
||||
predicate?: Predicate<Props>;
|
||||
|
||||
/**
|
||||
* Extensions are ordered by name (ASC).
|
||||
*/
|
||||
extensionName?: string;
|
||||
|
||||
/**
|
||||
* Extensions are ordered by priority (DESC).
|
||||
*/
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
function isBindOptions<Props>(input?: Predicate<Props> | BindOptions<Props>): input is BindOptions<Props> {
|
||||
return typeof input !== "function" && typeof input === "object";
|
||||
}
|
||||
|
||||
/**
|
||||
* Binder is responsible for binding plugin extensions to their corresponding extension points.
|
||||
@@ -60,10 +80,7 @@ export class Binder {
|
||||
* @param extension provided extension
|
||||
* @param predicate to decide if the extension gets rendered for the given props
|
||||
*/
|
||||
bind<E extends ExtensionPointDefinition<string, unknown, undefined>>(
|
||||
extensionPoint: E["name"],
|
||||
extension: E["type"]
|
||||
): void;
|
||||
bind<E extends ExtensionPointDefinition<string, unknown>>(extensionPoint: E["name"], extension: E["type"]): void;
|
||||
bind<E extends ExtensionPointDefinition<string, unknown, any>>(
|
||||
extensionPoint: E["name"],
|
||||
extension: E["type"],
|
||||
@@ -73,17 +90,38 @@ export class Binder {
|
||||
bind<E extends ExtensionPointDefinition<string, unknown, any>>(
|
||||
extensionPoint: E["name"],
|
||||
extension: E["type"],
|
||||
predicate?: Predicate<E["props"]>,
|
||||
options?: BindOptions<E["props"]>
|
||||
): void;
|
||||
bind<E extends ExtensionPointDefinition<string, unknown, any>>(
|
||||
extensionPoint: E["name"],
|
||||
extension: E["type"],
|
||||
predicateOrOptions?: Predicate<E["props"]> | BindOptions<E["props"]>,
|
||||
extensionName?: string
|
||||
) {
|
||||
let predicate: Predicate<E["props"]> = () => true;
|
||||
let priority = 0;
|
||||
if (isBindOptions(predicateOrOptions)) {
|
||||
if (predicateOrOptions.predicate) {
|
||||
predicate = predicateOrOptions.predicate;
|
||||
}
|
||||
if (predicateOrOptions.extensionName) {
|
||||
extensionName = predicateOrOptions.extensionName;
|
||||
}
|
||||
if (typeof predicateOrOptions.priority === "number") {
|
||||
priority = predicateOrOptions.priority;
|
||||
}
|
||||
} else if (predicateOrOptions) {
|
||||
predicate = predicateOrOptions;
|
||||
}
|
||||
if (!this.extensionPoints[extensionPoint]) {
|
||||
this.extensionPoints[extensionPoint] = [];
|
||||
}
|
||||
const registration = {
|
||||
predicate: predicate ? predicate : () => true,
|
||||
predicate,
|
||||
extension,
|
||||
extensionName: extensionName ? extensionName : ""
|
||||
};
|
||||
extensionName: extensionName ? extensionName : "",
|
||||
priority,
|
||||
} as ExtensionRegistration<E["props"], E["type"]>;
|
||||
this.extensionPoints[extensionPoint].push(registration);
|
||||
}
|
||||
|
||||
@@ -128,10 +166,10 @@ export class Binder {
|
||||
): Array<E["type"]> {
|
||||
let registrations = this.extensionPoints[extensionPoint] || [];
|
||||
if (props) {
|
||||
registrations = registrations.filter(reg => reg.predicate(props));
|
||||
registrations = registrations.filter((reg) => reg.predicate(props));
|
||||
}
|
||||
registrations.sort(this.sortExtensions);
|
||||
return registrations.map(reg => reg.extension);
|
||||
return registrations.map((reg) => reg.extension);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,7 +189,11 @@ export class Binder {
|
||||
const regA = a.extensionName ? a.extensionName.toUpperCase() : "";
|
||||
const regB = b.extensionName ? b.extensionName.toUpperCase() : "";
|
||||
|
||||
if (regA === "" && regB !== "") {
|
||||
if (a.priority > b.priority) {
|
||||
return -1;
|
||||
} else if (a.priority < b.priority) {
|
||||
return 1;
|
||||
} else if (regA === "" && regB !== "") {
|
||||
return 1;
|
||||
} else if (regA !== "" && regB === "") {
|
||||
return -1;
|
||||
|
||||
@@ -135,3 +135,24 @@ export type PrimaryNavigationLogoutButtonExtension = ExtensionPointDefinition<
|
||||
"primary-navigation.logout",
|
||||
PrimaryNavigationLogoutButtonProps
|
||||
>;
|
||||
|
||||
export type RepositoryOverviewTopExtensionProps = {
|
||||
page: number;
|
||||
search: string;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
export type RepositoryOverviewTopExtension = ExtensionPointDefinition<
|
||||
"repository.overview.top",
|
||||
React.ComponentType<RepositoryOverviewTopExtensionProps>,
|
||||
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
|
||||
>;
|
||||
|
||||
@@ -186,6 +186,10 @@ $light-25: darken($high-contrast-light-gray, 45%);
|
||||
}
|
||||
*/
|
||||
|
||||
.has-hover-background-blue:hover {
|
||||
background-color: scale-color($blue, $alpha: -90%);
|
||||
}
|
||||
|
||||
// readability issues with original color
|
||||
.has-text-warning {
|
||||
color: #ffb600 !important;
|
||||
|
||||
@@ -23,10 +23,9 @@
|
||||
*/
|
||||
import React, { FC } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Icon, RepositoryEntry } from "@scm-manager/ui-components";
|
||||
import { Icon, RepositoryEntry, GroupEntries } from "@scm-manager/ui-components";
|
||||
import { RepositoryGroup } from "@scm-manager/ui-types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import GroupEntries from "./GroupEntries";
|
||||
|
||||
type Props = {
|
||||
group: RepositoryGroup;
|
||||
|
||||
@@ -27,20 +27,33 @@ import { NamespaceCollection, Repository } from "@scm-manager/ui-types";
|
||||
|
||||
import groupByNamespace from "./groupByNamespace";
|
||||
import RepositoryGroupEntry from "./RepositoryGroupEntry";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
|
||||
type Props = {
|
||||
repositories: Repository[];
|
||||
namespaces: NamespaceCollection;
|
||||
page: number;
|
||||
search: string;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
class RepositoryList extends React.Component<Props> {
|
||||
render() {
|
||||
const { repositories, namespaces } = this.props;
|
||||
const { repositories, namespaces, namespace, page, search } = this.props;
|
||||
|
||||
const groups = groupByNamespace(repositories, namespaces);
|
||||
return (
|
||||
<div className="content">
|
||||
{groups.map(group => {
|
||||
<ExtensionPoint
|
||||
name="repository.overview.top"
|
||||
renderAll={true}
|
||||
props={{
|
||||
page,
|
||||
search,
|
||||
namespace,
|
||||
}}
|
||||
/>
|
||||
{groups.map((group) => {
|
||||
return <RepositoryGroupEntry group={group} key={group.name} />;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -26,16 +26,32 @@ import { useHistory, useLocation, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
CreateButton,
|
||||
devices,
|
||||
LinkPaginator,
|
||||
Notification,
|
||||
OverviewPageActions,
|
||||
Page,
|
||||
PageActions,
|
||||
urls
|
||||
urls,
|
||||
} from "@scm-manager/ui-components";
|
||||
import RepositoryList from "../components/list";
|
||||
import { useNamespaces, useRepositories } from "@scm-manager/ui-api";
|
||||
import { NamespaceCollection, RepositoryCollection } from "@scm-manager/ui-types";
|
||||
import { ExtensionPoint, extensionPoints, useBinder } from "@scm-manager/ui-extensions";
|
||||
import styled from "styled-components";
|
||||
|
||||
const StickyColumn = styled.div`
|
||||
align-self: flex-start;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: ${devices.mobile.width}px) {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const useUrlParams = () => {
|
||||
const params = useParams();
|
||||
@@ -49,7 +65,7 @@ const useOverviewData = () => {
|
||||
const search = urls.getQueryStringFromLocation(location);
|
||||
|
||||
const request = {
|
||||
namespace: namespaces?._embedded.namespaces.find(n => n.namespace === namespace),
|
||||
namespace: namespaces?._embedded.namespaces.find((n) => n.namespace === namespace),
|
||||
// ui starts counting by 1,
|
||||
// but backend starts counting by 0
|
||||
page: page - 1,
|
||||
@@ -59,7 +75,7 @@ const useOverviewData = () => {
|
||||
// also do not fetch repositories if an invalid namespace is selected
|
||||
disabled:
|
||||
(!!namespace && !namespaces) ||
|
||||
(!!namespace && !namespaces?._embedded.namespaces.some(n => n.namespace === namespace))
|
||||
(!!namespace && !namespaces?._embedded.namespaces.some((n) => n.namespace === namespace)),
|
||||
};
|
||||
const { isLoading: isLoadingRepositories, error: errorRepositories, data: repositories } = useRepositories(request);
|
||||
|
||||
@@ -70,7 +86,7 @@ const useOverviewData = () => {
|
||||
namespace,
|
||||
repositories,
|
||||
search,
|
||||
page
|
||||
page,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -79,15 +95,22 @@ type RepositoriesProps = {
|
||||
repositories?: RepositoryCollection;
|
||||
search: string;
|
||||
page: number;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
const Repositories: FC<RepositoriesProps> = ({ namespaces, repositories, search, page }) => {
|
||||
const Repositories: FC<RepositoriesProps> = ({ namespaces, namespace, repositories, search, page }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
if (namespaces && repositories) {
|
||||
if (repositories._embedded && repositories._embedded.repositories.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<RepositoryList repositories={repositories._embedded.repositories} namespaces={namespaces} />
|
||||
<RepositoryList
|
||||
repositories={repositories._embedded.repositories}
|
||||
namespaces={namespaces}
|
||||
page={page}
|
||||
search={search}
|
||||
namespace={namespace}
|
||||
/>
|
||||
<LinkPaginator collection={repositories} page={page} filter={search} />
|
||||
</>
|
||||
);
|
||||
@@ -103,6 +126,9 @@ const Overview: FC = () => {
|
||||
const { isLoading, error, namespace, namespaces, repositories, search, page } = useOverviewData();
|
||||
const history = useHistory();
|
||||
const [t] = useTranslation("repos");
|
||||
const binder = useBinder();
|
||||
|
||||
const extensions = binder.getExtensions<extensionPoints.RepositoryOverviewLeftExtension>("repository.overview.left");
|
||||
|
||||
// we keep the create permission in the state,
|
||||
// because it does not change during searching or paging
|
||||
@@ -126,7 +152,7 @@ const Overview: FC = () => {
|
||||
const allNamespacesPlaceholder = t("overview.allNamespaces");
|
||||
let namespacesToRender: string[] = [];
|
||||
if (namespaces) {
|
||||
namespacesToRender = [allNamespacesPlaceholder, ...namespaces._embedded.namespaces.map(n => n.namespace).sort()];
|
||||
namespacesToRender = [allNamespacesPlaceholder, ...namespaces._embedded.namespaces.map((n) => n.namespace).sort()];
|
||||
}
|
||||
const namespaceSelected = (newNamespace: string) => {
|
||||
if (newNamespace === allNamespacesPlaceholder) {
|
||||
@@ -136,16 +162,38 @@ const Overview: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasExtensions = extensions.length > 0;
|
||||
|
||||
return (
|
||||
<Page title={t("overview.title")} subtitle={t("overview.subtitle")} loading={isLoading} error={error}>
|
||||
<Repositories namespaces={namespaces} repositories={repositories} search={search} page={page} />
|
||||
{showCreateButton ? <CreateButton label={t("overview.createButton")} link="/repos/create/" /> : null}
|
||||
<Page
|
||||
title={<ExtensionPoint name="repository.overview.title">{t("overview.title")}</ExtensionPoint>}
|
||||
subtitle={<ExtensionPoint name="repository.overview.subtitle">{t("overview.subtitle")}</ExtensionPoint>}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
>
|
||||
<div className="columns">
|
||||
{hasExtensions ? (
|
||||
<StickyColumn className="column is-one-third">
|
||||
{extensions.map((extension) => React.createElement(extension))}
|
||||
</StickyColumn>
|
||||
) : null}
|
||||
<div className="column is-clipped">
|
||||
<Repositories
|
||||
namespaces={namespaces}
|
||||
namespace={namespace}
|
||||
repositories={repositories}
|
||||
search={search}
|
||||
page={page}
|
||||
/>
|
||||
{showCreateButton ? <CreateButton label={t("overview.createButton")} link="/repos/create/" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<PageActions>
|
||||
{showActions ? (
|
||||
<OverviewPageActions
|
||||
showCreateButton={showCreateButton}
|
||||
currentGroup={
|
||||
namespace && namespaces?._embedded.namespaces.some(n => n.namespace === namespace) ? namespace : ""
|
||||
namespace && namespaces?._embedded.namespaces.some((n) => n.namespace === namespace) ? namespace : ""
|
||||
}
|
||||
groups={namespacesToRender}
|
||||
groupSelected={namespaceSelected}
|
||||
|
||||
Reference in New Issue
Block a user