Feature/branch details (#1876)

Enrich branch overview with more details like last committer and ahead/behind commits. Since calculating this information is pretty intense, we request it in chunks to prevent very long loading times. Also we cache the results in frontend and backend.

Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>
This commit is contained in:
Eduard Heimbuch
2021-12-01 14:19:18 +01:00
committed by GitHub
parent ce2eae1843
commit 9cc134f5a8
59 changed files with 1933 additions and 154 deletions

View File

@@ -127,7 +127,8 @@
"active": "Aktive Branches",
"stale": "Stale Branches"
},
"lastCommit": "Letzter Commit"
"lastCommit": "Letzter Commit",
"lastCommitter": "von {{name}}"
},
"create": {
"title": "Branch erstellen",
@@ -142,6 +143,9 @@
"sources": "Sources",
"defaultTag": "Default",
"dangerZone": "Branch löschen",
"aheadBehind": {
"tooltip": "{{ahead}} Commit(s) vor, {{behind}} Commit(s) hinter dem Default Branch"
},
"delete": {
"button": "Branch löschen",
"subtitle": "Branch löschen",

View File

@@ -127,7 +127,8 @@
"active": "Active Branches",
"stale": "Stale Branches"
},
"lastCommit": "Last commit"
"lastCommit": "Last commit",
"lastCommitter": "by {{name}}"
},
"create": {
"title": "Create Branch",
@@ -142,6 +143,9 @@
"sources": "Sources",
"defaultTag": "Default",
"dangerZone": "Delete Branch",
"aheadBehind": {
"tooltip": "{{ahead}} commit(s) ahead, {{behind}} commit(s) behind default branch"
},
"delete": {
"button": "Delete Branch",
"subtitle": "Delete Branch",

View File

@@ -0,0 +1,106 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { Branch, BranchDetails } from "@scm-manager/ui-types";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { Tooltip } from "@scm-manager/ui-components";
import { calculateBarLength } from "./aheadBehind";
type Props = {
branch: Branch;
details: BranchDetails;
};
type BarProps = {
width: number;
direction: "right" | "left";
};
const Ahead = styled.div`
border-left: 1px solid gray;
`;
const Behind = styled.div``;
const Count = styled.div`
word-break: keep-all;
`;
const Bar = styled.span.attrs<BarProps>(props => ({
style: {
width: props.width + "%",
borderRadius: props.direction === "left" ? "25px 0 0 25px" : "0 25px 25px 0"
}
}))<BarProps>`
height: 3px;
max-width: 100%;
margin-top: -2px;
margin-bottom: 2px;
`;
const TooltipWithDefaultCursor = styled(Tooltip)`
cursor: default !important;
`;
const AheadBehindTag: FC<Props> = ({ branch, details }) => {
const [t] = useTranslation("repos");
if (
branch.defaultBranch ||
typeof details.changesetsBehind !== "number" ||
typeof details.changesetsAhead !== "number"
) {
return null;
}
return (
<TooltipWithDefaultCursor
message={t("branch.aheadBehind.tooltip", { ahead: details.changesetsAhead, behind: details.changesetsBehind })}
location="top"
>
<div className="columns is-flex is-unselectable is-hidden-mobile">
<Behind className="column is-half is-flex is-flex-direction-column is-align-items-flex-end p-0">
<Count className="is-size-7 pr-1">{details.changesetsBehind}</Count>
<Bar
className="has-rounded-border-left has-background-grey"
width={calculateBarLength(details.changesetsBehind)}
direction="left"
/>
</Behind>
<Ahead className="column is-half is-flex is-flex-direction-column is-align-items-flex-start p-0">
<Count className="is-size-7 pl-1">{details.changesetsAhead}</Count>
<Bar
className="has-rounded-border-right has-background-grey"
width={calculateBarLength(details.changesetsAhead)}
direction="right"
/>
</Ahead>
</div>
</TooltipWithDefaultCursor>
);
};
export default AheadBehindTag;

View File

@@ -25,42 +25,80 @@ import React, { FC } from "react";
import { Link as ReactLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { Branch, Link } from "@scm-manager/ui-types";
import { Branch, BranchDetails, Link } from "@scm-manager/ui-types";
import { DateFromNow, Icon } from "@scm-manager/ui-components";
import DefaultBranchTag from "./DefaultBranchTag";
import AheadBehindTag from "./AheadBehindTag";
type Props = {
baseUrl: string;
branch: Branch;
onDelete: (branch: Branch) => void;
details?: BranchDetails;
};
const BranchRow: FC<Props> = ({ baseUrl, branch, onDelete }) => {
const BranchRow: FC<Props> = ({ baseUrl, branch, onDelete, details }) => {
const to = `${baseUrl}/${encodeURIComponent(branch.name)}/info`;
const [t] = useTranslation("repos");
let deleteButton;
if ((branch?._links?.delete as Link)?.href) {
deleteButton = (
<span className="icon is-small is-hovered" onClick={() => onDelete(branch)} onKeyDown={(e) => e.key === "Enter" && onDelete(branch)} tabIndex={0}>
<span
className="icon is-small is-hovered is-clickable"
onClick={() => onDelete(branch)}
onKeyDown={e => e.key === "Enter" && onDelete(branch)}
tabIndex={0}
>
<Icon name="trash" className="fas " title={t("branch.delete.button")} />
</span>
);
}
const renderBranchTag = () => {
if (branch.defaultBranch) {
return <DefaultBranchTag defaultBranch={branch.defaultBranch} />;
}
if (details) {
return <AheadBehindTag branch={branch} details={details} />;
}
return (
<div className="loader-wrapper">
<div className="loader is-loading" />
</div>
);
};
const committedAt = (
<>
{t("branches.table.lastCommit")} <DateFromNow date={branch.lastCommitDate} />
</>
);
let committedAtBy;
if (branch.lastCommitter?.name) {
committedAtBy = (
<>
{committedAt} {t("branches.table.lastCommitter", { name: branch.lastCommitter?.name })}
</>
);
} else {
committedAtBy = committedAt;
}
return (
<tr>
<td>
<td className="is-flex">
<ReactLink to={to} title={branch.name}>
{branch.name}
<DefaultBranchTag defaultBranch={branch.defaultBranch} />
</ReactLink>
{branch.lastCommitDate && (
<span className={classNames("has-text-grey", "is-ellipsis-overflow", "is-size-7", "ml-4")}>
{t("branches.table.lastCommit")} <DateFromNow date={branch.lastCommitDate} />
{committedAtBy}
</span>
)}
</td>
<td className="has-text-centered">{renderBranchTag()}</td>
<td className="is-darker has-text-centered">{deleteButton}</td>
</tr>
);

View File

@@ -24,7 +24,7 @@
import React, { FC, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import BranchRow from "./BranchRow";
import { Branch, Repository } from "@scm-manager/ui-types";
import { Branch, BranchDetails, Repository } from "@scm-manager/ui-types";
import { ConfirmAlert, ErrorNotification } from "@scm-manager/ui-components";
import { useDeleteBranch } from "@scm-manager/ui-api";
@@ -33,9 +33,10 @@ type Props = {
repository: Repository;
branches: Branch[];
type: string;
branchesDetails: BranchDetails[];
};
const BranchTable: FC<Props> = ({ repository, baseUrl, branches, type }) => {
const BranchTable: FC<Props> = ({ repository, baseUrl, branches, type, branchesDetails }) => {
const { isLoading, error, remove, isDeleted } = useDeleteBranch(repository);
const [t] = useTranslation("repos");
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
@@ -77,17 +78,17 @@ const BranchTable: FC<Props> = ({ repository, baseUrl, branches, type }) => {
className: "is-outlined",
label: t("branch.delete.confirmAlert.submit"),
isLoading,
onClick: () => deleteBranch(),
onClick: () => deleteBranch()
},
{
label: t("branch.delete.confirmAlert.cancel"),
onClick: () => abortDelete(),
},
onClick: () => abortDelete()
}
]}
close={() => abortDelete()}
/>
) : null}
{error ? <ErrorNotification error={error} /> : null}
<ErrorNotification error={error} />
<table className="card-table table is-hoverable is-fullwidth is-word-break">
<thead>
<tr>
@@ -95,8 +96,14 @@ const BranchTable: FC<Props> = ({ repository, baseUrl, branches, type }) => {
</tr>
</thead>
<tbody>
{(branches || []).map((branch) => (
<BranchRow key={branch.name} baseUrl={baseUrl} branch={branch} onDelete={onDelete} />
{(branches || []).map(branch => (
<BranchRow
key={branch.name}
baseUrl={baseUrl}
branch={branch}
onDelete={onDelete}
details={branchesDetails?.filter((b: BranchDetails) => b.branchName === branch.name)[0]}
/>
))}
</tbody>
</table>

View File

@@ -24,17 +24,22 @@
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import { Tag } from "@scm-manager/ui-components";
import styled from "styled-components";
type Props = WithTranslation & {
defaultBranch?: boolean;
};
const DefaultTag = styled(Tag)`
max-height: 1.5em;
`;
class DefaultBranchTag extends React.Component<Props> {
render() {
const { defaultBranch, t } = this.props;
if (defaultBranch) {
return <Tag className="ml-3" color="dark" label={t("branch.defaultTag")} />;
return <DefaultTag className="is-unselectable" color="dark" label={t("branch.defaultTag")} />;
}
return null;
}

View File

@@ -0,0 +1,46 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { calculateBarLength } from "./aheadBehind";
describe("ahead/behind percentage", () => {
it("0 should have percentage value of 5", () => {
const percentage = calculateBarLength(0);
expect(percentage).toEqual(5);
});
let lastPercentage = 5;
for (let changesets = 1; changesets < 4000; changesets++) {
it(`${changesets} should have percentage value less or equal to last value`, () => {
const percentage = calculateBarLength(changesets);
expect(percentage).toBeGreaterThanOrEqual(lastPercentage);
lastPercentage = percentage;
});
}
it("10000 should not have percentage value bigger than 100", () => {
const percentage = calculateBarLength(10000);
expect(percentage).toEqual(100);
});
});

View File

@@ -0,0 +1,37 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
export const calculateBarLength = (changesets: number) => {
if (changesets <= 10) {
return changesets + 5;
} else if (changesets <= 50) {
return (changesets - 10) / 5 + 15;
} else if (changesets <= 500) {
return (changesets - 50) / 10 + 23;
} else if (changesets <= 3700) {
return (changesets - 500) / 100 + 68;
} else {
return 100;
}
};

View File

@@ -0,0 +1,83 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Branch, HalRepresentation, Repository } from "@scm-manager/ui-types";
import { CreateButton, ErrorNotification, Notification, Subtitle } from "@scm-manager/ui-components";
import { orderBranches } from "../util/orderBranches";
import BranchTable from "../components/BranchTable";
import { useTranslation } from "react-i18next";
import { useBranchDetailsCollection } from "@scm-manager/ui-api";
type Props = {
repository: Repository;
baseUrl: string;
data: HalRepresentation;
};
const BranchTableWrapper: FC<Props> = ({ repository, baseUrl, data }) => {
const [t] = useTranslation("repos");
const branches: Branch[] = (data?._embedded?.branches as Branch[]) || [];
orderBranches(branches);
const staleBranches = branches.filter(b => b.stale);
const activeBranches = branches.filter(b => !b.stale);
const { error, data: branchesDetails } = useBranchDetailsCollection(repository, [
...activeBranches,
...staleBranches
]);
if (branches.length === 0) {
return <Notification type="info">{t("branches.overview.noBranches")}</Notification>;
}
const showCreateButton = !!data._links.create;
return (
<>
<Subtitle subtitle={t("branches.overview.title")} />
<ErrorNotification error={error} />
{activeBranches.length > 0 ? (
<BranchTable
repository={repository}
baseUrl={baseUrl}
type="active"
branches={activeBranches}
branchesDetails={branchesDetails}
/>
) : null}
{staleBranches.length > 0 ? (
<BranchTable
repository={repository}
baseUrl={baseUrl}
type="stale"
branches={staleBranches}
branchesDetails={branchesDetails}
/>
) : null}
{showCreateButton ? <CreateButton label={t("branches.overview.createButton")} link="./create" /> : null}
</>
);
};
export default BranchTableWrapper;

View File

@@ -22,12 +22,10 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Repository } from "@scm-manager/ui-types";
import { CreateButton, ErrorNotification, Loading, Notification, Subtitle } from "@scm-manager/ui-components";
import { orderBranches } from "../util/orderBranches";
import BranchTable from "../components/BranchTable";
import { ErrorNotification, Loading } from "@scm-manager/ui-components";
import { useBranches } from "@scm-manager/ui-api";
import BranchTableWrapper from "./BranchTableWrapper";
type Props = {
repository: Repository;
@@ -36,7 +34,6 @@ type Props = {
const BranchesOverview: FC<Props> = ({ repository, baseUrl }) => {
const { isLoading, error, data } = useBranches(repository);
const [t] = useTranslation("repos");
if (error) {
return <ErrorNotification error={error} />;
@@ -46,30 +43,7 @@ const BranchesOverview: FC<Props> = ({ repository, baseUrl }) => {
return <Loading />;
}
const branches = data?._embedded?.branches || [];
if (branches.length === 0) {
return <Notification type="info">{t("branches.overview.noBranches")}</Notification>;
}
orderBranches(branches);
const staleBranches = branches.filter((b) => b.stale);
const activeBranches = branches.filter((b) => !b.stale);
const showCreateButton = !!data._links.create;
return (
<>
<Subtitle subtitle={t("branches.overview.title")} />
{activeBranches.length > 0 ? (
<BranchTable repository={repository} baseUrl={baseUrl} type="active" branches={activeBranches} />
) : null}
{staleBranches.length > 0 ? (
<BranchTable repository={repository} baseUrl={baseUrl} type="stale" branches={staleBranches} />
) : null}
{showCreateButton ? <CreateButton label={t("branches.overview.createButton")} link="./create" /> : null}
</>
);
return <BranchTableWrapper repository={repository} baseUrl={baseUrl} data={data} />;
};
export default BranchesOverview;

View File

@@ -41,7 +41,12 @@ const TagRow: FC<Props> = ({ tag, baseUrl, onDelete }) => {
let deleteButton;
if ((tag?._links?.delete as Link)?.href) {
deleteButton = (
<span className="icon is-small" onClick={() => onDelete(tag)} onKeyDown={(e) => e.key === "Enter" && onDelete(tag)} tabIndex={0}>
<span
className="icon is-small is-clickable"
onClick={() => onDelete(tag)}
onKeyDown={e => e.key === "Enter" && onDelete(tag)}
tabIndex={0}
>
<Icon name="trash" className="fas" title={t("tag.delete.button")} />
</span>
);