mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-07 22:15:45 +01:00
Merged 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;
|
||||
113
scm-ui-components/packages/ui-components/src/BranchSelector.js
Normal file
113
scm-ui-components/packages/ui-components/src/BranchSelector.js
Normal 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);
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import HelpIcon from './HelpIcon';
|
||||
const styles = {
|
||||
tooltip: {
|
||||
display: "inline-block",
|
||||
paddingLeft: "3px"
|
||||
paddingLeft: "3px",
|
||||
position: "absolute"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
138
scm-ui-components/packages/ui-components/src/StatePaginator.js
Normal file
138
scm-ui-components/packages/ui-components/src/StatePaginator.js
Normal 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">…</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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ const fetchOptions: RequestOptions = {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
function isBackendError(response) {
|
||||
return response.headers.get("Content-Type") === "application/vnd.scmm-error+json;v=2";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// @flow
|
||||
export { default as ConfigurationBinder } from "./ConfigurationBinder";
|
||||
export { default as GlobalConfiguration } from "./GlobalConfiguration";
|
||||
export { default as Configuration } from "./Configuration";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,6 +1,6 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import Help from '../Help';
|
||||
import Help from "../Help.js";
|
||||
|
||||
type Props = {
|
||||
label?: string,
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
42
scm-ui-components/packages/ui-components/src/forms/Radio.js
Normal file
42
scm-ui-components/packages/ui-components/src/forms/Radio.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
46
scm-ui-components/packages/ui-components/src/modals/Modal.js
Normal file
46
scm-ui-components/packages/ui-components/src/modals/Modal.js
Normal 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;
|
||||
@@ -1,4 +1,5 @@
|
||||
// @create-index
|
||||
|
||||
export { default as ConfirmAlert, confirmAlert } from "./ConfirmAlert.js";
|
||||
export { default as Modal } from "./Modal.js";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
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,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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1,31 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { Changeset} from "@scm-manager/ui-types";
|
||||
import ChangesetTag from "./ChangesetTag";
|
||||
import ChangesetTagsCollapsed from "./ChangesetTagsCollapsed";
|
||||
|
||||
type Props = {
|
||||
changeset: Changeset
|
||||
};
|
||||
|
||||
class ChangesetTags extends React.Component<Props> {
|
||||
|
||||
getTags = () => {
|
||||
const { changeset } = this.props;
|
||||
return changeset._embedded.tags || [];
|
||||
};
|
||||
|
||||
render() {
|
||||
const tags = this.getTags();
|
||||
|
||||
if (tags.length === 1) {
|
||||
return <ChangesetTag tag={tags[0]} />;
|
||||
} else if (tags.length > 1) {
|
||||
return <ChangesetTagsCollapsed tags={tags} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ChangesetTags;
|
||||
@@ -0,0 +1,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);
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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,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";
|
||||
@@ -0,0 +1,5 @@
|
||||
// @flow
|
||||
|
||||
export * from "./changesets";
|
||||
export { default as Diff } from "./Diff";
|
||||
export { default as LoadingDiff } from "./LoadingDiff";
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user