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:
Konstantin Schaper
2021-10-19 09:31:40 +02:00
committed by GitHub
parent 35f4cb3e61
commit 57aacba03a
15 changed files with 347 additions and 78 deletions

View File

@@ -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
*/
```

View 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))

View File

@@ -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"
/>
));

View File

@@ -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>

View File

@@ -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]}
/>

View File

@@ -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}

View File

@@ -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";

View File

@@ -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"]);
});
});

View File

@@ -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;

View File

@@ -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
>;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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}