From 57aacba03a018bc8f969c0fc42194967641ae774 Mon Sep 17 00:00:00 2001 From: Konstantin Schaper Date: Tue, 19 Oct 2021 09:31:40 +0200 Subject: [PATCH] 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. --- docs/en/development/ui-extensions.md | 25 +++++ .../repooverviewextensionpoints.yaml | 4 + .../src/CardColumnSmall.stories.tsx | 20 +++- scm-ui/ui-components/src/CardColumnSmall.tsx | 13 +-- .../src/__snapshots__/storyshots.test.ts.snap | 100 +++++++++++++----- .../src/layout}/GroupEntries.tsx | 0 .../ui-components/src/layout/GroupEntry.tsx | 3 +- scm-ui/ui-components/src/layout/index.ts | 1 + scm-ui/ui-extensions/src/binder.test.ts | 78 +++++++++++--- scm-ui/ui-extensions/src/binder.ts | 66 +++++++++--- scm-ui/ui-extensions/src/extensionPoints.ts | 21 ++++ scm-ui/ui-styles/src/scm.scss | 4 + .../components/list/RepositoryGroupEntry.tsx | 3 +- .../repos/components/list/RepositoryList.tsx | 17 ++- .../src/repos/containers/Overview.tsx | 70 ++++++++++-- 15 files changed, 347 insertions(+), 78 deletions(-) create mode 100644 gradle/changelog/repooverviewextensionpoints.yaml rename scm-ui/{ui-webapp/src/repos/components/list => ui-components/src/layout}/GroupEntries.tsx (100%) diff --git a/docs/en/development/ui-extensions.md b/docs/en/development/ui-extensions.md index b1490a7126..d0396210f8 100644 --- a/docs/en/development/ui-extensions.md +++ b/docs/en/development/ui-extensions.md @@ -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",
Hello World the fourth
, { priority: 10, extensionName: "ignore" }); + binder.bind("extension.point.example",
Hello World the third
, { priority: 50 }); + binder.bind("extension.point.example",
Hello World the first
, { priority: 100, extensionName: "me" }); + binder.bind("extension.point.example",
Hello World the second
, { 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 + */ +``` diff --git a/gradle/changelog/repooverviewextensionpoints.yaml b/gradle/changelog/repooverviewextensionpoints.yaml new file mode 100644 index 0000000000..07ac1b5d46 --- /dev/null +++ b/gradle/changelog/repooverviewextensionpoints.yaml @@ -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)) diff --git a/scm-ui/ui-components/src/CardColumnSmall.stories.tsx b/scm-ui/ui-components/src/CardColumnSmall.stories.tsx index 9d2f4b6ffc..0cb1756b3a 100644 --- a/scm-ui/ui-components/src/CardColumnSmall.stories.tsx +++ b/scm-ui/ui-components/src/CardColumnSmall.stories.tsx @@ -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 = main content; const contentRight = more text; storiesOf("CardColumnSmall", module) - .addDecorator(story => {story()}) - .addDecorator(storyFn => {storyFn()}) + .addDecorator((story) => {story()}) + .addDecorator((storyFn) => {storyFn()}) .add("Default", () => ( )) - .add("Minimal", () => ); + .add("Minimal", () => ) + .add("Task", () => ( + } + contentLeft={Repository created} + contentRight={ + + + + } + footer="New: scmadmin/spaceship" + /> + )); diff --git a/scm-ui/ui-components/src/CardColumnSmall.tsx b/scm-ui/ui-components/src/CardColumnSmall.tsx index 80edec80f0..7f220890c0 100644 --- a/scm-ui/ui-components/src/CardColumnSmall.tsx +++ b/scm-ui/ui-components/src/CardColumnSmall.tsx @@ -37,18 +37,15 @@ type Props = { const StyledLink = styled(Link)` color: inherit; - :hover { - color: #33b2e8 !important; - } `; const CardColumnSmall: FC = ({ link, avatar, contentLeft, contentRight, footer }) => { - const renderAvatar = avatar ?
{avatar}
: null; + const renderAvatar = avatar ?
{avatar}
: null; const renderFooter = footer ? {footer} : null; return ( -
+
{renderAvatar}
= ({ link, avatar, contentLeft, contentRight, f "is-align-self-stretch" )} > -
-
{contentLeft}
-
{contentRight}
+
+
{contentLeft}
+
{contentRight}
{renderFooter}
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 6d32a2e9bd..13ef0fa74a 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -2640,15 +2640,15 @@ exports[`Storyshots CardColumnSmall Default 1`] = ` className="CardColumnSmallstories__Wrapper-ofr817-0 hUFZIW" >
-
+
more text @@ -2688,21 +2686,21 @@ exports[`Storyshots CardColumnSmall Minimal 1`] = ` className="CardColumnSmallstories__Wrapper-ofr817-0 hUFZIW" >
-
+
more text @@ -2724,6 +2720,58 @@ exports[`Storyshots CardColumnSmall Minimal 1`] = `
`; +exports[`Storyshots CardColumnSmall Task 1`] = ` +
+`; + exports[`Storyshots Changesets Co-Authors with avatar 1`] = `
@@ -57352,7 +57400,7 @@ exports[`Storyshots GroupEntry With long texts 1`] = ` className="is-relative" > @@ -72779,7 +72827,7 @@ exports[`Storyshots RepositoryEntry Archived 1`] = ` className="is-relative" > @@ -72883,7 +72931,7 @@ exports[`Storyshots RepositoryEntry Avatar EP 1`] = ` className="is-relative" > @@ -72976,7 +73024,7 @@ exports[`Storyshots RepositoryEntry Before Title EP 1`] = ` className="is-relative" > @@ -73072,7 +73120,7 @@ exports[`Storyshots RepositoryEntry Default 1`] = ` className="is-relative" > @@ -73165,7 +73213,7 @@ exports[`Storyshots RepositoryEntry Exporting 1`] = ` className="is-relative" > @@ -73269,7 +73317,7 @@ exports[`Storyshots RepositoryEntry HealthCheck Failure 1`] = ` className="is-relative" > @@ -73374,7 +73422,7 @@ exports[`Storyshots RepositoryEntry MultiRepositoryTags 1`] = ` className="is-relative" > @@ -73488,7 +73536,7 @@ exports[`Storyshots RepositoryEntry RepositoryFlag EP 1`] = ` className="is-relative" > @@ -73593,7 +73641,7 @@ exports[`Storyshots RepositoryEntry With long texts 1`] = ` className="is-relative" > diff --git a/scm-ui/ui-webapp/src/repos/components/list/GroupEntries.tsx b/scm-ui/ui-components/src/layout/GroupEntries.tsx similarity index 100% rename from scm-ui/ui-webapp/src/repos/components/list/GroupEntries.tsx rename to scm-ui/ui-components/src/layout/GroupEntries.tsx diff --git a/scm-ui/ui-components/src/layout/GroupEntry.tsx b/scm-ui/ui-components/src/layout/GroupEntry.tsx index 42108fb794..e73d25b5e7 100644 --- a/scm-ui/ui-components/src/layout/GroupEntry.tsx +++ b/scm-ui/ui-components/src/layout/GroupEntry.tsx @@ -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 = ({ link, avatar, title, name, description, contentRight }) => { return (
- + { 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("test.extension.b", 2); const binderExtensionsB = binder.getExtensions("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 = + SimpleDynamicExtensionPointDefinition< + "markdown-renderer.code.", + (props: any) => any, + MarkdownCodeLanguageRendererProps, + S + >; type UmlExtensionPoint = MarkdownCodeLanguageRendererExtensionPoint<"uml">; - binder.bind("markdown-renderer.code.uml", props => props.value); + binder.bind("markdown-renderer.code.uml", (props) => props.value); const language = "uml"; const extensionPointName = `markdown-renderer.code.${language}` as const; const dynamicExtension = binder.getExtension(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"]); + }); }); diff --git a/scm-ui/ui-extensions/src/binder.ts b/scm-ui/ui-extensions/src/binder.ts index ef80db9746..9d99a2de72 100644 --- a/scm-ui/ui-extensions/src/binder.ts +++ b/scm-ui/ui-extensions/src/binder.ts @@ -28,6 +28,7 @@ type ExtensionRegistration = { predicate: Predicate

; extension: T; extensionName: string; + priority: number; }; export type ExtensionPointDefinition = { @@ -36,7 +37,26 @@ export type ExtensionPointDefinition = { props: P; }; -export type SimpleDynamicExtensionPointDefinition

= ExtensionPointDefinition; +export type SimpleDynamicExtensionPointDefinition

= + ExtensionPointDefinition; + +export type BindOptions = { + predicate?: Predicate; + + /** + * Extensions are ordered by name (ASC). + */ + extensionName?: string; + + /** + * Extensions are ordered by priority (DESC). + */ + priority?: number; +}; + +function isBindOptions(input?: Predicate | BindOptions): input is BindOptions { + 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>( - extensionPoint: E["name"], - extension: E["type"] - ): void; + bind>(extensionPoint: E["name"], extension: E["type"]): void; bind>( extensionPoint: E["name"], extension: E["type"], @@ -73,17 +90,38 @@ export class Binder { bind>( extensionPoint: E["name"], extension: E["type"], - predicate?: Predicate, + options?: BindOptions + ): void; + bind>( + extensionPoint: E["name"], + extension: E["type"], + predicateOrOptions?: Predicate | BindOptions, extensionName?: string ) { + let predicate: Predicate = () => 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; this.extensionPoints[extensionPoint].push(registration); } @@ -128,10 +166,10 @@ export class Binder { ): Array { 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; diff --git a/scm-ui/ui-extensions/src/extensionPoints.ts b/scm-ui/ui-extensions/src/extensionPoints.ts index 817b4f2563..837f5d139f 100644 --- a/scm-ui/ui-extensions/src/extensionPoints.ts +++ b/scm-ui/ui-extensions/src/extensionPoints.ts @@ -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 +>; +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 +>; diff --git a/scm-ui/ui-styles/src/scm.scss b/scm-ui/ui-styles/src/scm.scss index 9db74be440..08fa5a8476 100644 --- a/scm-ui/ui-styles/src/scm.scss +++ b/scm-ui/ui-styles/src/scm.scss @@ -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; diff --git a/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx b/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx index 2b18ca2c88..1c8ff8bfe1 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx +++ b/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx @@ -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; diff --git a/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx b/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx index fc9e539ab2..e8b52480e6 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx +++ b/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx @@ -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 { render() { - const { repositories, namespaces } = this.props; + const { repositories, namespaces, namespace, page, search } = this.props; const groups = groupByNamespace(repositories, namespaces); return (

- {groups.map(group => { + + {groups.map((group) => { return ; })}
diff --git a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx index 9fff58af5b..5a6fc1d3ec 100644 --- a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx @@ -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 = ({ namespaces, repositories, search, page }) => { +const Repositories: FC = ({ namespaces, namespace, repositories, search, page }) => { const [t] = useTranslation("repos"); if (namespaces && repositories) { if (repositories._embedded && repositories._embedded.repositories.length > 0) { return ( <> - + ); @@ -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("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 ( - - - {showCreateButton ? : null} + {t("overview.title")}} + subtitle={{t("overview.subtitle")}} + loading={isLoading} + error={error} + > +
+ {hasExtensions ? ( + + {extensions.map((extension) => React.createElement(extension))} + + ) : null} +
+ + {showCreateButton ? : null} +
+
{showActions ? ( n.namespace === namespace) ? namespace : "" + namespace && namespaces?._embedded.namespaces.some((n) => n.namespace === namespace) ? namespace : "" } groups={namespacesToRender} groupSelected={namespaceSelected}