Merged in feature/changeset_committer_extension_point (pull request #182)

Redesign changeset list and added extension point for changeset author
This commit is contained in:
Philipp Czora
2019-02-07 11:04:13 +00:00
22 changed files with 400 additions and 216 deletions

View File

@@ -27,7 +27,7 @@ public interface HalAppender {
LinkArrayBuilder linkArrayBuilder(String rel);
/**
* Appends one embedded to the json response.
* Appends one embedded object to the json response.
*
* @param rel name of relation
* @param embeddedItem embedded object
@@ -40,7 +40,7 @@ public interface HalAppender {
interface LinkArrayBuilder {
/**
* Append an link to the array.
* Append a link to the array.
*
* @param name name of link
* @param href link target

View File

@@ -2,31 +2,40 @@
import React from "react";
import moment from "moment";
import { translate } from "react-i18next";
import injectSheet from "react-jss";
const styles = {
date: {
borderBottom: "1px dotted rgba(219, 219, 219)",
cursor: "help"
}
};
type Props = {
date?: string,
// context props
classes: any,
i18n: any
};
class DateFromNow extends React.Component<Props> {
static format(locale: string, date?: string) {
let fromNow = "";
if (date) {
fromNow = moment(date)
.locale(locale)
.fromNow();
}
return fromNow;
}
render() {
const { i18n, date } = this.props;
const { i18n, date, classes } = this.props;
const fromNow = DateFromNow.format(i18n.language, date);
return <span>{fromNow}</span>;
if (date) {
const dateWithLocale = moment(date).locale(i18n.language);
return (
<time title={dateWithLocale.format()} className={classes.date}>
{dateWithLocale.fromNow()}
</time>
);
}
return null;
}
}
export default translate()(DateFromNow);
export default injectSheet(styles)(translate()(DateFromNow));

View File

@@ -4,21 +4,27 @@ import classNames from "classnames";
type Props = {
message: string,
className: string,
className?: string,
location: string,
children: React.Node
};
class Tooltip extends React.Component<Props> {
static defaultProps = {
location: "right"
};
render() {
const { className, message, children } = this.props;
const { className, message, location, children } = this.props;
const multiline = message.length > 60 ? "is-tooltip-multiline" : "";
return (
<div
className={classNames("tooltip", "is-tooltip-right", multiline, className)}
<span
className={classNames("tooltip", "is-tooltip-" + location, multiline, className)}
data-tooltip={message}
>
{children}
</div>
</span>
);
}
}

View File

@@ -1,16 +1,17 @@
//@flow
import React from "react";
import * as React from "react";
import classNames from "classnames";
import { withRouter } from "react-router-dom";
export type ButtonProps = {
label: string,
label?: string,
loading?: boolean,
disabled?: boolean,
action?: (event: Event) => void,
link?: string,
fullWidth?: boolean,
className?: string,
children?: React.Node,
classes: any
};
@@ -45,6 +46,7 @@ class Button extends React.Component<Props> {
type,
color,
fullWidth,
children,
className
} = this.props;
const loadingClass = loading ? "is-loading" : "";
@@ -62,7 +64,7 @@ class Button extends React.Component<Props> {
className
)}
>
{label}
{label} {children}
</button>
);
};

View File

@@ -1,41 +1,30 @@
// @flow
import React from "react";
import Button from "./Button";
import * as React from "react";
type Props = {
firstlabel: string,
secondlabel: string,
firstAction?: (event: Event) => void,
secondAction?: (event: Event) => void,
firstIsSelected: boolean
addons?: boolean,
className?: string,
children: React.Node
};
class ButtonGroup extends React.Component<Props> {
static defaultProps = {
addons: true
};
render() {
const { firstlabel, secondlabel, firstAction, secondAction, firstIsSelected } = this.props;
let showFirstColor = "";
let showSecondColor = "";
if (firstIsSelected) {
showFirstColor += "link is-selected";
} else {
showSecondColor += "link is-selected";
const { addons, className, children } = this.props;
let styleClasses = "buttons";
if (addons) {
styleClasses += " has-addons";
}
if (className) {
styleClasses += " " + className;
}
return (
<div className="buttons has-addons">
<Button
label={firstlabel}
color={showFirstColor}
action={firstAction}
/>
<Button
label={secondlabel}
color={showSecondColor}
action={secondAction}
/>
<div className={styleClasses}>
{ children }
</div>
);
}

View File

@@ -1,9 +1,14 @@
//@flow
import React from "react";
import type {Changeset} from "@scm-manager/ui-types";
import type { Changeset } from "@scm-manager/ui-types";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import {translate} from "react-i18next";
type Props = {
changeset: Changeset
changeset: Changeset,
// context props
t: (string) => string
};
class ChangesetAuthor extends React.Component<Props> {
@@ -13,26 +18,35 @@ class ChangesetAuthor extends React.Component<Props> {
return null;
}
const { name } = changeset.author;
const { name, mail } = changeset.author;
if (mail) {
return this.withExtensionPoint(this.renderWithMail(name, mail));
}
return this.withExtensionPoint(<>{name}</>);
}
renderWithMail(name: string, mail: string) {
const { t } = this.props;
return (
<>
{name} {this.renderMail()}
</>
<a href={"mailto: " + mail} title={t("changeset.author.mailto") + " " + mail}>
{name}
</a>
);
}
renderMail() {
const { mail } = this.props.changeset.author;
if (mail) {
return (
<a className="is-hidden-mobile" href={"mailto:" + mail}>
&lt;
{mail}
&gt;
</a>
);
}
withExtensionPoint(child: any) {
const { t } = this.props;
return (
<>
{t("changeset.author.prefix")} {child}
<ExtensionPoint
name="changesets.author.suffix"
props={{ changeset: this.props.changeset }}
renderAll={true}
/>
</>
);
}
}
export default ChangesetAuthor;
export default translate("repos")(ChangesetAuthor);

View File

@@ -0,0 +1,49 @@
//@flow
import React from "react";
import type { Changeset, Repository } from "@scm-manager/ui-types";
import ButtonGroup from "../../buttons/ButtonGroup";
import Button from "../../buttons/Button";
import { createChangesetLink, createSourcesLink } from "./changesets";
import { translate } from "react-i18next";
type Props = {
repository: Repository,
changeset: Changeset,
// context props
t: (string) => string
}
class ChangesetButtonGroup extends React.Component<Props> {
render() {
const { repository, changeset, t } = this.props;
const changesetLink = createChangesetLink(repository, changeset);
const sourcesLink = createSourcesLink(repository, changeset);
return (
<ButtonGroup className="is-pulled-right">
<Button link={changesetLink}>
<span className="icon">
<i className="fas fa-code-branch"></i>
</span>
<span className="is-hidden-mobile is-hidden-tablet-only">
{t("changeset.buttons.details")}
</span>
</Button>
<Button link={sourcesLink}>
<span className="icon">
<i className="fas fa-code"></i>
</span>
<span className="is-hidden-mobile is-hidden-tablet-only">
{t("changeset.buttons.sources")}
</span>
</Button>
</ButtonGroup>
);
}
}
export default translate("repos")(ChangesetButtonGroup);

View File

@@ -25,7 +25,7 @@ class ChangesetDiff extends React.Component<Props> {
render() {
const { changeset, t } = this.props;
if (!this.isDiffSupported(changeset)) {
return <Notification type="danger">{t("changesets.changeset.diffNotSupported")}</Notification>;
return <Notification type="danger">{t("changeset.diffNotSupported")}</Notification>;
} else {
const url = this.createUrl(changeset);
return <LoadingDiff url={url} />;

View File

@@ -3,6 +3,7 @@
import {Link} from "react-router-dom";
import React from "react";
import type {Changeset, Repository} from "@scm-manager/ui-types";
import { createChangesetLink } from "./changesets";
type Props = {
repository: Repository,
@@ -20,13 +21,11 @@ export default class ChangesetId extends React.Component<Props> {
};
renderLink = () => {
const { changeset, repository } = this.props;
const { repository, changeset } = this.props;
const link = createChangesetLink(repository, changeset);
return (
<Link
to={`/repo/${repository.namespace}/${repository.name}/changeset/${
changeset.id
}`}
>
<Link to={link}>
{this.shortId(changeset)}
</Link>
);

View File

@@ -8,21 +8,39 @@ import ChangesetId from "./ChangesetId";
import injectSheet from "react-jss";
import { DateFromNow } from "../..";
import ChangesetAuthor from "./ChangesetAuthor";
import ChangesetTag from "./ChangesetTag";
import { parseDescription } from "./changesets";
import { AvatarWrapper, AvatarImage } from "../../avatar";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import ChangesetTags from "./ChangesetTags";
import ChangesetButtonGroup from "./ChangesetButtonGroup";
const styles = {
pointer: {
cursor: "pointer"
changeset: {
// & references parent rule
// have a look at https://cssinjs.org/jss-plugin-nested?v=v10.0.0-alpha.9
"& + &": {
borderTop: "1px solid rgba(219, 219, 219, 0.5)",
marginTop: "1rem",
paddingTop: "1rem"
}
},
changesetGroup: {
marginBottom: "1em"
avatarFigure: {
marginTop: ".25rem",
marginRight: ".5rem",
},
withOverflow: {
overflow: "auto"
avatarImage: {
height: "35px",
width: "35px"
},
isVcentered: {
marginTop: "auto",
marginBottom: "auto"
},
metadata: {
marginLeft: 0
},
tag: {
marginTop: ".5rem"
}
};
@@ -34,74 +52,70 @@ type Props = {
};
class ChangesetRow extends React.Component<Props> {
createLink = (changeset: Changeset) => {
createChangesetId = (changeset: Changeset) => {
const { repository } = this.props;
return <ChangesetId changeset={changeset} repository={repository} />;
};
getTags = () => {
const { changeset } = this.props;
return changeset._embedded.tags || [];
};
render() {
const { changeset, classes } = this.props;
const changesetLink = this.createLink(changeset);
const dateFromNow = <DateFromNow date={changeset.date} />;
const authorLine = <ChangesetAuthor changeset={changeset} />;
const { repository, changeset, classes } = this.props;
const description = parseDescription(changeset.description);
const changesetId = this.createChangesetId(changeset);
const dateFromNow = <DateFromNow date={changeset.date} />;
return (
<article className={classNames("media", classes.inner)}>
<AvatarWrapper>
<div>
<figure className="media-left">
<p className="image is-64x64">
<AvatarImage person={changeset.author} />
</p>
</figure>
<div className={classes.changeset}>
<div className="columns">
<div className="column is-three-fifths">
<h4 className="has-text-weight-bold is-ellipsis-overflow">
<ExtensionPoint
name="changeset.description"
props={{ changeset, value: description.title }}
renderAll={false}
>
{description.title}
</ExtensionPoint>
</h4>
<div className="media">
<AvatarWrapper>
<figure className={classNames(classes.avatarFigure, "media-left")}>
<div className={classNames("image", classes.avatarImage)}>
<AvatarImage person={changeset.author} />
</div>
</figure>
</AvatarWrapper>
<div className={classNames(classes.metadata, "media-right")}>
<p className="is-hidden-mobile is-hidden-tablet-only">
<Interpolate
i18nKey="changeset.summary"
id={changesetId}
time={dateFromNow}
/>
</p>
<p className="is-hidden-desktop">
<Interpolate
i18nKey="changeset.shortSummary"
id={changesetId}
time={dateFromNow}
/>
</p>
<p className="is-size-7">
<ChangesetAuthor changeset={changeset} />
</p>
</div>
</div>
</div>
</AvatarWrapper>
<div className={classNames("media-content", classes.withOverflow)}>
<div className="content">
<p className="is-ellipsis-overflow">
<strong>
<ExtensionPoint
name="changesets.changeset.description"
props={{ changeset, value: description.title }}
renderAll={false}
>
{description.title}
</ExtensionPoint>
</strong>
<br />
<Interpolate
i18nKey="changesets.changeset.summary"
id={changesetLink}
time={dateFromNow}
/>
</p>{" "}
<div className="is-size-7">{authorLine}</div>
<div className={classNames("column", classes.isVcentered)}>
<ChangesetTags changeset={changeset} />
<ChangesetButtonGroup repository={repository} changeset={changeset} />
</div>
</div>
{this.renderTags()}
</article>
</div>
);
}
renderTags = () => {
const tags = this.getTags();
if (tags.length > 0) {
return (
<div className="media-right">
{tags.map((tag: Tag) => {
return <ChangesetTag key={tag.name} tag={tag} />;
})}
</div>
);
}
return null;
};
}
export default injectSheet(styles)(translate("repos")(ChangesetRow));

View File

@@ -1,32 +1,17 @@
//@flow
import React from "react";
import type { Tag } from "@scm-manager/ui-types";
import injectSheet from "react-jss";
import classNames from "classnames";
const styles = {
spacing: {
marginRight: "4px"
}
};
import ChangesetTagBase from "./ChangesetTagBase";
type Props = {
tag: Tag,
// context props
classes: Object
tag: Tag
};
class ChangesetTag extends React.Component<Props> {
render() {
const { tag, classes } = this.props;
return (
<span className="tag is-info">
<span className={classNames("fa", "fa-tag", classes.spacing)} />{" "}
{tag.name}
</span>
);
const { tag } = this.props;
return <ChangesetTagBase icon={"fa-tag"} label={tag.name} />;
}
}
export default injectSheet(styles)(ChangesetTag);
export default ChangesetTag;

View File

@@ -0,0 +1,34 @@
//@flow
import React from "react";
import injectSheet from "react-jss";
import classNames from "classnames";
const styles = {
tag: {
marginTop: ".5rem"
},
spacing: {
marginRight: ".25rem"
}
};
type Props = {
icon: string,
label: string,
// context props
classes: Object
};
class ChangesetTagBase extends React.Component<Props> {
render() {
const { icon, label, classes } = this.props;
return (
<span className={classNames(classes.tag, "tag", "is-info")}>
<span className={classNames("fa", icon, classes.spacing)} /> {label}
</span>
);
}
}
export default injectSheet(styles)(ChangesetTagBase);

View File

@@ -0,0 +1,31 @@
//@flow
import React from "react";
import type { Changeset} from "@scm-manager/ui-types";
import ChangesetTag from "./ChangesetTag";
import ChangesetTagsCollapsed from "./ChangesetTagsCollapsed";
type Props = {
changeset: Changeset
};
class ChangesetTags extends React.Component<Props> {
getTags = () => {
const { changeset } = this.props;
return changeset._embedded.tags || [];
};
render() {
const tags = this.getTags();
if (tags.length === 1) {
return <ChangesetTag tag={tags[0]} />;
} else if (tags.length > 1) {
return <ChangesetTagsCollapsed tags={tags} />;
} else {
return null;
}
}
}
export default ChangesetTags;

View File

@@ -0,0 +1,27 @@
//@flow
import React from "react";
import type { Tag } from "@scm-manager/ui-types";
import ChangesetTagBase from "./ChangesetTagBase";
import { translate } from "react-i18next";
import Tooltip from "../../Tooltip";
type Props = {
tags: Tag[],
// context props
t: (string) => string
};
class ChangesetTagsCollapsed extends React.Component<Props> {
render() {
const { tags, t } = this.props;
const message = tags.map((tag) => tag.name).join(", ");
return (
<Tooltip location="top" message={message}>
<ChangesetTagBase icon={"fa-tags"} label={ tags.length + " " + t("changeset.tags") } />
</Tooltip>
);
}
}
export default translate("repos")(ChangesetTagsCollapsed);

View File

@@ -1,9 +1,19 @@
// @flow
import type { Changeset, Repository } from "@scm-manager/ui-types";
export type Description = {
title: string,
message: string
};
export function createChangesetLink(repository: Repository, changeset: Changeset) {
return `/repo/${repository.namespace}/${repository.name}/changeset/${changeset.id}`;
}
export function createSourcesLink(repository: Repository, changeset: Changeset) {
return `/repo/${repository.namespace}/${repository.name}/sources/${changeset.id}`;
}
export function parseDescription(description?: string): Description {
const desc = description ? description : "";
const lineBreak = desc.indexOf("\n");

View File

@@ -3,8 +3,11 @@ import * as changesets from "./changesets";
export { changesets };
export { default as ChangesetAuthor } from "./ChangesetAuthor";
export { default as ChangesetButtonGroup } from "./ChangesetButtonGroup";
export { default as ChangesetDiff } from "./ChangesetDiff";
export { default as ChangesetId } from "./ChangesetId";
export { default as ChangesetList } from "./ChangesetList";
export { default as ChangesetRow } from "./ChangesetRow";
export { default as ChangesetTag } from "./ChangesetTag";
export { default as ChangesetDiff } from "./ChangesetDiff";
export { default as ChangesetTags } from "./ChangesetTags";
export { default as ChangesetTagsCollapsed } from "./ChangesetTagsCollapsed";

View File

@@ -17,6 +17,7 @@
"i18next": "^11.4.0",
"i18next-browser-languagedetector": "^2.2.2",
"i18next-fetch-backend": "^0.1.0",
"jss-nested": "^6.0.1",
"moment": "^2.22.2",
"node-sass": "^4.9.3",
"postcss-easy-import": "^3.0.0",

View File

@@ -42,18 +42,21 @@
"changesets": {
"errorTitle": "Fehler",
"errorSubtitle": "Changesets konnten nicht abgerufen werden",
"branchSelectorLabel": "Branches",
"changeset": {
"description": "Beschreibung",
"summary": "Changeset {{id}} wurde committet {{time}}",
"diffNotSupported": "Diff des Changesets wird von diesem Repositorytyp nicht unterstützt",
"id": "ID",
"contact": "Kontakt",
"date": "Datum"
},
"branchSelectorLabel": "Branches"
},
"changeset": {
"description": "Beschreibung",
"summary": "Changeset {{id}} wurde committet {{time}}",
"shortSummary": "Committet {{id}} {{time}}",
"tags": "Tags",
"diffNotSupported": "Diff des Changesets wird von diesem Repositorytyp nicht unterstützt",
"author": {
"name": "Autor",
"mail": "Mail"
"prefix": "Verfasst von",
"mailto": "Mail senden an"
},
"buttons": {
"details": "Details",
"sources": "Sources"
}
},
"repositoryForm": {

View File

@@ -42,18 +42,21 @@
"changesets": {
"errorTitle": "Error",
"errorSubtitle": "Could not fetch changesets",
"branchSelectorLabel": "Branches",
"changeset": {
"description": "Description",
"summary": "Changeset {{id}} was committed {{time}}",
"diffNotSupported": "Diff of changesets is not supported by the type of repository",
"id": "ID",
"contact": "Contact",
"date": "Date"
},
"branchSelectorLabel": "Branches"
},
"changeset": {
"description": "Description",
"summary": "Changeset {{id}} was committed {{time}}",
"shortSummary": "Committed {{id}} {{time}}",
"tags": "Tags",
"diffNotSupported": "Diff of changesets is not supported by the type of repository",
"author": {
"name": "Author",
"mail": "Mail"
"prefix": "Authored by",
"mailto": "Send mail to"
},
"buttons": {
"details": "Details",
"sources": "Sources"
}
},
"repositoryForm": {

View File

@@ -17,6 +17,12 @@ import { ConnectedRouter } from "react-router-redux";
import { urls } from "@scm-manager/ui-components";
import jss from "jss";
import jssNested from "jss-nested";
// setup jss and install required plugins
jss.setup(jssNested());
// Create a history of your choosing (we're using a browser history in this case)
const history: BrowserHistory = createHistory({
basename: urls.contextPath

View File

@@ -22,6 +22,11 @@ import { ExtensionPoint } from "@scm-manager/ui-extensions";
const styles = {
spacing: {
marginRight: "1em"
},
tags: {
"& .tag": {
marginLeft: ".25rem"
}
}
};
@@ -48,7 +53,7 @@ class ChangesetDetails extends React.Component<Props> {
<div className="content">
<h4>
<ExtensionPoint
name="changesets.changeset.description"
name="changeset.description"
props={{ changeset, value: description.title }}
renderAll={false}
>
@@ -67,7 +72,7 @@ class ChangesetDetails extends React.Component<Props> {
</p>
<p>
<Interpolate
i18nKey="changesets.changeset.summary"
i18nKey="changeset.summary"
id={id}
time={date}
/>
@@ -81,7 +86,7 @@ class ChangesetDetails extends React.Component<Props> {
return (
<span key={key}>
<ExtensionPoint
name="changesets.changeset.description"
name="changeset.description"
props={{ changeset, value: item }}
renderAll={false}
>
@@ -106,10 +111,11 @@ class ChangesetDetails extends React.Component<Props> {
};
renderTags = () => {
const { classes } = this.props;
const tags = this.getTags();
if (tags.length > 0) {
return (
<div className="level-item">
<div className={classNames("level-item", classes.tags)}>
{tags.map((tag: Tag) => {
return <ChangesetTag key={tag.name} tag={tag} />;
})}

View File

@@ -1,7 +1,7 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { ButtonGroup } from "@scm-manager/ui-components";
import { ButtonGroup, Button } from "@scm-manager/ui-components";
type Props = {
t: string => string,
@@ -18,39 +18,32 @@ class FileButtonGroup extends React.Component<Props> {
this.props.showHistory(false);
};
color = (selected: boolean) => {
return selected ? "link is-selected" : null;
};
render() {
const { t, historyIsSelected } = this.props;
const sourcesLabel = (
<>
<span className="icon">
<i className="fas fa-code" />
</span>
<span className="is-hidden-mobile">
{t("sources.content.sourcesButton")}
</span>
</>
);
const historyLabel = (
<>
<span className="icon">
<i className="fas fa-history" />
</span>
<span className="is-hidden-mobile">
{t("sources.content.historyButton")}
</span>
</>
);
return (
<ButtonGroup
firstlabel={sourcesLabel}
secondlabel={historyLabel}
firstAction={this.showSources}
secondAction={this.showHistory}
firstIsSelected={!historyIsSelected}
/>
<ButtonGroup>
<Button action={this.showSources} color={ this.color(!historyIsSelected) }>
<span className="icon">
<i className="fas fa-code"/>
</span>
<span className="is-hidden-mobile">
{t("sources.content.sourcesButton")}
</span>
</Button>
<Button action={this.showHistory} color={ this.color(historyIsSelected) }>
<span className="icon">
<i className="fas fa-history"/>
</span>
<span className="is-hidden-mobile">
{t("sources.content.historyButton")}
</span>
</Button>
</ButtonGroup>
);
}
}