Merged 2.0.0-m3

This commit is contained in:
Philipp Czora
2019-02-25 16:53:25 +01:00
753 changed files with 23810 additions and 20056 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

@@ -0,0 +1,113 @@
// @flow
import React from "react";
import type { Branch } from "@scm-manager/ui-types";
import injectSheet from "react-jss";
import classNames from "classnames";
import DropDown from "./forms/DropDown";
const styles = {
zeroflex: {
flexGrow: 0
},
minWidthOfLabel: {
minWidth: "4.5rem"
},
labelSizing: {
fontSize: "1rem !important"
},
noBottomMargin: {
marginBottom: "0 !important"
}
};
type Props = {
branches: Branch[], // TODO: Use generics?
selected: (branch?: Branch) => void,
selectedBranch?: string,
label: string,
// context props
classes: Object
};
type State = { selectedBranch?: Branch };
class BranchSelector extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {};
}
componentDidMount() {
const selectedBranch = this.props.branches.find(
branch => branch.name === this.props.selectedBranch
);
this.setState({ selectedBranch });
}
render() {
const { branches, classes, label } = this.props;
if (branches) {
return (
<div
className={classNames(
"field",
"is-horizontal",
classes.noBottomMargin
)}
>
<div
className={classNames(
"field-label",
"is-normal",
classes.zeroflex,
classes.minWidthOfLabel
)}
>
<label className={classNames("label", classes.labelSizing)}>
{label}
</label>
</div>
<div className="field-body">
<div
className={classNames("field is-narrow", classes.noBottomMargin)}
>
<div className="control">
<DropDown
className="is-fullwidth"
options={branches.map(b => b.name)}
optionSelected={this.branchSelected}
preselectedOption={
this.state.selectedBranch
? this.state.selectedBranch.name
: ""
}
/>
</div>
</div>
</div>
</div>
);
} else {
return null;
}
}
branchSelected = (branchName: string) => {
const { branches, selected } = this.props;
if (!branchName) {
this.setState({ selectedBranch: undefined });
selected(undefined);
return;
}
const branch = branches.find(b => b.name === branchName);
selected(branch);
this.setState({ selectedBranch: branch });
};
}
export default injectSheet(styles)(BranchSelector);

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

