Merge with 2.0.0-m3

This commit is contained in:
René Pfeuffer
2018-12-19 15:09:40 +01:00
145 changed files with 3447 additions and 938 deletions

View 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;

View File

@@ -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;
}
}

View File

@@ -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();

View File

@@ -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;

View File

@@ -0,0 +1,8 @@
// @flow
export type Person = {
name: string,
mail?: string
};
export const EXTENSION_POINT = "avatar.factory";

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,4 @@
// @flow
export { default as AvatarWrapper } from "./AvatarWrapper";
export { default as AvatarImage } from "./AvatarImage";

View File

@@ -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

View File

@@ -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;

View File

@@ -1,6 +1,6 @@
//@flow
import React from "react";
import Help from '../Help';
import Help from "../Help.js";
type Props = {
label?: string,

View File

@@ -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());
};
}

View File

@@ -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";

View File

@@ -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";

View 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;

View File

@@ -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;

View File

@@ -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}>
&lt;
{mail}
&gt;
</a>
);
}
}
}
export default ChangesetAuthor;

View 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("changesets.diff.not-supported")}</Notification>;
} else {
const url = this.createUrl(changeset);
return <LoadingDiff url={url} />;
}
}
}
export default translate("repos")(ChangesetDiff);

View File

@@ -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();
}
}

View 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 <div className="box">{content}</div>;
}
}
export default ChangesetList;

View File

@@ -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));

View File

@@ -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);

View File

@@ -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
};
}

View 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("");
});
});

View File

@@ -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";

View File

@@ -0,0 +1,5 @@
// @flow
export * from "./changesets";
export { default as Diff } from "./Diff";
export { default as LoadingDiff } from "./LoadingDiff";