mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-14 09:25:43 +01:00
Improve ui performance
Move repository overview extension to avoid waiting for repositories. Also improve the extension points api to allow predicates without props. Co-authored-by: Konstantin Schaper <konstantin.schaper@cloudogu.com>
This commit is contained in:
4
gradle/changelog/ui_performance.yaml
Normal file
4
gradle/changelog/ui_performance.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
- type: changed
|
||||||
|
description: Optimize ui performance for repository overview
|
||||||
|
- type: changed
|
||||||
|
description: Enhance extensions name logic by allow bind options
|
||||||
@@ -106,7 +106,7 @@ describe("binder tests", () => {
|
|||||||
type TestExtensionPointA = ExtensionPointDefinition<"test.extension.a", number, undefined>;
|
type TestExtensionPointA = ExtensionPointDefinition<"test.extension.a", number, undefined>;
|
||||||
type TestExtensionPointB = ExtensionPointDefinition<"test.extension.b", number, { testProp: boolean[] }>;
|
type TestExtensionPointB = ExtensionPointDefinition<"test.extension.b", number, { testProp: boolean[] }>;
|
||||||
|
|
||||||
binder.bind<TestExtensionPointA>("test.extension.a", 2, () => false);
|
binder.bind<TestExtensionPointA>("test.extension.a", 2, () => true);
|
||||||
const binderExtensionA = binder.getExtension<TestExtensionPointA>("test.extension.a");
|
const binderExtensionA = binder.getExtension<TestExtensionPointA>("test.extension.a");
|
||||||
expect(binderExtensionA).not.toBeNull();
|
expect(binderExtensionA).not.toBeNull();
|
||||||
binder.bind<TestExtensionPointB>("test.extension.b", 2);
|
binder.bind<TestExtensionPointB>("test.extension.b", 2);
|
||||||
@@ -114,7 +114,7 @@ describe("binder tests", () => {
|
|||||||
testProp: [true, false],
|
testProp: [true, false],
|
||||||
});
|
});
|
||||||
expect(binderExtensionsB).toHaveLength(1);
|
expect(binderExtensionsB).toHaveLength(1);
|
||||||
binder.bind("test.extension.c", 2, () => false);
|
binder.bind("test.extension.c", 2, () => true);
|
||||||
const binderExtensionC = binder.getExtension("test.extension.c");
|
const binderExtensionC = binder.getExtension("test.extension.c");
|
||||||
expect(binderExtensionC).not.toBeNull();
|
expect(binderExtensionC).not.toBeNull();
|
||||||
});
|
});
|
||||||
@@ -126,7 +126,7 @@ describe("binder tests", () => {
|
|||||||
binder.bind<TestExtensionPointA>(
|
binder.bind<TestExtensionPointA>(
|
||||||
"test.extension.a",
|
"test.extension.a",
|
||||||
() => <h1>Hello world</h1>,
|
() => <h1>Hello world</h1>,
|
||||||
() => false
|
() => true
|
||||||
);
|
);
|
||||||
const binderExtensionA = binder.getExtension<TestExtensionPointA>("test.extension.a");
|
const binderExtensionA = binder.getExtension<TestExtensionPointA>("test.extension.a");
|
||||||
expect(binderExtensionA).not.toBeNull();
|
expect(binderExtensionA).not.toBeNull();
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ export type BindOptions<Props> = {
|
|||||||
priority?: number;
|
priority?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isBindOptions<Props>(input?: Predicate<Props> | BindOptions<Props>): input is BindOptions<Props> {
|
function isBindOptions<Props>(input?: string | Predicate<Props> | BindOptions<Props>): input is BindOptions<Props> {
|
||||||
return typeof input !== "function" && typeof input === "object";
|
return typeof input !== "string" && typeof input !== "function" && typeof input === "object";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,11 +105,25 @@ export class Binder {
|
|||||||
extension: E["type"],
|
extension: E["type"],
|
||||||
options?: BindOptions<E["props"]>
|
options?: BindOptions<E["props"]>
|
||||||
): void;
|
): void;
|
||||||
|
/**
|
||||||
|
* Binds an extension to the extension point.
|
||||||
|
*
|
||||||
|
* @param extensionPoint name of extension point
|
||||||
|
* @param extension provided extension
|
||||||
|
* @param predicate to decide if the extension gets rendered for the given props
|
||||||
|
* @param options object with additional settings
|
||||||
|
*/
|
||||||
|
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>>(
|
bind<E extends ExtensionPointDefinition<string, unknown, any>>(
|
||||||
extensionPoint: E["name"],
|
extensionPoint: E["name"],
|
||||||
extension: E["type"],
|
extension: E["type"],
|
||||||
predicateOrOptions?: Predicate<E["props"]> | BindOptions<E["props"]>,
|
predicateOrOptions?: Predicate<E["props"]> | BindOptions<E["props"]>,
|
||||||
extensionName?: string
|
extensionNameOrOptions?: string | BindOptions<E["props"]>
|
||||||
) {
|
) {
|
||||||
let predicate: Predicate<E["props"]> = () => true;
|
let predicate: Predicate<E["props"]> = () => true;
|
||||||
let priority = 0;
|
let priority = 0;
|
||||||
@@ -118,13 +132,23 @@ export class Binder {
|
|||||||
predicate = predicateOrOptions.predicate;
|
predicate = predicateOrOptions.predicate;
|
||||||
}
|
}
|
||||||
if (predicateOrOptions.extensionName) {
|
if (predicateOrOptions.extensionName) {
|
||||||
extensionName = predicateOrOptions.extensionName;
|
extensionNameOrOptions = predicateOrOptions.extensionName;
|
||||||
}
|
}
|
||||||
if (typeof predicateOrOptions.priority === "number") {
|
if (typeof predicateOrOptions.priority === "number") {
|
||||||
priority = predicateOrOptions.priority;
|
priority = predicateOrOptions.priority;
|
||||||
}
|
}
|
||||||
} else if (predicateOrOptions) {
|
} else if (predicateOrOptions) {
|
||||||
predicate = predicateOrOptions;
|
predicate = predicateOrOptions;
|
||||||
|
if (isBindOptions(extensionNameOrOptions)) {
|
||||||
|
if (typeof extensionNameOrOptions.priority === "number") {
|
||||||
|
priority = extensionNameOrOptions.priority;
|
||||||
|
}
|
||||||
|
if (extensionNameOrOptions?.extensionName) {
|
||||||
|
extensionNameOrOptions = extensionNameOrOptions.extensionName;
|
||||||
|
} else {
|
||||||
|
extensionNameOrOptions = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!this.extensionPoints[extensionPoint]) {
|
if (!this.extensionPoints[extensionPoint]) {
|
||||||
this.extensionPoints[extensionPoint] = [];
|
this.extensionPoints[extensionPoint] = [];
|
||||||
@@ -132,7 +156,7 @@ export class Binder {
|
|||||||
const registration = {
|
const registration = {
|
||||||
predicate,
|
predicate,
|
||||||
extension,
|
extension,
|
||||||
extensionName: extensionName ? extensionName : "",
|
extensionName: extensionNameOrOptions ? extensionNameOrOptions : "",
|
||||||
priority,
|
priority,
|
||||||
} as ExtensionRegistration<E["props"], E["type"]>;
|
} as ExtensionRegistration<E["props"], E["type"]>;
|
||||||
this.extensionPoints[extensionPoint].push(registration);
|
this.extensionPoints[extensionPoint].push(registration);
|
||||||
@@ -186,9 +210,7 @@ export class Binder {
|
|||||||
props?: E["props"]
|
props?: E["props"]
|
||||||
): Array<E["type"]> {
|
): Array<E["type"]> {
|
||||||
let registrations = this.extensionPoints[extensionPoint] || [];
|
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);
|
registrations.sort(this.sortExtensions);
|
||||||
return registrations.map((reg) => reg.extension);
|
return registrations.map((reg) => reg.extension);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export type RepositoryOverviewTop = RenderableExtensionPointDefinition<
|
|||||||
"repository.overview.top",
|
"repository.overview.top",
|
||||||
{
|
{
|
||||||
page: number;
|
page: number;
|
||||||
search: string;
|
search?: string;
|
||||||
namespace?: string;
|
namespace?: string;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -27,40 +27,22 @@ import { NamespaceCollection, Repository } from "@scm-manager/ui-types";
|
|||||||
|
|
||||||
import groupByNamespace from "./groupByNamespace";
|
import groupByNamespace from "./groupByNamespace";
|
||||||
import RepositoryGroupEntry from "./RepositoryGroupEntry";
|
import RepositoryGroupEntry from "./RepositoryGroupEntry";
|
||||||
import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions";
|
|
||||||
import { KeyboardIterator, KeyboardSubIterator } from "@scm-manager/ui-shortcuts";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
repositories: Repository[];
|
repositories: Repository[];
|
||||||
namespaces: NamespaceCollection;
|
namespaces: NamespaceCollection;
|
||||||
page: number;
|
|
||||||
search: string;
|
|
||||||
namespace?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class RepositoryList extends React.Component<Props> {
|
class RepositoryList extends React.Component<Props> {
|
||||||
render() {
|
render() {
|
||||||
const { repositories, namespaces, namespace, page, search } = this.props;
|
const { repositories, namespaces } = this.props;
|
||||||
|
|
||||||
const groups = groupByNamespace(repositories, namespaces);
|
const groups = groupByNamespace(repositories, namespaces);
|
||||||
return (
|
return (
|
||||||
<div className="content">
|
<div className="content">
|
||||||
<KeyboardIterator>
|
|
||||||
<KeyboardSubIterator>
|
|
||||||
<ExtensionPoint<extensionPoints.RepositoryOverviewTop>
|
|
||||||
name="repository.overview.top"
|
|
||||||
renderAll={true}
|
|
||||||
props={{
|
|
||||||
page,
|
|
||||||
search,
|
|
||||||
namespace,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</KeyboardSubIterator>
|
|
||||||
{groups.map((group) => {
|
{groups.map((group) => {
|
||||||
return <RepositoryGroupEntry group={group} key={group.name} />;
|
return <RepositoryGroupEntry group={group} key={group.name} />;
|
||||||
})}
|
})}
|
||||||
</KeyboardIterator>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
CreateButton,
|
CreateButton,
|
||||||
devices,
|
devices,
|
||||||
|
ErrorNotification,
|
||||||
LinkPaginator,
|
LinkPaginator,
|
||||||
|
Loading,
|
||||||
Notification,
|
Notification,
|
||||||
OverviewPageActions,
|
OverviewPageActions,
|
||||||
Page,
|
Page,
|
||||||
@@ -39,6 +41,8 @@ import { useNamespaceAndNameContext, useNamespaces, useRepositories } from "@scm
|
|||||||
import { NamespaceCollection, RepositoryCollection } from "@scm-manager/ui-types";
|
import { NamespaceCollection, RepositoryCollection } from "@scm-manager/ui-types";
|
||||||
import { binder, ExtensionPoint, extensionPoints, useBinder } from "@scm-manager/ui-extensions";
|
import { binder, ExtensionPoint, extensionPoints, useBinder } from "@scm-manager/ui-extensions";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import { KeyboardIterator, KeyboardSubIterator } from "@scm-manager/ui-shortcuts";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
const StickyColumn = styled.div`
|
const StickyColumn = styled.div`
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
@@ -100,32 +104,68 @@ const useOverviewData = () => {
|
|||||||
type RepositoriesProps = {
|
type RepositoriesProps = {
|
||||||
namespaces?: NamespaceCollection;
|
namespaces?: NamespaceCollection;
|
||||||
repositories?: RepositoryCollection;
|
repositories?: RepositoryCollection;
|
||||||
search: string;
|
search?: string;
|
||||||
page: number;
|
page: number;
|
||||||
namespace?: string;
|
isLoading?: boolean;
|
||||||
|
error?: Error;
|
||||||
|
hasTopExtension?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Repositories: FC<RepositoriesProps> = ({ namespaces, namespace, repositories, search, page }) => {
|
const Repositories: FC<RepositoriesProps> = ({
|
||||||
|
namespaces,
|
||||||
|
repositories,
|
||||||
|
hasTopExtension,
|
||||||
|
search,
|
||||||
|
page,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
const [t] = useTranslation("repos");
|
const [t] = useTranslation("repos");
|
||||||
if (namespaces && repositories) {
|
let header;
|
||||||
|
if (hasTopExtension) {
|
||||||
|
header = (
|
||||||
|
<div className={classNames("is-flex", "is-align-items-center", "is-size-6", "has-text-weight-bold", "p-3")}>
|
||||||
|
{t("overview.title")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
<ErrorNotification error={error} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else if (isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
<Loading />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else if (namespaces && repositories) {
|
||||||
if (repositories._embedded && repositories._embedded.repositories.length > 0) {
|
if (repositories._embedded && repositories._embedded.repositories.length > 0) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RepositoryList
|
<RepositoryList repositories={repositories._embedded.repositories} namespaces={namespaces} />
|
||||||
repositories={repositories._embedded.repositories}
|
|
||||||
namespaces={namespaces}
|
|
||||||
page={page}
|
|
||||||
search={search}
|
|
||||||
namespace={namespace}
|
|
||||||
/>
|
|
||||||
<LinkPaginator collection={repositories} page={page} filter={search} />
|
<LinkPaginator collection={repositories} page={page} filter={search} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return <Notification type="info">{t("overview.noRepositories")}</Notification>;
|
return (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
<Notification type="info">{t("overview.noRepositories")}</Notification>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return <Notification type="info">{t("overview.invalidNamespace")}</Notification>;
|
return (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
<Notification type="info">{t("overview.invalidNamespace")}</Notification>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,8 +186,6 @@ const Overview: FC = () => {
|
|||||||
};
|
};
|
||||||
}, [namespace, context]);
|
}, [namespace, context]);
|
||||||
|
|
||||||
const extensions = binder.getExtensions<extensionPoints.RepositoryOverviewLeft>("repository.overview.left");
|
|
||||||
|
|
||||||
// we keep the create permission in the state,
|
// we keep the create permission in the state,
|
||||||
// because it does not change during searching or paging
|
// because it does not change during searching or paging
|
||||||
// and we can avoid bouncing of search bar elements
|
// and we can avoid bouncing of search bar elements
|
||||||
@@ -180,9 +218,7 @@ const Overview: FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasExtensions = extensions.length > 0;
|
const createLink = namespace ? `/repos/create/?namespace=${namespace}` : "/repos/create/";
|
||||||
|
|
||||||
const createLink = namespace ? `/repos/create/?namespace=${namespace}`: "/repos/create/";
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
documentTitle={t("overview.title")}
|
documentTitle={t("overview.title")}
|
||||||
@@ -196,23 +232,40 @@ const Overview: FC = () => {
|
|||||||
{t("overview.subtitle")}
|
{t("overview.subtitle")}
|
||||||
</ExtensionPoint>
|
</ExtensionPoint>
|
||||||
}
|
}
|
||||||
loading={isLoading}
|
|
||||||
error={error}
|
|
||||||
>
|
>
|
||||||
<div className="columns">
|
<div className="columns">
|
||||||
{hasExtensions ? (
|
{binder.hasExtension<extensionPoints.RepositoryOverviewLeft>("repository.overview.left") ? (
|
||||||
<StickyColumn className="column is-one-third">
|
<StickyColumn className="column is-one-third">
|
||||||
{extensions.map((extension) => React.createElement(extension))}
|
{<ExtensionPoint<extensionPoints.RepositoryOverviewLeft> name="repository.overview.left" renderAll />}
|
||||||
</StickyColumn>
|
</StickyColumn>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="column is-clipped">
|
<div className="column is-clipped">
|
||||||
|
<KeyboardIterator>
|
||||||
|
<KeyboardSubIterator>
|
||||||
|
<ExtensionPoint<extensionPoints.RepositoryOverviewTop>
|
||||||
|
name="repository.overview.top"
|
||||||
|
renderAll={true}
|
||||||
|
props={{
|
||||||
|
page,
|
||||||
|
search,
|
||||||
|
namespace,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</KeyboardSubIterator>
|
||||||
<Repositories
|
<Repositories
|
||||||
namespaces={namespaces}
|
namespaces={namespaces}
|
||||||
namespace={namespace}
|
|
||||||
repositories={repositories}
|
repositories={repositories}
|
||||||
search={search}
|
search={search}
|
||||||
page={page}
|
page={page}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
hasTopExtension={binder.hasExtension<extensionPoints.RepositoryOverviewTop>("repository.overview.top", {
|
||||||
|
page,
|
||||||
|
search,
|
||||||
|
namespace,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
|
</KeyboardIterator>
|
||||||
{showCreateButton ? <CreateButton label={t("overview.createButton")} link={createLink} /> : null}
|
{showCreateButton ? <CreateButton label={t("overview.createButton")} link={createLink} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user