mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-15 09:46:16 +01:00
scm-ui: new repository layout
This commit is contained in:
27
scm-ui/ui-components/src/repos/Diff.js
Normal file
27
scm-ui/ui-components/src/repos/Diff.js
Normal file
@@ -0,0 +1,27 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import DiffFile from "./DiffFile";
|
||||
import type {DiffObjectProps, File} from "./DiffTypes";
|
||||
|
||||
type Props = DiffObjectProps & {
|
||||
diff: File[]
|
||||
};
|
||||
|
||||
class Diff extends React.Component<Props> {
|
||||
static defaultProps = {
|
||||
sideBySide: false
|
||||
};
|
||||
|
||||
render() {
|
||||
const { diff, ...fileProps } = this.props;
|
||||
return (
|
||||
<>
|
||||
{diff.map((file, index) => (
|
||||
<DiffFile key={index} file={file} {...fileProps} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Diff;
|
||||
313
scm-ui/ui-components/src/repos/DiffFile.js
Normal file
313
scm-ui/ui-components/src/repos/DiffFile.js
Normal file
@@ -0,0 +1,313 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import {
|
||||
Change,
|
||||
Diff as DiffComponent,
|
||||
DiffObjectProps,
|
||||
File,
|
||||
getChangeKey,
|
||||
Hunk
|
||||
} from "react-diff-view";
|
||||
import injectSheets from "react-jss";
|
||||
import classNames from "classnames";
|
||||
import { translate } from "react-i18next";
|
||||
import { Button, ButtonGroup } from "../buttons";
|
||||
import Tag from "../Tag";
|
||||
|
||||
const styles = {
|
||||
panel: {
|
||||
fontSize: "1rem"
|
||||
},
|
||||
/* breaks into a second row
|
||||
when buttons and title become too long */
|
||||
level: {
|
||||
flexWrap: "wrap"
|
||||
},
|
||||
titleHeader: {
|
||||
display: "flex",
|
||||
maxWidth: "100%",
|
||||
cursor: "pointer"
|
||||
},
|
||||
title: {
|
||||
marginLeft: ".25rem",
|
||||
fontSize: "1rem"
|
||||
},
|
||||
/* align child to right */
|
||||
buttonHeader: {
|
||||
display: "flex",
|
||||
marginLeft: "auto"
|
||||
},
|
||||
hunkDivider: {
|
||||
margin: ".5rem 0"
|
||||
},
|
||||
changeType: {
|
||||
marginLeft: ".75rem"
|
||||
},
|
||||
diff: {
|
||||
/* column sizing */
|
||||
"& > colgroup .diff-gutter-col": {
|
||||
width: "3.25rem"
|
||||
},
|
||||
/* prevent following content from moving down */
|
||||
"& > .diff-gutter:empty:hover::after": {
|
||||
fontSize: "0.7rem"
|
||||
},
|
||||
/* smaller font size for code */
|
||||
"& .diff-line": {
|
||||
fontSize: "0.75rem"
|
||||
},
|
||||
/* comment padding for sideBySide view */
|
||||
"&.split .diff-widget-content .is-indented-line": {
|
||||
paddingLeft: "3.25rem"
|
||||
},
|
||||
/* comment padding for combined view */
|
||||
"&.unified .diff-widget-content .is-indented-line": {
|
||||
paddingLeft: "6.5rem"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
type Props = DiffObjectProps & {
|
||||
file: File,
|
||||
collapsible: true,
|
||||
|
||||
// context props
|
||||
classes: any,
|
||||
t: string => string
|
||||
};
|
||||
|
||||
type State = {
|
||||
collapsed: boolean,
|
||||
sideBySide: boolean
|
||||
};
|
||||
|
||||
class DiffFile extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
collapsed: false,
|
||||
sideBySide: false
|
||||
};
|
||||
}
|
||||
|
||||
toggleCollapse = () => {
|
||||
if (this.props.collapsable) {
|
||||
this.setState(state => ({
|
||||
collapsed: !state.collapsed
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
toggleSideBySide = () => {
|
||||
this.setState(state => ({
|
||||
sideBySide: !state.sideBySide
|
||||
}));
|
||||
};
|
||||
|
||||
setCollapse = (collapsed: boolean) => {
|
||||
this.setState({
|
||||
collapsed
|
||||
});
|
||||
};
|
||||
|
||||
createHunkHeader = (hunk: Hunk, i: number) => {
|
||||
const { classes } = this.props;
|
||||
if (i > 0) {
|
||||
return <hr className={classes.hunkDivider} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
collectHunkAnnotations = (hunk: Hunk) => {
|
||||
const { annotationFactory, file } = this.props;
|
||||
if (annotationFactory) {
|
||||
return annotationFactory({
|
||||
hunk,
|
||||
file
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleClickEvent = (change: Change, hunk: Hunk) => {
|
||||
const { file, onClick } = this.props;
|
||||
const context = {
|
||||
changeId: getChangeKey(change),
|
||||
change,
|
||||
hunk,
|
||||
file
|
||||
};
|
||||
if (onClick) {
|
||||
onClick(context);
|
||||
}
|
||||
};
|
||||
|
||||
createCustomEvents = (hunk: Hunk) => {
|
||||
const { onClick } = this.props;
|
||||
if (onClick) {
|
||||
return {
|
||||
gutter: {
|
||||
onClick: (change: Change) => {
|
||||
this.handleClickEvent(change, hunk);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
renderHunk = (hunk: Hunk, i: number) => {
|
||||
return (
|
||||
<Hunk
|
||||
key={hunk.content}
|
||||
hunk={hunk}
|
||||
header={this.createHunkHeader(hunk, i)}
|
||||
widgets={this.collectHunkAnnotations(hunk)}
|
||||
customEvents={this.createCustomEvents(hunk)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderFileTitle = (file: any) => {
|
||||
if (
|
||||
file.oldPath !== file.newPath &&
|
||||
(file.type === "copy" || file.type === "rename")
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{file.oldPath} <i className="fa fa-arrow-right" /> {file.newPath}
|
||||
</>
|
||||
);
|
||||
} else if (file.type === "delete") {
|
||||
return file.oldPath;
|
||||
}
|
||||
return file.newPath;
|
||||
};
|
||||
|
||||
hoverFileTitle = (file: any) => {
|
||||
if (
|
||||
file.oldPath !== file.newPath &&
|
||||
(file.type === "copy" || file.type === "rename")
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{file.oldPath} > {file.newPath}
|
||||
</>
|
||||
);
|
||||
} else if (file.type === "delete") {
|
||||
return file.oldPath;
|
||||
}
|
||||
return file.newPath;
|
||||
};
|
||||
|
||||
renderChangeTag = (file: any) => {
|
||||
const { t, classes } = this.props;
|
||||
if (!file.type) {
|
||||
return;
|
||||
}
|
||||
const key = "diff.changes." + file.type;
|
||||
let value = t(key);
|
||||
if (key === value) {
|
||||
value = file.type;
|
||||
}
|
||||
const color =
|
||||
value === "added"
|
||||
? "success is-outlined"
|
||||
: value === "deleted"
|
||||
? "danger is-outlined"
|
||||
: "info is-outlined";
|
||||
|
||||
return (
|
||||
<Tag
|
||||
className={classNames(
|
||||
"is-rounded",
|
||||
"has-text-weight-normal",
|
||||
classes.changeType
|
||||
)}
|
||||
color={color}
|
||||
label={value}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
file,
|
||||
fileControlFactory,
|
||||
fileAnnotationFactory,
|
||||
collapsible,
|
||||
classes,
|
||||
t
|
||||
} = this.props;
|
||||
const { collapsed, sideBySide } = this.state;
|
||||
const viewType = sideBySide ? "split" : "unified";
|
||||
|
||||
let body = null;
|
||||
let icon = "fa fa-angle-right";
|
||||
if (!collapsed) {
|
||||
const fileAnnotations = fileAnnotationFactory
|
||||
? fileAnnotationFactory(file)
|
||||
: null;
|
||||
icon = "fa fa-angle-down";
|
||||
body = (
|
||||
<div className="panel-block is-paddingless">
|
||||
{fileAnnotations}
|
||||
<DiffComponent
|
||||
className={classNames(viewType, classes.diff)}
|
||||
viewType={viewType}
|
||||
>
|
||||
{file.hunks.map(this.renderHunk)}
|
||||
</DiffComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const collapseIcon = collapsible ? <i className={icon} /> : null;
|
||||
|
||||
const fileControls = fileControlFactory
|
||||
? fileControlFactory(file, this.setCollapse)
|
||||
: null;
|
||||
return (
|
||||
<div className={classNames("panel", classes.panel)}>
|
||||
<div className="panel-heading">
|
||||
<div className={classNames("level", classes.level)}>
|
||||
<div
|
||||
className={classNames("level-left", classes.titleHeader)}
|
||||
onClick={this.toggleCollapse}
|
||||
title={this.hoverFileTitle(file)}
|
||||
>
|
||||
{collapseIcon}
|
||||
<span
|
||||
className={classNames("is-ellipsis-overflow", classes.title)}
|
||||
>
|
||||
{this.renderFileTitle(file)}
|
||||
</span>
|
||||
{this.renderChangeTag(file)}
|
||||
</div>
|
||||
<div className={classNames("level-right", classes.buttonHeader)}>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
action={this.toggleSideBySide}
|
||||
className="reduced-mobile"
|
||||
>
|
||||
<span className="icon is-small">
|
||||
<i
|
||||
className={classNames(
|
||||
"fas",
|
||||
sideBySide ? "fa-align-left" : "fa-columns"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
{t(sideBySide ? "diff.combined" : "diff.sideBySide")}
|
||||
</span>
|
||||
</Button>
|
||||
{fileControls}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectSheets(styles)(translate("repos")(DiffFile));
|
||||
74
scm-ui/ui-components/src/repos/DiffTypes.js
Normal file
74
scm-ui/ui-components/src/repos/DiffTypes.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// @flow
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
// We place the types here and not in @scm-manager/ui-types,
|
||||
// because they represent not a real scm-manager related type.
|
||||
// This types represents only the required types for the Diff related components,
|
||||
// such as every other component does with its Props.
|
||||
|
||||
export type FileChangeType = "add" | "modify" | "delete" | "copy" | "rename";
|
||||
|
||||
export type File = {
|
||||
hunks: Hunk[],
|
||||
newEndingNewLine: boolean,
|
||||
newMode?: string,
|
||||
newPath: string,
|
||||
newRevision?: string,
|
||||
oldEndingNewLine: boolean,
|
||||
oldMode?: string,
|
||||
oldPath: string,
|
||||
oldRevision?: string,
|
||||
type: FileChangeType
|
||||
};
|
||||
|
||||
export type Hunk = {
|
||||
changes: Change[],
|
||||
content: string
|
||||
};
|
||||
|
||||
export type ChangeType = "insert" | "delete" | "normal";
|
||||
|
||||
export type Change = {
|
||||
content: string,
|
||||
isNormal?: boolean,
|
||||
isInsert?: boolean,
|
||||
isDelete?: boolean,
|
||||
lineNumber?: number,
|
||||
newLineNumber?: number,
|
||||
oldLineNumber?: number,
|
||||
type: ChangeType
|
||||
};
|
||||
|
||||
export type BaseContext = {
|
||||
hunk: Hunk,
|
||||
file: File
|
||||
};
|
||||
|
||||
export type AnnotationFactoryContext = BaseContext;
|
||||
|
||||
export type FileAnnotationFactory = (file: File) => React.Node[];
|
||||
|
||||
// key = change id, value = react component
|
||||
export type AnnotationFactory = (
|
||||
context: AnnotationFactoryContext
|
||||
) => {
|
||||
[string]: any
|
||||
};
|
||||
|
||||
export type DiffEventContext = BaseContext & {
|
||||
changeId: string,
|
||||
change: Change
|
||||
};
|
||||
|
||||
export type DiffEventHandler = (context: DiffEventContext) => void;
|
||||
|
||||
export type FileControlFactory = (file: File, setCollapseState: (boolean) => void) => ?React.Node;
|
||||
|
||||
export type DiffObjectProps = {
|
||||
sideBySide: boolean,
|
||||
onClick?: DiffEventHandler,
|
||||
fileControlFactory?: FileControlFactory,
|
||||
fileAnnotationFactory?: FileAnnotationFactory,
|
||||
annotationFactory?: AnnotationFactory
|
||||
};
|
||||
81
scm-ui/ui-components/src/repos/LoadingDiff.js
Normal file
81
scm-ui/ui-components/src/repos/LoadingDiff.js
Normal file
@@ -0,0 +1,81 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import {apiClient} from "../apiclient";
|
||||
import ErrorNotification from "../ErrorNotification";
|
||||
import parser from "gitdiff-parser";
|
||||
|
||||
import Loading from "../Loading";
|
||||
import Diff from "./Diff";
|
||||
import type {DiffObjectProps, File} from "./DiffTypes";
|
||||
|
||||
type Props = DiffObjectProps & {
|
||||
url: string
|
||||
};
|
||||
|
||||
type State = {
|
||||
diff?: File[],
|
||||
loading: boolean,
|
||||
error?: Error
|
||||
};
|
||||
|
||||
class LoadingDiff extends React.Component<Props, State> {
|
||||
|
||||
static defaultProps = {
|
||||
sideBySide: false
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
loading: true
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchDiff();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if(prevProps.url !== this.props.url){
|
||||
this.fetchDiff();
|
||||
}
|
||||
}
|
||||
|
||||
fetchDiff = () => {
|
||||
const { url } = this.props;
|
||||
apiClient
|
||||
.get(url)
|
||||
.then(response => response.text())
|
||||
.then(parser.parse)
|
||||
// $FlowFixMe
|
||||
.then((diff: File[]) => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
diff: diff
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { diff, loading, error } = this.state;
|
||||
if (error) {
|
||||
return <ErrorNotification error={error} />;
|
||||
} else if (loading) {
|
||||
return <Loading />;
|
||||
} else if(!diff){
|
||||
return null;
|
||||
}
|
||||
else {
|
||||
return <Diff diff={diff} {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default LoadingDiff;
|
||||
55
scm-ui/ui-components/src/repos/changesets/ChangesetAuthor.js
Normal file
55
scm-ui/ui-components/src/repos/changesets/ChangesetAuthor.js
Normal file
@@ -0,0 +1,55 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { Changeset } from "@scm-manager/ui-types";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
import { translate } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
changeset: Changeset,
|
||||
|
||||
// context props
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class ChangesetAuthor extends React.Component<Props> {
|
||||
render() {
|
||||
const { changeset } = this.props;
|
||||
if (!changeset.author) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 (
|
||||
<a
|
||||
href={"mailto:" + mail}
|
||||
title={t("changeset.author.mailto") + " " + mail}
|
||||
>
|
||||
{name}
|
||||
</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 translate("repos")(ChangesetAuthor);
|
||||
@@ -0,0 +1,40 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { Changeset, Repository } from "@scm-manager/ui-types";
|
||||
import { ButtonAddons, Button } from "../../buttons";
|
||||
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 (
|
||||
<ButtonAddons className="is-marginless">
|
||||
<Button
|
||||
link={changesetLink}
|
||||
icon="exchange-alt"
|
||||
label={t("changeset.buttons.details")}
|
||||
reducedMobile={true}
|
||||
/>
|
||||
<Button
|
||||
link={sourcesLink}
|
||||
icon="code"
|
||||
label={t("changeset.buttons.sources")}
|
||||
reducedMobile={true}
|
||||
/>
|
||||
</ButtonAddons>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("repos")(ChangesetButtonGroup);
|
||||
37
scm-ui/ui-components/src/repos/changesets/ChangesetDiff.js
Normal file
37
scm-ui/ui-components/src/repos/changesets/ChangesetDiff.js
Normal file
@@ -0,0 +1,37 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { Changeset } from "@scm-manager/ui-types";
|
||||
import LoadingDiff from "../LoadingDiff";
|
||||
import Notification from "../../Notification";
|
||||
import {translate} from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
changeset: Changeset,
|
||||
|
||||
// context props
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class ChangesetDiff extends React.Component<Props> {
|
||||
|
||||
isDiffSupported(changeset: Changeset) {
|
||||
return !!changeset._links.diff;
|
||||
}
|
||||
|
||||
createUrl(changeset: Changeset) {
|
||||
return changeset._links.diff.href + "?format=GIT";
|
||||
}
|
||||
|
||||
render() {
|
||||
const { changeset, t } = this.props;
|
||||
if (!this.isDiffSupported(changeset)) {
|
||||
return <Notification type="danger">{t("changeset.diffNotSupported")}</Notification>;
|
||||
} else {
|
||||
const url = this.createUrl(changeset);
|
||||
return <LoadingDiff url={url} />;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default translate("repos")(ChangesetDiff);
|
||||
46
scm-ui/ui-components/src/repos/changesets/ChangesetId.js
Normal file
46
scm-ui/ui-components/src/repos/changesets/ChangesetId.js
Normal file
@@ -0,0 +1,46 @@
|
||||
//@flow
|
||||
|
||||
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,
|
||||
changeset: Changeset,
|
||||
link: boolean
|
||||
};
|
||||
|
||||
export default class ChangesetId extends React.Component<Props> {
|
||||
static defaultProps = {
|
||||
link: true
|
||||
};
|
||||
|
||||
shortId = (changeset: Changeset) => {
|
||||
return changeset.id.substr(0, 7);
|
||||
};
|
||||
|
||||
renderLink = () => {
|
||||
const { repository, changeset } = this.props;
|
||||
const link = createChangesetLink(repository, changeset);
|
||||
|
||||
return (
|
||||
<Link to={link}>
|
||||
{this.shortId(changeset)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
renderText = () => {
|
||||
const { changeset } = this.props;
|
||||
return this.shortId(changeset);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { link } = this.props;
|
||||
if (link) {
|
||||
return this.renderLink();
|
||||
}
|
||||
return this.renderText();
|
||||
}
|
||||
}
|
||||
28
scm-ui/ui-components/src/repos/changesets/ChangesetList.js
Normal file
28
scm-ui/ui-components/src/repos/changesets/ChangesetList.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// @flow
|
||||
import ChangesetRow from "./ChangesetRow";
|
||||
import React from "react";
|
||||
|
||||
import type { Changeset, Repository } from "@scm-manager/ui-types";
|
||||
|
||||
type Props = {
|
||||
repository: Repository,
|
||||
changesets: Changeset[]
|
||||
};
|
||||
|
||||
class ChangesetList extends React.Component<Props> {
|
||||
render() {
|
||||
const { repository, changesets } = this.props;
|
||||
const content = changesets.map(changeset => {
|
||||
return (
|
||||
<ChangesetRow
|
||||
key={changeset.id}
|
||||
repository={repository}
|
||||
changeset={changeset}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return <>{content}</>;
|
||||
}
|
||||
}
|
||||
|
||||
export default ChangesetList;
|
||||
138
scm-ui/ui-components/src/repos/changesets/ChangesetRow.js
Normal file
138
scm-ui/ui-components/src/repos/changesets/ChangesetRow.js
Normal file
@@ -0,0 +1,138 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { Changeset, Repository } from "@scm-manager/ui-types";
|
||||
|
||||
import classNames from "classnames";
|
||||
import { Interpolate, translate } from "react-i18next";
|
||||
import ChangesetId from "./ChangesetId";
|
||||
import injectSheet from "react-jss";
|
||||
import { DateFromNow } from "../..";
|
||||
import ChangesetAuthor from "./ChangesetAuthor";
|
||||
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 = {
|
||||
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"
|
||||
}
|
||||
},
|
||||
avatarFigure: {
|
||||
marginTop: ".5rem",
|
||||
marginRight: ".5rem"
|
||||
},
|
||||
avatarImage: {
|
||||
height: "35px",
|
||||
width: "35px"
|
||||
},
|
||||
metadata: {
|
||||
marginLeft: 0
|
||||
},
|
||||
authorMargin: {
|
||||
marginTop: "0.5rem"
|
||||
},
|
||||
isVcentered: {
|
||||
alignSelf: "center"
|
||||
},
|
||||
flexVcenter: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
repository: Repository,
|
||||
changeset: Changeset,
|
||||
t: any,
|
||||
classes: any
|
||||
};
|
||||
|
||||
class ChangesetRow extends React.Component<Props> {
|
||||
createChangesetId = (changeset: Changeset) => {
|
||||
const { repository } = this.props;
|
||||
return <ChangesetId changeset={changeset} repository={repository} />;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { repository, changeset, classes } = this.props;
|
||||
const description = parseDescription(changeset.description);
|
||||
const changesetId = this.createChangesetId(changeset);
|
||||
const dateFromNow = <DateFromNow date={changeset.date} />;
|
||||
|
||||
return (
|
||||
<div className={classes.changeset}>
|
||||
<div className="columns is-gapless is-mobile">
|
||||
<div className="column is-three-fifths">
|
||||
<div className="columns is-gapless">
|
||||
<div className="column is-four-fifths">
|
||||
<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")}>
|
||||
<h4 className="has-text-weight-bold is-ellipsis-overflow">
|
||||
<ExtensionPoint
|
||||
name="changeset.description"
|
||||
props={{ changeset, value: description.title }}
|
||||
renderAll={false}
|
||||
>
|
||||
{description.title}
|
||||
</ExtensionPoint>
|
||||
</h4>
|
||||
<p className="is-hidden-touch">
|
||||
<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={classNames("is-size-7", classes.authorMargin)}>
|
||||
<ChangesetAuthor changeset={changeset} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames("column", classes.isVcentered)}>
|
||||
<ChangesetTags changeset={changeset} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames("column", classes.flexVcenter)}>
|
||||
<ChangesetButtonGroup
|
||||
repository={repository}
|
||||
changeset={changeset}
|
||||
/>
|
||||
<ExtensionPoint
|
||||
name="changeset.right"
|
||||
props={{ repository, changeset }}
|
||||
renderAll={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(translate("repos")(ChangesetRow));
|
||||
17
scm-ui/ui-components/src/repos/changesets/ChangesetTag.js
Normal file
17
scm-ui/ui-components/src/repos/changesets/ChangesetTag.js
Normal file
@@ -0,0 +1,17 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { Tag } from "@scm-manager/ui-types";
|
||||
import ChangesetTagBase from "./ChangesetTagBase";
|
||||
|
||||
type Props = {
|
||||
tag: Tag
|
||||
};
|
||||
|
||||
class ChangesetTag extends React.Component<Props> {
|
||||
render() {
|
||||
const { tag } = this.props;
|
||||
return <ChangesetTagBase icon="tag" label={tag.name} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default ChangesetTag;
|
||||
@@ -0,0 +1,19 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import Tag from "../../Tag";
|
||||
|
||||
type Props = {
|
||||
icon: string,
|
||||
label: string
|
||||
};
|
||||
|
||||
class ChangesetTagBase extends React.Component<Props> {
|
||||
render() {
|
||||
const { icon, label } = this.props;
|
||||
return (
|
||||
<Tag color="info" icon={icon} label={label} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ChangesetTagBase;
|
||||
31
scm-ui/ui-components/src/repos/changesets/ChangesetTags.js
Normal file
31
scm-ui/ui-components/src/repos/changesets/ChangesetTags.js
Normal 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;
|
||||
@@ -0,0 +1,30 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import type { Tag } from "@scm-manager/ui-types";
|
||||
import Tooltip from "../../Tooltip";
|
||||
import ChangesetTagBase from "./ChangesetTagBase";
|
||||
|
||||
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="tags"
|
||||
label={tags.length + " " + t("changeset.tags")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("repos")(ChangesetTagsCollapsed);
|
||||
35
scm-ui/ui-components/src/repos/changesets/changesets.js
Normal file
35
scm-ui/ui-components/src/repos/changesets/changesets.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// @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");
|
||||
|
||||
let title;
|
||||
let message = "";
|
||||
|
||||
if (lineBreak > 0) {
|
||||
title = desc.substring(0, lineBreak);
|
||||
message = desc.substring(lineBreak + 1);
|
||||
} else {
|
||||
title = desc;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
message
|
||||
};
|
||||
}
|
||||
22
scm-ui/ui-components/src/repos/changesets/changesets.test.js
Normal file
22
scm-ui/ui-components/src/repos/changesets/changesets.test.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// @flow
|
||||
|
||||
import {parseDescription} from "./changesets";
|
||||
|
||||
describe("parseDescription tests", () => {
|
||||
it("should return a description with title and message", () => {
|
||||
const desc = parseDescription("Hello\nTrillian");
|
||||
expect(desc.title).toBe("Hello");
|
||||
expect(desc.message).toBe("Trillian");
|
||||
});
|
||||
|
||||
it("should return a description with title and without message", () => {
|
||||
const desc = parseDescription("Hello Trillian");
|
||||
expect(desc.title).toBe("Hello Trillian");
|
||||
});
|
||||
|
||||
it("should return an empty description for undefined", () => {
|
||||
const desc = parseDescription();
|
||||
expect(desc.title).toBe("");
|
||||
expect(desc.message).toBe("");
|
||||
});
|
||||
});
|
||||
13
scm-ui/ui-components/src/repos/changesets/index.js
Normal file
13
scm-ui/ui-components/src/repos/changesets/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
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 ChangesetTags } from "./ChangesetTags";
|
||||
export { default as ChangesetTagsCollapsed } from "./ChangesetTagsCollapsed";
|
||||
18
scm-ui/ui-components/src/repos/diffs.js
Normal file
18
scm-ui/ui-components/src/repos/diffs.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// @flow
|
||||
import type { BaseContext, File, Hunk } from "./DiffTypes";
|
||||
|
||||
export function getPath(file: File) {
|
||||
if (file.type === "delete") {
|
||||
return file.oldPath;
|
||||
}
|
||||
return file.newPath;
|
||||
}
|
||||
|
||||
export function createHunkIdentifier(file: File, hunk: Hunk) {
|
||||
const path = getPath(file);
|
||||
return `${file.type}_${path}_${hunk.content}`;
|
||||
}
|
||||
|
||||
export function createHunkIdentifierFromContext(ctx: BaseContext) {
|
||||
return createHunkIdentifier(ctx.file, ctx.hunk);
|
||||
}
|
||||
76
scm-ui/ui-components/src/repos/diffs.test.js
Normal file
76
scm-ui/ui-components/src/repos/diffs.test.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// @flow
|
||||
import type { File, FileChangeType, Hunk } from "./DiffTypes";
|
||||
import {
|
||||
getPath,
|
||||
createHunkIdentifier,
|
||||
createHunkIdentifierFromContext
|
||||
} from "./diffs";
|
||||
|
||||
describe("tests for diff util functions", () => {
|
||||
const file = (
|
||||
type: FileChangeType,
|
||||
oldPath: string,
|
||||
newPath: string
|
||||
): File => {
|
||||
return {
|
||||
hunks: [],
|
||||
type: type,
|
||||
oldPath,
|
||||
newPath,
|
||||
newEndingNewLine: true,
|
||||
oldEndingNewLine: true
|
||||
};
|
||||
};
|
||||
|
||||
const add = (path: string) => {
|
||||
return file("add", "/dev/null", path);
|
||||
};
|
||||
|
||||
const rm = (path: string) => {
|
||||
return file("delete", path, "/dev/null");
|
||||
};
|
||||
|
||||
const modify = (path: string) => {
|
||||
return file("modify", path, path);
|
||||
};
|
||||
|
||||
const createHunk = (content: string): Hunk => {
|
||||
return {
|
||||
content,
|
||||
changes: []
|
||||
};
|
||||
};
|
||||
|
||||
describe("getPath tests", () => {
|
||||
it("should pick the new path, for type add", () => {
|
||||
const file = add("/etc/passwd");
|
||||
const path = getPath(file);
|
||||
expect(path).toBe("/etc/passwd");
|
||||
});
|
||||
|
||||
it("should pick the old path, for type delete", () => {
|
||||
const file = rm("/etc/passwd");
|
||||
const path = getPath(file);
|
||||
expect(path).toBe("/etc/passwd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHunkIdentifier tests", () => {
|
||||
it("should create identifier", () => {
|
||||
const file = modify("/etc/passwd");
|
||||
const hunk = createHunk("@@ -1,18 +1,15 @@");
|
||||
const identifier = createHunkIdentifier(file, hunk);
|
||||
expect(identifier).toBe("modify_/etc/passwd_@@ -1,18 +1,15 @@");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHunkIdentifierFromContext tests", () => {
|
||||
it("should create identifier", () => {
|
||||
const identifier = createHunkIdentifierFromContext({
|
||||
file: rm("/etc/passwd"),
|
||||
hunk: createHunk("@@ -1,42 +1,39 @@")
|
||||
});
|
||||
expect(identifier).toBe("delete_/etc/passwd_@@ -1,42 +1,39 @@");
|
||||
});
|
||||
});
|
||||
});
|
||||
23
scm-ui/ui-components/src/repos/index.js
Normal file
23
scm-ui/ui-components/src/repos/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// @flow
|
||||
import * as diffs from "./diffs";
|
||||
|
||||
export { diffs };
|
||||
|
||||
export * from "./changesets";
|
||||
|
||||
export { default as Diff } from "./Diff";
|
||||
export { default as DiffFile } from "./DiffFile";
|
||||
export { default as LoadingDiff } from "./LoadingDiff";
|
||||
|
||||
export type {
|
||||
File,
|
||||
FileChangeType,
|
||||
Hunk,
|
||||
Change,
|
||||
ChangeType,
|
||||
BaseContext,
|
||||
AnnotationFactory,
|
||||
AnnotationFactoryContext,
|
||||
DiffEventHandler,
|
||||
DiffEventContext
|
||||
} from "./DiffTypes";
|
||||
Reference in New Issue
Block a user