mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-06 21:45:43 +01:00
Merge with 2.0.0-m3
This commit is contained in:
73
scm-ui-components/packages/ui-components/src/Autocomplete.js
Normal file
73
scm-ui-components/packages/ui-components/src/Autocomplete.js
Normal file
@@ -0,0 +1,73 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { AsyncCreatable } from "react-select";
|
||||
import type { AutocompleteObject, SelectValue } from "@scm-manager/ui-types";
|
||||
import LabelWithHelpIcon from "./forms/LabelWithHelpIcon";
|
||||
|
||||
|
||||
type Props = {
|
||||
loadSuggestions: string => Promise<AutocompleteObject>,
|
||||
valueSelected: SelectValue => void,
|
||||
label: string,
|
||||
helpText?: string,
|
||||
value?: SelectValue,
|
||||
placeholder: string,
|
||||
loadingMessage: string,
|
||||
noOptionsMessage: string
|
||||
};
|
||||
|
||||
|
||||
type State = {};
|
||||
|
||||
class Autocomplete extends React.Component<Props, State> {
|
||||
|
||||
|
||||
static defaultProps = {
|
||||
placeholder: "Type here",
|
||||
loadingMessage: "Loading...",
|
||||
noOptionsMessage: "No suggestion available"
|
||||
};
|
||||
|
||||
handleInputChange = (newValue: SelectValue) => {
|
||||
this.props.valueSelected(newValue);
|
||||
};
|
||||
|
||||
// We overwrite this to avoid running into a bug (https://github.com/JedWatson/react-select/issues/2944)
|
||||
isValidNewOption = (inputValue: string, selectValue: SelectValue, selectOptions: SelectValue[]) => {
|
||||
const isNotDuplicated = !selectOptions
|
||||
.map(option => option.label)
|
||||
.includes(inputValue);
|
||||
const isNotEmpty = inputValue !== "";
|
||||
return isNotEmpty && isNotDuplicated;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { label, helpText, value, placeholder, loadingMessage, noOptionsMessage, loadSuggestions } = this.props;
|
||||
return (
|
||||
<div className="field">
|
||||
<LabelWithHelpIcon label={label} helpText={helpText} />
|
||||
<div className="control">
|
||||
<AsyncCreatable
|
||||
cacheOptions
|
||||
loadOptions={loadSuggestions}
|
||||
onChange={this.handleInputChange}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
loadingMessage={() => loadingMessage}
|
||||
noOptionsMessage={() => noOptionsMessage}
|
||||
isValidNewOption={this.isValidNewOption}
|
||||
onCreateOption={value => {
|
||||
this.handleInputChange({
|
||||
label: value,
|
||||
value: { id: value, displayName: value }
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default Autocomplete;
|
||||
@@ -2,6 +2,7 @@
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import Notification from "./Notification";
|
||||
import {UNAUTHORIZED_ERROR} from "./apiclient";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
@@ -9,16 +10,27 @@ type Props = {
|
||||
};
|
||||
|
||||
class ErrorNotification extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
const { t, error } = this.props;
|
||||
if (error) {
|
||||
return (
|
||||
<Notification type="danger">
|
||||
<strong>{t("error-notification.prefix")}:</strong> {error.message}
|
||||
</Notification>
|
||||
);
|
||||
if (error === UNAUTHORIZED_ERROR) {
|
||||
return (
|
||||
<Notification type="danger">
|
||||
<strong>{t("error-notification.prefix")}:</strong> {t("error-notification.timeout")}
|
||||
{" "}
|
||||
<a href="javascript:window.location.reload(true)">{t("error-notification.loginLink")}</a>
|
||||
</Notification>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Notification type="danger">
|
||||
<strong>{t("error-notification.prefix")}:</strong> {error.message}
|
||||
</Notification>
|
||||
);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import {mount, shallow} from "enzyme";
|
||||
import { mount, shallow } from "enzyme";
|
||||
import "./tests/enzyme";
|
||||
import "./tests/i18n";
|
||||
import ReactRouterEnzymeContext from "react-router-enzyme-context";
|
||||
import Paginator from "./Paginator";
|
||||
|
||||
// TODO: Fix tests
|
||||
xdescribe("paginator rendering tests", () => {
|
||||
|
||||
const options = new ReactRouterEnzymeContext();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// @flow
|
||||
import {contextPath} from "./urls";
|
||||
|
||||
export const NOT_FOUND_ERROR_MESSAGE = "not found";
|
||||
export const UNAUTHORIZED_ERROR_MESSAGE = "unauthorized";
|
||||
export const NOT_FOUND_ERROR = new Error("not found");
|
||||
export const UNAUTHORIZED_ERROR = new Error("unauthorized");
|
||||
export const CONFLICT_ERROR = new Error("conflict");
|
||||
|
||||
const fetchOptions: RequestOptions = {
|
||||
credentials: "same-origin",
|
||||
@@ -15,28 +16,19 @@ function handleStatusCode(response: Response) {
|
||||
if (!response.ok) {
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
return throwErrorWithMessage(response, UNAUTHORIZED_ERROR_MESSAGE);
|
||||
throw UNAUTHORIZED_ERROR;
|
||||
case 404:
|
||||
return throwErrorWithMessage(response, NOT_FOUND_ERROR_MESSAGE);
|
||||
throw NOT_FOUND_ERROR;
|
||||
case 409:
|
||||
throw CONFLICT_ERROR;
|
||||
default:
|
||||
return throwErrorWithMessage(response, "server returned status code " + response.status);
|
||||
throw new Error("server returned status code " + response.status);
|
||||
}
|
||||
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function throwErrorWithMessage(response: Response, message: string) {
|
||||
return response.json().then(
|
||||
json => {
|
||||
throw Error(json.message);
|
||||
},
|
||||
() => {
|
||||
throw Error(message);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function createUrl(url: string) {
|
||||
if (url.includes("://")) {
|
||||
return url;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// @flow
|
||||
|
||||
export type Person = {
|
||||
name: string,
|
||||
mail?: string
|
||||
};
|
||||
|
||||
export const EXTENSION_POINT = "avatar.factory";
|
||||
@@ -0,0 +1,34 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import {binder} from "@scm-manager/ui-extensions";
|
||||
import {Image} from "..";
|
||||
import type { Person } from "./Avatar";
|
||||
import { EXTENSION_POINT } from "./Avatar";
|
||||
|
||||
|
||||
type Props = {
|
||||
person: Person
|
||||
};
|
||||
|
||||
class AvatarImage extends React.Component<Props> {
|
||||
render() {
|
||||
const { person } = this.props;
|
||||
|
||||
const avatarFactory = binder.getExtension(EXTENSION_POINT);
|
||||
if (avatarFactory) {
|
||||
const avatar = avatarFactory(person);
|
||||
|
||||
return (
|
||||
<Image
|
||||
className="has-rounded-border"
|
||||
src={avatar}
|
||||
alt={person.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default AvatarImage;
|
||||
@@ -0,0 +1,19 @@
|
||||
//@flow
|
||||
import * as React from "react";
|
||||
import {binder} from "@scm-manager/ui-extensions";
|
||||
import { EXTENSION_POINT } from "./Avatar";
|
||||
|
||||
type Props = {
|
||||
children: React.Node
|
||||
};
|
||||
|
||||
class AvatarWrapper extends React.Component<Props> {
|
||||
render() {
|
||||
if (binder.hasExtension(EXTENSION_POINT)) {
|
||||
return <>{this.props.children}</>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default AvatarWrapper;
|
||||
@@ -0,0 +1,4 @@
|
||||
// @flow
|
||||
|
||||
export { default as AvatarWrapper } from "./AvatarWrapper";
|
||||
export { default as AvatarImage } from "./AvatarImage";
|
||||
@@ -63,8 +63,9 @@ class ConfigurationBinder {
|
||||
|
||||
|
||||
// route for global configuration, passes the current repository to component
|
||||
const RepoRoute = ({ url, repository }) => {
|
||||
return this.route(url + to, <RepositoryComponent repository={repository}/>);
|
||||
const RepoRoute = ({url, repository}) => {
|
||||
const link = repository._links[linkName].href
|
||||
return this.route(url + to, <RepositoryComponent repository={repository} link={link}/>);
|
||||
};
|
||||
|
||||
// bind config route to extension point
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
|
||||
import type { AutocompleteObject, SelectValue } from "@scm-manager/ui-types";
|
||||
import Autocomplete from "../Autocomplete";
|
||||
import AddButton from "../buttons/AddButton";
|
||||
|
||||
type Props = {
|
||||
addEntry: SelectValue => void,
|
||||
disabled: boolean,
|
||||
buttonLabel: string,
|
||||
fieldLabel: string,
|
||||
helpText?: string,
|
||||
loadSuggestions: string => Promise<AutocompleteObject>,
|
||||
placeholder?: string,
|
||||
loadingMessage?: string,
|
||||
noOptionsMessage?: string
|
||||
};
|
||||
|
||||
type State = {
|
||||
selectedValue?: SelectValue
|
||||
};
|
||||
|
||||
class AutocompleteAddEntryToTableField extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { selectedValue: undefined };
|
||||
}
|
||||
render() {
|
||||
const {
|
||||
disabled,
|
||||
buttonLabel,
|
||||
fieldLabel,
|
||||
helpText,
|
||||
loadSuggestions,
|
||||
placeholder,
|
||||
loadingMessage,
|
||||
noOptionsMessage
|
||||
} = this.props;
|
||||
|
||||
const { selectedValue } = this.state;
|
||||
return (
|
||||
<div className="field">
|
||||
<Autocomplete
|
||||
label={fieldLabel}
|
||||
loadSuggestions={loadSuggestions}
|
||||
valueSelected={this.handleAddEntryChange}
|
||||
helpText={helpText}
|
||||
value={selectedValue}
|
||||
placeholder={placeholder}
|
||||
loadingMessage={loadingMessage}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
/>
|
||||
|
||||
<AddButton
|
||||
label={buttonLabel}
|
||||
action={this.addButtonClicked}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
addButtonClicked = (event: Event) => {
|
||||
event.preventDefault();
|
||||
this.appendEntry();
|
||||
};
|
||||
|
||||
appendEntry = () => {
|
||||
const { selectedValue } = this.state;
|
||||
if (!selectedValue) {
|
||||
return;
|
||||
}
|
||||
// $FlowFixMe null is needed to clear the selection; undefined does not work
|
||||
this.setState({ ...this.state, selectedValue: null }, () =>
|
||||
this.props.addEntry(selectedValue)
|
||||
);
|
||||
};
|
||||
|
||||
handleAddEntryChange = (selection: SelectValue) => {
|
||||
this.setState({
|
||||
...this.state,
|
||||
selectedValue: selection
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default AutocompleteAddEntryToTableField;
|
||||
@@ -1,6 +1,6 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import Help from '../Help';
|
||||
import Help from "../Help.js";
|
||||
|
||||
type Props = {
|
||||
label?: string,
|
||||
|
||||
@@ -11,7 +11,7 @@ type State = {
|
||||
passwordConfirmationFailed: boolean
|
||||
};
|
||||
type Props = {
|
||||
passwordChanged: string => void,
|
||||
passwordChanged: (string, boolean) => void,
|
||||
passwordValidator?: string => boolean,
|
||||
// Context props
|
||||
t: string => string
|
||||
@@ -98,14 +98,12 @@ class PasswordConfirmation extends React.Component<Props, State> {
|
||||
);
|
||||
};
|
||||
|
||||
isValid = () => {
|
||||
return this.state.passwordValid && !this.state.passwordConfirmationFailed
|
||||
};
|
||||
|
||||
propagateChange = () => {
|
||||
if (
|
||||
this.state.password &&
|
||||
this.state.passwordValid &&
|
||||
!this.state.passwordConfirmationFailed
|
||||
) {
|
||||
this.props.passwordChanged(this.state.password);
|
||||
}
|
||||
this.props.passwordChanged(this.state.password, this.isValid());
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @create-index
|
||||
|
||||
export { default as AddEntryToTableField } from "./AddEntryToTableField.js";
|
||||
export { default as AutocompleteAddEntryToTableField } from "./AutocompleteAddEntryToTableField.js";
|
||||
export { default as Checkbox } from "./Checkbox.js";
|
||||
export { default as InputField } from "./InputField.js";
|
||||
export { default as Select } from "./Select.js";
|
||||
|
||||
@@ -23,12 +23,15 @@ export { default as Help } from "./Help";
|
||||
export { default as HelpIcon } from "./HelpIcon";
|
||||
export { default as Tooltip } from "./Tooltip";
|
||||
export { getPageFromMatch } from "./urls";
|
||||
export { default as Autocomplete} from "./Autocomplete";
|
||||
|
||||
export { apiClient, NOT_FOUND_ERROR_MESSAGE, UNAUTHORIZED_ERROR_MESSAGE } from "./apiclient.js";
|
||||
export { apiClient, NOT_FOUND_ERROR, UNAUTHORIZED_ERROR, CONFLICT_ERROR } from "./apiclient.js";
|
||||
|
||||
export * from "./avatar";
|
||||
export * from "./buttons";
|
||||
export * from "./config";
|
||||
export * from "./forms";
|
||||
export * from "./layout";
|
||||
export * from "./modals";
|
||||
export * from "./navigation";
|
||||
export * from "./repos";
|
||||
|
||||
36
scm-ui-components/packages/ui-components/src/repos/Diff.js
Normal file
36
scm-ui-components/packages/ui-components/src/repos/Diff.js
Normal file
@@ -0,0 +1,36 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { Diff2Html } from "diff2html";
|
||||
|
||||
type Props = {
|
||||
diff: string,
|
||||
sideBySide: boolean
|
||||
};
|
||||
|
||||
class Diff extends React.Component<Props> {
|
||||
|
||||
static defaultProps = {
|
||||
sideBySide: false
|
||||
};
|
||||
|
||||
render() {
|
||||
const { diff, sideBySide } = this.props;
|
||||
|
||||
const options = {
|
||||
inputFormat: "diff",
|
||||
outputFormat: sideBySide ? "side-by-side" : "line-by-line",
|
||||
showFiles: false,
|
||||
matching: "lines"
|
||||
};
|
||||
|
||||
const outputHtml = Diff2Html.getPrettyHtml(diff, options);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/no-danger
|
||||
<div dangerouslySetInnerHTML={{ __html: outputHtml }} />
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Diff;
|
||||
@@ -0,0 +1,64 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { apiClient } from "../apiclient";
|
||||
import ErrorNotification from "../ErrorNotification";
|
||||
import Loading from "../Loading";
|
||||
import Diff from "./Diff";
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
sideBySide: boolean
|
||||
};
|
||||
|
||||
type State = {
|
||||
diff?: string,
|
||||
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() {
|
||||
const { url } = this.props;
|
||||
apiClient
|
||||
.get(url)
|
||||
.then(response => response.text())
|
||||
.then(text => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
diff: text
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { diff, loading, error } = this.state;
|
||||
if (error) {
|
||||
return <ErrorNotification error={error} />;
|
||||
} else if (loading || !diff) {
|
||||
return <Loading />;
|
||||
} else {
|
||||
return <Diff diff={diff} />;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default LoadingDiff;
|
||||
@@ -0,0 +1,38 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type {Changeset} from "@scm-manager/ui-types";
|
||||
|
||||
type Props = {
|
||||
changeset: Changeset
|
||||
};
|
||||
|
||||
class ChangesetAuthor extends React.Component<Props> {
|
||||
render() {
|
||||
const { changeset } = this.props;
|
||||
if (!changeset.author) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { name } = changeset.author;
|
||||
return (
|
||||
<>
|
||||
{name} {this.renderMail()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderMail() {
|
||||
const { mail } = this.props.changeset.author;
|
||||
if (mail) {
|
||||
return (
|
||||
<a className="is-hidden-mobile" href={"mailto:" + mail}>
|
||||
<
|
||||
{mail}
|
||||
>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ChangesetAuthor;
|
||||
@@ -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("changesets.diff.not-supported")}</Notification>;
|
||||
} else {
|
||||
const url = this.createUrl(changeset);
|
||||
return <LoadingDiff url={url} />;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default translate("repos")(ChangesetDiff);
|
||||
@@ -0,0 +1,47 @@
|
||||
//@flow
|
||||
|
||||
import {Link} from "react-router-dom";
|
||||
import React from "react";
|
||||
import type {Changeset, Repository} from "@scm-manager/ui-types";
|
||||
|
||||
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 { changeset, repository } = this.props;
|
||||
return (
|
||||
<Link
|
||||
to={`/repo/${repository.namespace}/${repository.name}/changeset/${
|
||||
changeset.id
|
||||
}`}
|
||||
>
|
||||
{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();
|
||||
}
|
||||
}
|
||||
@@ -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 <div className="box">{content}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
export default ChangesetList;
|
||||
@@ -0,0 +1,98 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { Changeset, Repository, Tag } 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 ChangesetTag from "./ChangesetTag";
|
||||
|
||||
import {parseDescription} from "./changesets";
|
||||
import {AvatarWrapper, AvatarImage} from "../../avatar";
|
||||
|
||||
const styles = {
|
||||
pointer: {
|
||||
cursor: "pointer"
|
||||
},
|
||||
changesetGroup: {
|
||||
marginBottom: "1em"
|
||||
},
|
||||
withOverflow: {
|
||||
overflow: "auto"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
repository: Repository,
|
||||
changeset: Changeset,
|
||||
t: any,
|
||||
classes: any
|
||||
};
|
||||
|
||||
class ChangesetRow extends React.Component<Props> {
|
||||
createLink = (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 description = parseDescription(changeset.description);
|
||||
|
||||
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>
|
||||
</AvatarWrapper>
|
||||
<div className={classNames("media-content", classes.withOverflow)}>
|
||||
<div className="content">
|
||||
<p className="is-ellipsis-overflow">
|
||||
<strong>{description.title}</strong>
|
||||
<br />
|
||||
<Interpolate
|
||||
i18nKey="changesets.changeset.summary"
|
||||
id={changesetLink}
|
||||
time={dateFromNow}
|
||||
/>
|
||||
</p>{" "}
|
||||
<div className="is-size-7">{authorLine}</div>
|
||||
</div>
|
||||
</div>
|
||||
{this.renderTags()}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -0,0 +1,32 @@
|
||||
//@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"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
tag: Tag,
|
||||
|
||||
// context props
|
||||
classes: Object
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(ChangesetTag);
|
||||
@@ -0,0 +1,25 @@
|
||||
// @flow
|
||||
export type Description = {
|
||||
title: string,
|
||||
message: string
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
// @flow
|
||||
import * as changesets from "./changesets";
|
||||
export { changesets };
|
||||
|
||||
export { default as ChangesetAuthor } from "./ChangesetAuthor";
|
||||
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";
|
||||
@@ -0,0 +1,5 @@
|
||||
// @flow
|
||||
|
||||
export * from "./changesets";
|
||||
export { default as Diff } from "./Diff";
|
||||
export { default as LoadingDiff } from "./LoadingDiff";
|
||||
Reference in New Issue
Block a user