@@ -2,7 +2,7 @@
import React from "react";
import { translate } from "react-i18next";
import Notification from "./Notification";
import { BackendError } from "./errors";
import {BackendError, UnauthorizedError} from "./errors";
type Props = {
t: string => string,
@@ -11,18 +11,18 @@ type Props = {
class ErrorNotification extends React.Component<Props> {
renderMoreInformationsLink(error: BackendError) {
renderMoreInformationLink(error: BackendError) {
if (error.url) {
// TODO i18n
return (
<p>
For more informations, see <a href={error.url} target="_blank">{error.errorCode}</a>
For more information, see <a href={error.url} target="_blank">{error.errorCode}</a>
</p>
);
}
}
renderBackendError(error: BackendError) {
renderBackendError = (error: BackendError) => {
// TODO i18n
// how should we handle i18n for the message?
// we could add translation for known error codes to i18n and pass the context objects as parameters,
@@ -47,7 +47,7 @@ class ErrorNotification extends React.Component<Props> {
);
})}
</ul>
{ this.renderMoreInformationsLink(error) }
{ this.renderMoreInformationLink(error) }
<div className="level is-size-7">
<div className="left">
ErrorCode: {error.errorCode}
@@ -60,25 +60,20 @@ class ErrorNotification extends React.Component<Props> {
);
}
renderError(error: Error) {
if (error instanceof BackendError) {
return this.renderBackendError(error);
} else {
return error.message;
}
}
render() {
const { error } = this.props;
const { t, error } = this.props;
if (error) {
return (
<Notification type="danger">
{this.renderError(error)}
</Notification>
);
if (error instanceof BackendError) {
return this.renderBackendError(error)
} else {
return (
<Notification type="danger">
<strong>{t("error-notification.prefix")}:</strong> {error.message}
</Notification>
);
}
}
return "";
return null;
}
}

View File

@@ -7,7 +7,8 @@ import HelpIcon from './HelpIcon';
const styles = {
tooltip: {
display: "inline-block",
paddingLeft: "3px"
paddingLeft: "3px",
position: "absolute"
}
};

View File

@@ -1,14 +1,23 @@
//@flow
import React from "react";
import injectSheet from "react-jss";
import classNames from "classnames";
type Props = {
classes: any
};
const styles = {
textinfo: {
color: "#98d8f3 !important"
}
};
class HelpIcon extends React.Component<Props> {
render() {
return <i className={classNames("fa fa-question has-text-info")} />
const { classes } = this.props;
return <i className={classNames("fa fa-question-circle has-text-info", classes.textinfo)}></i>;
}
}
export default HelpIcon;
export default injectSheet(styles)(HelpIcon);

View File

@@ -25,13 +25,13 @@ class LinkPaginator extends React.Component<Props> {
);
}
renderPreviousButton(label?: string) {
renderPreviousButton(className: string, label?: string) {
const { page } = this.props;
const previousPage = page - 1;
return (
<Button
className={"pagination-previous"}
className={className}
label={label ? label : previousPage.toString()}
disabled={!this.hasLink("prev")}
link={`${previousPage}`}
@@ -44,12 +44,12 @@ class LinkPaginator extends React.Component<Props> {
return collection._links[name];
}
renderNextButton(label?: string) {
renderNextButton(className: string, label?: string) {
const { page } = this.props;
const nextPage = page + 1;
return (
<Button
className={"pagination-next"}
className={className}
label={label ? label : nextPage.toString()}
disabled={!this.hasLink("next")}
link={`${nextPage}`}
@@ -96,13 +96,13 @@ class LinkPaginator extends React.Component<Props> {
links.push(this.separator());
}
if (page > 2) {
links.push(this.renderPreviousButton());
links.push(this.renderPreviousButton("pagination-link"));
}
links.push(this.currentPage(page));
if (page + 1 < pageTotal) {
links.push(this.renderNextButton());
links.push(this.renderNextButton("pagination-link"));
}
if (page + 2 < pageTotal)
//if there exists pages between next and last
@@ -118,13 +118,13 @@ class LinkPaginator extends React.Component<Props> {
const { t } = this.props;
return (
<nav className="pagination is-centered" aria-label="pagination">
{this.renderPreviousButton(t("paginator.previous"))}
{this.renderPreviousButton("pagination-previous", t("paginator.previous"))}
<ul className="pagination-list">
{this.pageLinks().map((link, index) => {
return <li key={index}>{link}</li>;
})}
</ul>
{this.renderNextButton(t("paginator.next"))}
{this.renderNextButton("pagination-next", t("paginator.next"))}
</nav>
);
}

View File

@@ -6,7 +6,7 @@ import "./tests/i18n";
import ReactRouterEnzymeContext from "react-router-enzyme-context";
import Paginator from "./Paginator";
describe("paginator rendering tests", () => {
xdescribe("paginator rendering tests", () => {
const options = new ReactRouterEnzymeContext();
@@ -18,7 +18,8 @@ describe("paginator rendering tests", () => {
const collection = {
page: 10,
pageTotal: 20,
_links: {}
_links: {},
_embedded: {}
};
const paginator = shallow(
@@ -40,7 +41,8 @@ describe("paginator rendering tests", () => {
first: dummyLink,
next: dummyLink,
last: dummyLink
}
},
_embedded: {}
};
const paginator = shallow(
@@ -79,7 +81,8 @@ describe("paginator rendering tests", () => {
prev: dummyLink,
next: dummyLink,
last: dummyLink
}
},
_embedded: {}
};
const paginator = shallow(
@@ -121,7 +124,8 @@ describe("paginator rendering tests", () => {
_links: {
first: dummyLink,
prev: dummyLink
}
},
_embedded: {}
};
const paginator = shallow(
@@ -160,7 +164,8 @@ describe("paginator rendering tests", () => {
prev: dummyLink,
next: dummyLink,
last: dummyLink
}
},
_embedded: {}
};
const paginator = shallow(
@@ -204,7 +209,8 @@ describe("paginator rendering tests", () => {
prev: dummyLink,
next: dummyLink,
last: dummyLink
}
},
_embedded: {}
};
const paginator = shallow(
@@ -256,7 +262,8 @@ describe("paginator rendering tests", () => {
},
next: dummyLink,
last: dummyLink
}
},
_embedded: {}
};
let urlToOpen;

View File

@@ -0,0 +1,138 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import type { PagedCollection } from "@scm-manager/ui-types";
import { Button } from "./index";
type Props = {
collection: PagedCollection,
page: number,
updatePage: number => void,
// context props
t: string => string
};
class StatePaginator extends React.Component<Props> {
renderFirstButton() {
return (
<Button
className={"pagination-link"}
label={"1"}
disabled={false}
action={() => this.updateCurrentPage(1)}
/>
);
}
updateCurrentPage = (newPage: number) => {
this.props.updatePage(newPage);
};
renderPreviousButton(label?: string) {
const { page } = this.props;
const previousPage = page - 1;
return (
<Button
className={"pagination-previous"}
label={label ? label : previousPage.toString()}
disabled={!this.hasLink("prev")}
action={() => this.updateCurrentPage(previousPage)}
/>
);
}
hasLink(name: string) {
const { collection } = this.props;
return collection._links[name];
}
renderNextButton(label?: string) {
const { page } = this.props;
const nextPage = page + 1;
return (
<Button
className={"pagination-next"}
label={label ? label : nextPage.toString()}
disabled={!this.hasLink("next")}
action={() => this.updateCurrentPage(nextPage)}
/>
);
}
renderLastButton() {
const { collection } = this.props;
return (
<Button
className={"pagination-link"}
label={`${collection.pageTotal}`}
disabled={false}
action={() => this.updateCurrentPage(collection.pageTotal)}
/>
);
}
separator() {
return <span className="pagination-ellipsis">&hellip;</span>;
}
currentPage(page: number) {
return (
<Button
className="pagination-link is-current"
label={page}
disabled={true}
action={() => this.updateCurrentPage(page)}
/>
);
}
pageLinks() {
const { collection } = this.props;
const links = [];
const page = collection.page + 1;
const pageTotal = collection.pageTotal;
if (page > 1) {
links.push(this.renderFirstButton());
}
if (page > 3) {
links.push(this.separator());
}
if (page > 2) {
links.push(this.renderPreviousButton());
}
links.push(this.currentPage(page));
if (page + 1 < pageTotal) {
links.push(this.renderNextButton());
}
if (page + 2 < pageTotal)
//if there exists pages between next and last
links.push(this.separator());
if (page < pageTotal) {
links.push(this.renderLastButton());
}
return links;
}
render() {
const { t } = this.props;
return (
<nav className="pagination is-centered" aria-label="pagination">
{this.renderPreviousButton(t("paginator.previous"))}
<ul className="pagination-list">
{this.pageLinks().map((link, index) => {
return <li key={index}>{link}</li>;
})}
</ul>
{this.renderNextButton(t("paginator.next"))}
</nav>
);
}
}
export default translate("commons")(StatePaginator);

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

@@ -10,7 +10,6 @@ const fetchOptions: RequestOptions = {
}
};
function isBackendError(response) {
return response.headers.get("Content-Type") === "application/vnd.scmm-error+json;v=2";
}

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

@@ -1,11 +1,11 @@
//@flow
import React from "react";
import Button, { type ButtonProps } from "./Button";
class AddButton extends React.Component<ButtonProps> {
render() {
return <Button color="default" {...this.props} />;
}
}
export default AddButton;
//@flow
import React from "react";
import Button, { type ButtonProps } from "./Button";
class AddButton extends React.Component<ButtonProps> {
render() {
return <Button color="default" {...this.props} />;
}
}
export default AddButton;

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

@@ -0,0 +1,33 @@
// @flow
import * as React from "react";
type Props = {
addons?: boolean,
className?: string,
children: React.Node
};
class ButtonGroup extends React.Component<Props> {
static defaultProps = {
addons: true
};
render() {
const { addons, className, children } = this.props;
let styleClasses = "buttons";
if (addons) {
styleClasses += " has-addons";
}
if (className) {
styleClasses += " " + className;
}
return (
<div className={styleClasses}>
{ children }
</div>
);
}
}
export default ButtonGroup;

View File

@@ -1,21 +1,25 @@
//@flow
import React from "react";
import injectSheet from "react-jss";
import AddButton, { type ButtonProps } from "./Button";
import { type ButtonProps } from "./Button";
import classNames from "classnames";
import Button from "./Button";
const styles = {
spacing: {
margin: "1em 0 0 1em"
marginTop: "2em",
border: "2px solid #e9f7fd",
padding: "1em 1em"
}
};
class CreateButton extends React.Component<ButtonProps> {
render() {
const { classes } = this.props;
return (
<div className={classNames("is-pulled-right", classes.spacing)}>
<AddButton {...this.props} />
<div className={classNames("has-text-centered", classes.spacing)}>
<Button color="primary" {...this.props} />
</div>
);
}

View File

@@ -1,18 +1,19 @@
//@flow
import React from "react";
import Button, { type ButtonProps } from "./Button";
import type {File} from "@scm-manager/ui-types";
type Props = {
displayName: string,
url: string
url: string,
disabled: boolean,
onClick?: () => void
};
class DownloadButton extends React.Component<Props> {
render() {
const {displayName, url} = this.props;
const { displayName, url, disabled, onClick } = this.props;
const onClickOrDefault = !!onClick ? onClick : () => {};
return (
<a className="button is-large is-info" href={url}>
<a className="button is-large is-link" href={url} disabled={disabled} onClick={onClickOrDefault}>
<span className="icon is-medium">
<i className="fas fa-arrow-circle-down" />
</span>

View File

@@ -4,7 +4,20 @@ import Button, { type ButtonProps } from "./Button";
class SubmitButton extends React.Component<ButtonProps> {
render() {
return <Button type="submit" color="primary" {...this.props} />;
const { action } = this.props;
return (
<Button
type="submit"
color="primary"
{...this.props}
action={(event) => {
if (action) {
action(event)
}
window.scrollTo(0, 0);
}}
/>
);
}
}

View File

@@ -5,6 +5,9 @@ export { default as Button } from "./Button.js";
export { default as CreateButton } from "./CreateButton.js";
export { default as DeleteButton } from "./DeleteButton.js";
export { default as EditButton } from "./EditButton.js";
export { default as RemoveEntryOfTableButton } from "./RemoveEntryOfTableButton.js";
export { default as SubmitButton } from "./SubmitButton.js";
export {default as DownloadButton} from "./DownloadButton.js";
export { default as DownloadButton } from "./DownloadButton.js";
export { default as ButtonGroup } from "./ButtonGroup.js";
export {
default as RemoveEntryOfTableButton
} from "./RemoveEntryOfTableButton.js";

View File

@@ -2,17 +2,12 @@
import React from "react";
import { translate } from "react-i18next";
import type { Links } from "@scm-manager/ui-types";
import {
apiClient,
SubmitButton,
Loading,
ErrorNotification
} from "../";
import { apiClient, SubmitButton, Loading, ErrorNotification } from "../";
type RenderProps = {
readOnly: boolean,
initialConfiguration: Configuration,
onConfigurationChange: (Configuration, boolean) => void
initialConfiguration: ConfigurationType,
onConfigurationChange: (ConfigurationType, boolean) => void
};
type Props = {
@@ -20,10 +15,10 @@ type Props = {
render: (props: RenderProps) => any, // ???
// context props
t: (string) => string
t: string => string
};
type Configuration = {
type ConfigurationType = {
_links: Links
} & Object;
@@ -32,9 +27,10 @@ type State = {
fetching: boolean,
modifying: boolean,
contentType?: string,
configChanged: boolean,
configuration?: Configuration,
modifiedConfiguration?: Configuration,
configuration?: ConfigurationType,
modifiedConfiguration?: ConfigurationType,
valid: boolean
};
@@ -42,13 +38,13 @@ type State = {
* GlobalConfiguration uses the render prop pattern to encapsulate the logic for
* synchronizing the configuration with the backend.
*/
class GlobalConfiguration extends React.Component<Props, State> {
class Configuration extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
fetching: true,
modifying: false,
configChanged: false,
valid: false
};
}
@@ -56,7 +52,8 @@ class GlobalConfiguration extends React.Component<Props, State> {
componentDidMount() {
const { link } = this.props;
apiClient.get(link)
apiClient
.get(link)
.then(this.captureContentType)
.then(response => response.json())
.then(this.loadConfig)
@@ -84,7 +81,7 @@ class GlobalConfiguration extends React.Component<Props, State> {
});
};
loadConfig = (configuration: Configuration) => {
loadConfig = (configuration: ConfigurationType) => {
this.setState({
configuration,
fetching: false,
@@ -107,7 +104,7 @@ class GlobalConfiguration extends React.Component<Props, State> {
return !modificationUrl;
};
configurationChanged = (configuration: Configuration, valid: boolean) => {
configurationChanged = (configuration: ConfigurationType, valid: boolean) => {
this.setState({
modifiedConfiguration: configuration,
valid
@@ -119,19 +116,39 @@ class GlobalConfiguration extends React.Component<Props, State> {
this.setState({ modifying: true });
const {modifiedConfiguration} = this.state;
const { modifiedConfiguration } = this.state;
apiClient.put(this.getModificationUrl(), modifiedConfiguration, this.getContentType())
.then(() => this.setState({ modifying: false }))
apiClient
.put(
this.getModificationUrl(),
modifiedConfiguration,
this.getContentType()
)
.then(() => this.setState({ modifying: false, configChanged: true, valid: false }))
.catch(this.handleError);
};
renderConfigChangedNotification = () => {
if (this.state.configChanged) {
return (
<div className="notification is-primary">
<button
className="delete"
onClick={() => this.setState({ configChanged: false })}
/>
{this.props.t("config-form.submit-success-notification")}
</div>
);
}
return null;
};
render() {
const { t } = this.props;
const { fetching, error, configuration, modifying, valid } = this.state;
if (error) {
return <ErrorNotification error={error}/>;
return <ErrorNotification error={error} />;
} else if (fetching || !configuration) {
return <Loading />;
} else {
@@ -144,19 +161,21 @@ class GlobalConfiguration extends React.Component<Props, State> {
};
return (
<form onSubmit={this.modifyConfiguration}>
{ this.props.render(renderProps) }
<hr/>
<SubmitButton
label={t("config-form.submit")}
disabled={!valid || readOnly}
loading={modifying}
/>
</form>
<>
{this.renderConfigChangedNotification()}
<form onSubmit={this.modifyConfiguration}>
{this.props.render(renderProps)}
<hr />
<SubmitButton
label={t("config-form.submit")}
disabled={!valid || readOnly}
loading={modifying}
/>
</form>
</>
);
}
}
}
export default translate("config")(GlobalConfiguration);
export default translate("config")(Configuration);

View File

@@ -9,6 +9,16 @@ class ConfigurationBinder {
i18nNamespace: string = "plugins";
navLink(to: string, labelI18nKey: string, t: any){
return <NavLink to={to} label={t(labelI18nKey)} />;
}
route(path: string, Component: any){
return <Route path={path}
render={() => Component}
exact/>;
}
bindGlobal(to: string, labelI18nKey: string, linkName: string, ConfigurationComponent: any) {
// create predicate based on the link name of the index resource
@@ -19,25 +29,76 @@ class ConfigurationBinder {
// create NavigationLink with translated label
const ConfigNavLink = translate(this.i18nNamespace)(({t}) => {
return <NavLink to={"/config" + to} label={t(labelI18nKey)} />;
return this.navLink("/config" + to, labelI18nKey, t);
});
// bind navigation link to extension point
binder.bind("config.navigation", ConfigNavLink, configPredicate);
// route for global configuration, passes the link from the index resource to component
const ConfigRoute = ({ url, links }) => {
const ConfigRoute = ({ url, links, ...additionalProps }) => {
const link = links[linkName].href;
return <Route path={url + to}
render={() => <ConfigurationComponent link={link}/>}
exact/>;
return this.route(url + to, <ConfigurationComponent link={link} {...additionalProps} />);
};
// bind config route to extension point
binder.bind("config.route", ConfigRoute, configPredicate);
}
bindRepository(to: string, labelI18nKey: string, linkName: string, RepositoryComponent: any) {
// create predicate based on the link name of the current repository route
// if the linkname is not available, the navigation link and the route are not bound to the extension points
const repoPredicate = (props: Object) => {
return props.repository && props.repository._links && props.repository._links[linkName];
};
// create NavigationLink with translated label
const RepoNavLink = translate(this.i18nNamespace)(({t, url}) => {
return this.navLink(url + to, labelI18nKey, t);
});
// bind navigation link to extension point
binder.bind("repository.navigation", RepoNavLink, repoPredicate);
// route for global configuration, passes the current repository to component
const RepoRoute = ({url, repository, ...additionalProps}) => {
const link = repository._links[linkName].href;
return this.route(url + to, <RepositoryComponent repository={repository} link={link} {...additionalProps}/>);
};
// bind config route to extension point
binder.bind("repository.route", RepoRoute, repoPredicate);
}
bindRepositorySetting(to: string, labelI18nKey: string, linkName: string, RepositoryComponent: any) {
// create predicate based on the link name of the current repository route
// if the linkname is not available, the navigation link and the route are not bound to the extension points
const repoPredicate = (props: Object) => {
return props.repository && props.repository._links && props.repository._links[linkName];
};
// create NavigationLink with translated label
const RepoNavLink = translate(this.i18nNamespace)(({t, url}) => {
return this.navLink(url + "/settings" + to, labelI18nKey, t);
});
// bind navigation link to extension point
binder.bind("repository.setting", RepoNavLink, repoPredicate);
// route for global configuration, passes the current repository to component
const RepoRoute = ({url, repository, ...additionalProps}) => {
const link = repository._links[linkName].href;
return this.route(url + "/settings" + to, <RepositoryComponent repository={repository} link={link} {...additionalProps}/>);
};
// bind config route to extension point
binder.bind("repository.route", RepoRoute, repoPredicate);
}
}
export default new ConfigurationBinder();

View File

@@ -1,3 +1,3 @@
// @flow
export { default as ConfigurationBinder } from "./ConfigurationBinder";
export { default as GlobalConfiguration } from "./GlobalConfiguration";
export { default as Configuration } from "./Configuration";

View File

@@ -10,7 +10,8 @@ type Props = {
buttonLabel: string,
fieldLabel: string,
errorMessage: string,
helpText?: string
helpText?: string,
validateEntry?: string => boolean
};
type State = {
@@ -25,6 +26,15 @@ class AddEntryToTableField extends React.Component<Props, State> {
};
}
isValid = () => {
const {validateEntry} = this.props;
if (!this.state.entryToAdd || this.state.entryToAdd === "" || !validateEntry) {
return true;
} else {
return validateEntry(this.state.entryToAdd);
}
};
render() {
const {
disabled,
@@ -39,7 +49,7 @@ class AddEntryToTableField extends React.Component<Props, State> {
label={fieldLabel}
errorMessage={errorMessage}
onChange={this.handleAddEntryChange}
validationError={false}
validationError={!this.isValid()}
value={this.state.entryToAdd}
onReturnPressed={this.appendEntry}
disabled={disabled}
@@ -48,7 +58,7 @@ class AddEntryToTableField extends React.Component<Props, State> {
<AddButton
label={buttonLabel}
action={this.addButtonClicked}
disabled={disabled}
disabled={disabled || this.state.entryToAdd ==="" || !this.isValid()}
/>
</div>
);

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

@@ -0,0 +1,42 @@
// @flow
import React from "react";
import classNames from "classnames";
type Props = {
options: string[],
optionSelected: string => void,
preselectedOption?: string,
className: any,
disabled?: boolean
};
class DropDown extends React.Component<Props> {
render() {
const { options, preselectedOption, className, disabled } = this.props;
return (
<div className={classNames(className, "select")}>
<select
value={preselectedOption ? preselectedOption : ""}
onChange={this.change}
disabled={disabled}
>
<option key="" />
{options.map(option => {
return (
<option key={option} value={option}>
{option}
</option>
);
})}
</select>
</div>
);
}
change = (event: SyntheticInputEvent<HTMLSelectElement>) => {
this.props.optionSelected(event.target.value);
};
}
export default DropDown;

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

@@ -0,0 +1,48 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import RemoveEntryOfTableButton from "../buttons/RemoveEntryOfTableButton";
type Props = {
members: string[],
t: string => string,
memberListChanged: (string[]) => void
};
type State = {};
class MemberNameTable extends React.Component<Props, State> {
render() {
const { t } = this.props;
return (
<div>
<table className="table is-hoverable is-fullwidth">
<tbody>
{this.props.members.map(member => {
return (
<tr key={member}>
<td key={member}>{member}</td>
<td>
<RemoveEntryOfTableButton
entryname={member}
removeEntry={this.removeEntry}
disabled={false}
label={t("remove-member-button.label")}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
removeEntry = (membername: string) => {
const newMembers = this.props.members.filter(name => name !== membername);
this.props.memberListChanged(newMembers);
};
}
export default translate("groups")(MemberNameTable);

View File

@@ -0,0 +1,110 @@
// @flow
import React from "react";
import {translate} from "react-i18next";
import InputField from "./InputField";
type State = {
password: string,
confirmedPassword: string,
passwordValid: boolean,
passwordConfirmationFailed: boolean
};
type Props = {
passwordChanged: (string, boolean) => void,
passwordValidator?: string => boolean,
// Context props
t: string => string
};
class PasswordConfirmation extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
password: "",
confirmedPassword: "",
passwordValid: true,
passwordConfirmationFailed: false
};
}
componentDidMount() {
this.setState({
password: "",
confirmedPassword: "",
passwordValid: true,
passwordConfirmationFailed: false
});
}
render() {
const { t } = this.props;
return (
<>
<InputField
label={t("password.newPassword")}
type="password"
onChange={this.handlePasswordChange}
value={this.state.password ? this.state.password : ""}
validationError={!this.state.passwordValid}
errorMessage={t("password.passwordInvalid")}
helpText={t("password.passwordHelpText")}
/>
<InputField
label={t("password.confirmPassword")}
type="password"
onChange={this.handlePasswordValidationChange}
value={this.state ? this.state.confirmedPassword : ""}
validationError={this.state.passwordConfirmationFailed}
errorMessage={t("password.passwordConfirmFailed")}
helpText={t("password.passwordConfirmHelpText")}
/>
</>
);
}
validatePassword = password => {
const { passwordValidator } = this.props;
if (passwordValidator) {
return passwordValidator(password);
}
return password.length >= 6 && password.length < 32;
};
handlePasswordValidationChange = (confirmedPassword: string) => {
const passwordConfirmed = this.state.password === confirmedPassword;
this.setState(
{
confirmedPassword,
passwordConfirmationFailed: !passwordConfirmed
},
this.propagateChange
);
};
handlePasswordChange = (password: string) => {
const passwordConfirmationFailed =
password !== this.state.confirmedPassword;
this.setState(
{
passwordValid: this.validatePassword(password),
passwordConfirmationFailed,
password: password
},
this.propagateChange
);
};
isValid = () => {
return this.state.passwordValid && !this.state.passwordConfirmationFailed
};
propagateChange = () => {
this.props.passwordChanged(this.state.password, this.isValid());
};
}
export default translate("commons")(PasswordConfirmation);

View File

@@ -0,0 +1,42 @@
//@flow
import React from "react";
import { Help } from "../index";
type Props = {
label?: string,
name?: string,
value?: string,
checked: boolean,
onChange?: (value: boolean, name?: string) => void,
disabled?: boolean,
helpText?: string
};
class Radio extends React.Component<Props> {
renderHelp = () => {
const helpText = this.props.helpText;
if (helpText) {
return <Help message={helpText} />;
}
};
render() {
return (
<label className="radio" disabled={this.props.disabled}>
<input
type="radio"
name={this.props.name}
value={this.props.value}
checked={this.props.checked}
onChange={this.props.onChange}
disabled={this.props.disabled}
/>{" "}
{this.props.label}
{this.renderHelp()}
</label>
);
}
}
export default Radio;

View File

@@ -54,7 +54,7 @@ class Select extends React.Component<Props> {
>
{options.map(opt => {
return (
<option value={opt.value} key={opt.value}>
<option value={opt.value} key={"KEY_" + opt.value}>
{opt.label}
</option>
);

View File

@@ -1,9 +1,14 @@
// @create-index
export { default as AddEntryToTableField } from "./AddEntryToTableField.js";
export { default as AutocompleteAddEntryToTableField } from "./AutocompleteAddEntryToTableField.js";
export { default as MemberNameTable } from "./MemberNameTable.js";
export { default as Checkbox } from "./Checkbox.js";
export { default as Radio } from "./Radio.js";
export { default as InputField } from "./InputField.js";
export { default as Select } from "./Select.js";
export { default as Textarea } from "./Textarea.js";
export { default as LabelWithHelpIcon } from "./LabelWithHelpIcon";
export { default as PasswordConfirmation } from "./PasswordConfirmation.js";
export { default as LabelWithHelpIcon } from "./LabelWithHelpIcon.js";
export { default as DropDown } from "./DropDown.js";

View File

@@ -16,18 +16,24 @@ export { default as MailLink } from "./MailLink.js";
export { default as Notification } from "./Notification.js";
export { default as Paginator } from "./Paginator.js";
export { default as LinkPaginator } from "./LinkPaginator.js";
export { default as StatePaginator } from "./StatePaginator.js";
export { default as ProtectedRoute } from "./ProtectedRoute.js";
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 { default as BranchSelector } from "./BranchSelector";
export { apiClient } from "./apiclient.js";
export * from "./errors";
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

@@ -1,31 +1,31 @@
//@flow
import * as React from "react";
import Logo from "./../Logo";
type Props = {
children?: React.Node
};
class Header extends React.Component<Props> {
render() {
const { children } = this.props;
return (
<section className="hero is-dark is-small">
<div className="hero-body">
<div className="container">
<div className="columns is-vcentered">
<div className="column">
<Logo />
</div>
</div>
</div>
</div>
<div className="hero-foot">
<div className="container">{children}</div>
</div>
</section>
);
}
}
export default Header;
//@flow
import * as React from "react";
import Logo from "./../Logo";
type Props = {
children?: React.Node
};
class Header extends React.Component<Props> {
render() {
const { children } = this.props;
return (
<section className="hero is-dark is-small">
<div className="hero-body">
<div className="container">
<div className="columns is-vcentered">
<div className="column">
<Logo />
</div>
</div>
</div>
</div>
<div className="hero-foot">
<div className="container">{children}</div>
</div>
</section>
);
}
}
export default Header;

View File

@@ -4,6 +4,9 @@ import Loading from "./../Loading";
import ErrorNotification from "./../ErrorNotification";
import Title from "./Title";
import Subtitle from "./Subtitle";
import injectSheet from "react-jss";
import classNames from "classnames";
import PageActions from "./PageActions";
type Props = {
title?: string,
@@ -11,17 +14,26 @@ type Props = {
loading?: boolean,
error?: Error,
showContentOnError?: boolean,
children: React.Node
children: React.Node,
// context props
classes: Object
};
const styles = {
spacing: {
marginTop: "1.25rem",
textAlign: "right"
}
};
class Page extends React.Component<Props> {
render() {
const { title, error, subtitle } = this.props;
const { error } = this.props;
return (
<section className="section">
<div className="container">
<Title title={title} />
<Subtitle subtitle={subtitle} />
{this.renderPageHeader()}
<ErrorNotification error={error} />
{this.renderContent()}
</div>
@@ -29,16 +41,64 @@ class Page extends React.Component<Props> {
);
}
renderPageHeader() {
const { title, subtitle, children, classes } = this.props;
let pageActions = null;
let pageActionsExists = false;
React.Children.forEach(children, child => {
if (child && child.type.name === PageActions.name) {
pageActions = (
<div className="column is-two-fifths">
<div
className={classNames(
classes.spacing,
"is-mobile-create-button-spacing"
)}
>
{child}
</div>
</div>
);
pageActionsExists = true;
}
});
let underline = pageActionsExists ? (
<hr className="header-with-actions" />
) : null;
return (
<>
<div className="columns">
<div className="column">
<Title title={title} />
<Subtitle subtitle={subtitle} />
</div>
{pageActions}
</div>
{underline}
</>
);
}
renderContent() {
const { loading, children, showContentOnError, error } = this.props;
if (error && !showContentOnError) {
return null;
}
if (loading) {
return <Loading />;
}
return children;
let content = [];
React.Children.forEach(children, child => {
if (child && child.type.name !== PageActions.name) {
content.push(child);
}
});
return content;
}
}
export default Page;
export default injectSheet(styles)(Page);

View File

@@ -0,0 +1,28 @@
//@flow
import * as React from "react";
import Loading from "./../Loading";
type Props = {
loading?: boolean,
error?: Error,
children: React.Node
};
class PageActions extends React.Component<Props> {
render() {
return <>{this.renderContent()}</>;
}
renderContent() {
const { loading, children, error } = this.props;
if (error) {
return null;
}
if (loading) {
return <Loading />;
}
return children;
}
}
export default PageActions;

View File

@@ -9,7 +9,7 @@ class Subtitle extends React.Component<Props> {
render() {
const { subtitle } = this.props;
if (subtitle) {
return <h1 className="subtitle">{subtitle}</h1>;
return <h2 className="subtitle">{subtitle}</h2>;
}
return null;
}

View File

@@ -3,6 +3,7 @@
export { default as Footer } from "./Footer.js";
export { default as Header } from "./Header.js";
export { default as Page } from "./Page.js";
export { default as PageActions } from "./PageActions.js";
export { default as Subtitle } from "./Subtitle.js";
export { default as Title } from "./Title.js";

View File

@@ -1,102 +0,0 @@
/*modified from https://github.com/GA-MO/react-confirm-alert*/
.react-confirm-alert-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
background: rgba(255, 255, 255, 0.9);
display: -webkit-flex;
display: -moz-flex;
display: -ms-flex;
display: -o-flex;
display: flex;
justify-content: center;
-ms-align-items: center;
align-items: center;
opacity: 0;
-webkit-animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards;
-moz-animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards;
-o-animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards;
animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards;
}
.react-confirm-alert-body {
font-family: Arial, Helvetica, sans-serif;
width: 400px;
padding: 30px;
text-align: left;
background: #fff;
border-radius: 10px;
box-shadow: 0 20px 75px rgba(0, 0, 0, 0.13);
color: #666;
}
.react-confirm-alert-body > h1 {
margin-top: 0;
}
.react-confirm-alert-body > h3 {
margin: 0;
font-size: 16px;
}
.react-confirm-alert-button-group {
display: -webkit-flex;
display: -moz-flex;
display: -ms-flex;
display: -o-flex;
display: flex;
justify-content: flex-start;
margin-top: 20px;
}
.react-confirm-alert-button-group > button {
outline: none;
background: #333;
border: none;
display: inline-block;
padding: 6px 18px;
color: #eee;
margin-right: 10px;
border-radius: 5px;
font-size: 12px;
cursor: pointer;
}
@-webkit-keyframes react-confirm-alert-fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@-moz-keyframes react-confirm-alert-fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@-o-keyframes react-confirm-alert-fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes react-confirm-alert-fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@@ -1,9 +1,7 @@
// @flow
//modified from https://github.com/GA-MO/react-confirm-alert
import * as React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import "./ConfirmAlert.css";
import ReactDOM from "react-dom";
import Modal from "./Modal";
type Button = {
label: string,
@@ -25,58 +23,47 @@ class ConfirmAlert extends React.Component<Props> {
};
close = () => {
removeElementReconfirm();
ReactDOM.unmountComponentAtNode(document.getElementById("modalRoot"));
};
render() {
const { title, message, buttons } = this.props;
return (
<div className="react-confirm-alert-overlay">
<div className="react-confirm-alert">
{
<div className="react-confirm-alert-body">
{title && <h1>{title}</h1>}
{message}
<div className="react-confirm-alert-button-group">
{buttons.map((button, i) => (
<button
key={i}
onClick={() => this.handleClickButton(button)}
>
{button.label}
</button>
))}
</div>
</div>
}
</div>
const body = <>{message}</>;
const footer = (
<div className="field is-grouped">
{buttons.map((button, i) => (
<p className="control">
<a
className="button is-info"
key={i}
onClick={() => this.handleClickButton(button)}
>
{button.label}
</a>
</p>
))}
</div>
);
return (
<Modal
title={title}
closeFunction={() => this.close()}
body={body}
active={true}
footer={footer}
/>
);
}
}
function createElementReconfirm(properties: Props) {
const divTarget = document.createElement("div");
divTarget.id = "react-confirm-alert";
if (document.body) {
document.body.appendChild(divTarget);
render(<ConfirmAlert {...properties} />, divTarget);
}
}
function removeElementReconfirm() {
const target = document.getElementById("react-confirm-alert");
if (target) {
unmountComponentAtNode(target);
if (target.parentNode) {
target.parentNode.removeChild(target);
}
}
}
export function confirmAlert(properties: Props) {
createElementReconfirm(properties);
const root = document.getElementById("modalRoot");
if (root) {
ReactDOM.render(<ConfirmAlert {...properties} />, root);
}
}
export default ConfirmAlert;

View File

@@ -0,0 +1,46 @@
// @flow
import * as React from "react";
import classNames from "classnames";
type Props = {
title: string,
closeFunction: () => void,
body: any,
footer?: any,
active: boolean,
};
class Modal extends React.Component<Props> {
render() {
const { title, closeFunction, body, footer, active } = this.props;
const isActive = active ? "is-active" : null;
let showFooter = null;
if (footer) {
showFooter = <footer className="modal-card-foot">{footer}</footer>;
}
return (
<div className={classNames("modal", isActive)}>
<div className="modal-background" />
<div className="modal-card">
<header className="modal-card-head">
<p className="modal-card-title">{title}</p>
<button
className="delete"
aria-label="close"
onClick={closeFunction}
/>
</header>
<section className="modal-card-body">{body}</section>
{showFooter}
</div>
</div>
);
}
}
export default Modal;

View File

@@ -1,4 +1,5 @@
// @create-index
export { default as ConfirmAlert, confirmAlert } from "./ConfirmAlert.js";
export { default as Modal } from "./Modal.js";

View File

@@ -2,16 +2,23 @@
import React from "react";
type Props = {
icon?: string,
label: string,
action: () => void
};
class NavAction extends React.Component<Props> {
render() {
const { label, action } = this.props;
const { label, icon, action } = this.props;
let showIcon = null;
if (icon) {
showIcon = (<><i className={icon}></i>{" "}</>);
}
return (
<li>
<a onClick={action}>{label}</a>
<a onClick={action} href="javascript:void(0);">{showIcon}{label}</a>
</li>
);
}

View File

@@ -6,6 +6,7 @@ import {Link, Route} from "react-router-dom";
type Props = {
to: string,
icon?: string,
label: string,
activeOnlyWhenExact?: boolean,
activeWhenMatch?: (route: any) => boolean
@@ -23,10 +24,17 @@ class NavLink extends React.Component<Props> {
}
renderLink = (route: any) => {
const { to, label } = this.props;
const { to, icon, label } = this.props;
let showIcon = null;
if (icon) {
showIcon = (<><i className={icon} />{" "}</>);
}
return (
<li>
<Link className={this.isActive(route) ? "is-active" : ""} to={to}>
{showIcon}
{label}
</Link>
</li>
@@ -35,6 +43,7 @@ class NavLink extends React.Component<Props> {
render() {
const { to, activeOnlyWhenExact } = this.props;
return (
<Route path={to} exact={activeOnlyWhenExact} children={this.renderLink} />
);

View File

@@ -2,60 +2,92 @@
import React from "react";
import { translate } from "react-i18next";
import PrimaryNavigationLink from "./PrimaryNavigationLink";
import type { Links } from "@scm-manager/ui-types";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
type Props = {
t: string => string,
repositoriesLink: string,
usersLink: string,
groupsLink: string,
configLink: string,
logoutLink: string
links: Links,
};
class PrimaryNavigation extends React.Component<Props> {
render() {
const { t, repositoriesLink, usersLink, groupsLink, configLink, logoutLink } = this.props;
const links = [
repositoriesLink ? (
<PrimaryNavigationLink
to="/repos"
match="/(repo|repos)"
label={t("primary-navigation.repositories")}
key={"repositoriesLink"}
/>): null,
usersLink ? (
<PrimaryNavigationLink
to="/users"
match="/(user|users)"
label={t("primary-navigation.users")}
key={"usersLink"}
/>) : null,
groupsLink ? (
<PrimaryNavigationLink
to="/groups"
match="/(group|groups)"
label={t("primary-navigation.groups")}
key={"groupsLink"}
/>) : null,
configLink ? (
<PrimaryNavigationLink
to="/config"
label={t("primary-navigation.config")}
key={"configLink"}
/>) : null,
logoutLink ? (
<PrimaryNavigationLink
to="/logout"
label={t("primary-navigation.logout")}
key={"logoutLink"}
/>) : null
];
createNavigationAppender = (navigationItems) => {
const { t, links } = this.props;
return (to: string, match: string, label: string, linkName: string) => {
const link = links[linkName];
if (link) {
const navigationItem = (
<PrimaryNavigationLink
to={to}
match={match}
label={t(label)}
key={linkName}
/>)
;
navigationItems.push(navigationItem);
}
};
};
appendLogout = (navigationItems, append) => {
const { t, links } = this.props;
const props = {
links,
label: t("primary-navigation.logout")
};
if (binder.hasExtension("primary-navigation.logout", props)) {
navigationItems.push(
<ExtensionPoint name="primary-navigation.logout" props={props} />
);
} else {
append("/logout", "/logout", "primary-navigation.logout", "logout");
}
};
createNavigationItems = () => {
const navigationItems = [];
const { t, links } = this.props;
const props = {
links,
label: t("primary-navigation.first-menu")
};
const append = this.createNavigationAppender(navigationItems);
if (binder.hasExtension("primary-navigation.first-menu", props)) {
navigationItems.push(
<ExtensionPoint name="primary-navigation.first-menu" props={props} />
);
}
append("/repos", "/(repo|repos)", "primary-navigation.repositories", "repositories");
append("/users", "/(user|users)", "primary-navigation.users", "users");
append("/groups", "/(group|groups)", "primary-navigation.groups", "groups");
append("/config", "/config", "primary-navigation.config", "config");
navigationItems.push(
<ExtensionPoint
name="primary-navigation"
renderAll={true}
props={{links: this.props.links}}
/>
);
this.appendLogout(navigationItems, append);
return navigationItems;
};
render() {
const navigationItems = this.createNavigationItems();
return (
<nav className="tabs is-boxed">
<ul>
{links}
{navigationItems}
</ul>
</nav>
);

View File

@@ -0,0 +1,65 @@
//@flow
import * as React from "react";
import { Link, Route } from "react-router-dom";
type Props = {
to: string,
icon?: string,
label: string,
activeOnlyWhenExact?: boolean,
activeWhenMatch?: (route: any) => boolean,
children?: React.Node
};
class SubNavigation extends React.Component<Props> {
static defaultProps = {
activeOnlyWhenExact: false
};
isActive(route: any) {
const { activeWhenMatch } = this.props;
return route.match || (activeWhenMatch && activeWhenMatch(route));
}
renderLink = (route: any) => {
const { to, icon, label } = this.props;
let defaultIcon = "fas fa-cog";
if (icon) {
defaultIcon = icon;
}
let children = null;
if (this.isActive(route)) {
children = <ul className="sub-menu">{this.props.children}</ul>;
}
return (
<li>
<Link className={this.isActive(route) ? "is-active" : ""} to={to}>
<i className={defaultIcon} /> {label}
</Link>
{children}
</li>
);
};
render() {
const { to, activeOnlyWhenExact } = this.props;
// removes last part of url
let parents = to.split("/");
parents.splice(-1, 1);
let parent = parents.join("/");
return (
<Route
path={parent}
exact={activeOnlyWhenExact}
children={this.renderLink}
/>
);
}
}
export default SubNavigation;

View File

@@ -3,6 +3,7 @@
export { default as NavAction } from "./NavAction.js";
export { default as NavLink } from "./NavLink.js";
export { default as Navigation } from "./Navigation.js";
export { default as SubNavigation } from "./SubNavigation.js";
export { default as PrimaryNavigation } from "./PrimaryNavigation.js";
export { default as PrimaryNavigationLink } from "./PrimaryNavigationLink.js";
export { default as Section } from "./Section.js";

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,77 @@
//@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() {
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(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) {
return <Loading />;
} else if(!diff){
return null;
}
else {
return <Diff diff={diff} />;
}
}
}
export default LoadingDiff;

View File

@@ -0,0 +1,52 @@
//@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);

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

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

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

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 <>{content}</>;
}
}
export default ChangesetList;

View File

@@ -0,0 +1,121 @@
//@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 { 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: ".25rem",
marginRight: ".5rem",
},
avatarImage: {
height: "35px",
width: "35px"
},
isVcentered: {
marginTop: "auto",
marginBottom: "auto"
},
metadata: {
marginLeft: 0
},
tag: {
marginTop: ".5rem"
}
};
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">
<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>
<div className={classNames("column", classes.isVcentered)}>
<ChangesetTags changeset={changeset} />
<ChangesetButtonGroup repository={repository} changeset={changeset} />
</div>
</div>
</div>
);
}
}
export default injectSheet(styles)(translate("repos")(ChangesetRow));

View 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={"fa-tag"} label={tag.name} />;
}
}
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

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

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

View File

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

View File

@@ -5,7 +5,7 @@ export const isNameValid = (name: string) => {
return nameRegex.test(name);
};
const mailRegex = /^[A-z0-9][\w.-]*@[A-z0-9][\w\-.]*\.[A-z0-9][A-z0-9-]+$/;
const mailRegex = /^[ -~]+@[A-Za-z0-9][\w\-.]*\.[A-Za-z0-9][A-Za-z0-9-]+$/;
export const isMailValid = (mail: string) => {
return mailRegex.test(mail);

View File

@@ -59,9 +59,8 @@ describe("test mail validation", () => {
"@ostfalia.de",
"s.sdorra@",
"s.sdorra@ostfalia",
"s.sdorra@@ostfalia.de",
"s.sdorra@ ostfalia.de",
"s.sdorra @ostfalia.de"
"s.sdorra@[ostfalia.de"
];
for (let mail of invalid) {
expect(validator.isMailValid(mail)).toBe(false);
@@ -78,7 +77,9 @@ describe("test mail validation", () => {
"s.sdorra@t.co",
"s.sdorra@ucla.college",
"s.sdorra@example.xn--p1ai",
"s.sdorra@scm.solutions"
"s.sdorra@scm.solutions",
"s'sdorra@scm.solutions",
"\"S Sdorra\"@scm.solutions"
];
for (let mail of valid) {
expect(validator.isMailValid(mail)).toBe(true);