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}