scm-ui: new repository layout

This commit is contained in:
Sebastian Sdorra
2019-10-07 10:57:09 +02:00
parent 09c7def874
commit c05798e254
417 changed files with 3620 additions and 52971 deletions

View File

@@ -0,0 +1,53 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { Checkbox, InputField, Subtitle } from "@scm-manager/ui-components";
type Props = {
baseUrl: string,
forceBaseUrl: boolean,
t: string => string,
onChange: (boolean, any, string) => void,
hasUpdatePermission: boolean
};
class BaseUrlSettings extends React.Component<Props> {
render() {
const { t, baseUrl, forceBaseUrl, hasUpdatePermission } = this.props;
return (
<div>
<Subtitle subtitle={t("base-url-settings.name")} />
<div className="columns">
<div className="column is-half">
<InputField
label={t("base-url-settings.base-url")}
onChange={this.handleBaseUrlChange}
value={baseUrl}
disabled={!hasUpdatePermission}
helpText={t("help.baseUrlHelpText")}
/>
</div>
<div className="column is-half">
<Checkbox
checked={forceBaseUrl}
label={t("base-url-settings.force-base-url")}
onChange={this.handleForceBaseUrlChange}
disabled={!hasUpdatePermission}
helpText={t("help.forceBaseUrlHelpText")}
/>
</div>
</div>
</div>
);
}
handleBaseUrlChange = (value: string) => {
this.props.onChange(true, value, "baseUrl");
};
handleForceBaseUrlChange = (value: boolean) => {
this.props.onChange(true, value, "forceBaseUrl");
};
}
export default translate("config")(BaseUrlSettings);

View File

@@ -0,0 +1,209 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { SubmitButton, Notification } from "@scm-manager/ui-components";
import type { NamespaceStrategies, Config } from "@scm-manager/ui-types";
import ProxySettings from "./ProxySettings";
import GeneralSettings from "./GeneralSettings";
import BaseUrlSettings from "./BaseUrlSettings";
import LoginAttempt from "./LoginAttempt";
type Props = {
submitForm: Config => void,
config?: Config,
loading?: boolean,
configReadPermission: boolean,
configUpdatePermission: boolean,
namespaceStrategies?: NamespaceStrategies,
// context props
t: string => string
};
type State = {
config: Config,
showNotification: boolean,
error: {
loginAttemptLimitTimeout: boolean,
loginAttemptLimit: boolean
},
changed: boolean
};
class ConfigForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
config: {
proxyPassword: null,
proxyPort: 0,
proxyServer: "",
proxyUser: null,
enableProxy: false,
realmDescription: "",
disableGroupingGrid: false,
dateFormat: "",
anonymousAccessEnabled: false,
baseUrl: "",
forceBaseUrl: false,
loginAttemptLimit: 0,
proxyExcludes: [],
skipFailedAuthenticators: false,
pluginUrl: "",
loginAttemptLimitTimeout: 0,
enabledXsrfProtection: true,
namespaceStrategy: "",
loginInfoUrl: "",
_links: {}
},
showNotification: false,
error: {
loginAttemptLimitTimeout: false,
loginAttemptLimit: false
},
changed: false
};
}
componentDidMount() {
const { config, configUpdatePermission } = this.props;
if (config) {
this.setState({ ...this.state, config: { ...config } });
}
if (!configUpdatePermission) {
this.setState({ ...this.state, showNotification: true });
}
}
submit = (event: Event) => {
event.preventDefault();
this.setState({
changed: false
});
this.props.submitForm(this.state.config);
};
render() {
const {
loading,
t,
namespaceStrategies,
configReadPermission,
configUpdatePermission
} = this.props;
const config = this.state.config;
let noPermissionNotification = null;
if (!configReadPermission) {
return (
<Notification
type={"danger"}
children={t("config.form.no-read-permission-notification")}
/>
);
}
if (this.state.showNotification) {
noPermissionNotification = (
<Notification
type={"info"}
children={t("config.form.no-write-permission-notification")}
onClose={() => this.onClose()}
/>
);
}
return (
<form onSubmit={this.submit}>
{noPermissionNotification}
<GeneralSettings
namespaceStrategies={namespaceStrategies}
loginInfoUrl={config.loginInfoUrl}
realmDescription={config.realmDescription}
disableGroupingGrid={config.disableGroupingGrid}
dateFormat={config.dateFormat}
anonymousAccessEnabled={config.anonymousAccessEnabled}
skipFailedAuthenticators={config.skipFailedAuthenticators}
pluginUrl={config.pluginUrl}
enabledXsrfProtection={config.enabledXsrfProtection}
namespaceStrategy={config.namespaceStrategy}
onChange={(isValid, changedValue, name) =>
this.onChange(isValid, changedValue, name)
}
hasUpdatePermission={configUpdatePermission}
/>
<hr />
<LoginAttempt
loginAttemptLimit={config.loginAttemptLimit}
loginAttemptLimitTimeout={config.loginAttemptLimitTimeout}
onChange={(isValid, changedValue, name) =>
this.onChange(isValid, changedValue, name)
}
hasUpdatePermission={configUpdatePermission}
/>
<hr />
<BaseUrlSettings
baseUrl={config.baseUrl}
forceBaseUrl={config.forceBaseUrl}
onChange={(isValid, changedValue, name) =>
this.onChange(isValid, changedValue, name)
}
hasUpdatePermission={configUpdatePermission}
/>
<hr />
<ProxySettings
proxyPassword={config.proxyPassword ? config.proxyPassword : ""}
proxyPort={config.proxyPort}
proxyServer={config.proxyServer ? config.proxyServer : ""}
proxyUser={config.proxyUser ? config.proxyUser : ""}
enableProxy={config.enableProxy}
proxyExcludes={config.proxyExcludes}
onChange={(isValid, changedValue, name) =>
this.onChange(isValid, changedValue, name)
}
hasUpdatePermission={configUpdatePermission}
/>
<hr />
<SubmitButton
loading={loading}
label={t("config.form.submit")}
disabled={
!configUpdatePermission || this.hasError() || !this.state.changed
}
/>
</form>
);
}
onChange = (isValid: boolean, changedValue: any, name: string) => {
this.setState({
...this.state,
config: {
...this.state.config,
[name]: changedValue
},
error: {
...this.state.error,
[name]: !isValid
},
changed: true
});
};
hasError = () => {
return (
this.state.error.loginAttemptLimit ||
this.state.error.loginAttemptLimitTimeout
);
};
onClose = () => {
this.setState({
...this.state,
showNotification: false
});
};
}
export default translate("config")(ConfigForm);

View File

@@ -0,0 +1,113 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { Checkbox, InputField } from "@scm-manager/ui-components";
import type { NamespaceStrategies } from "@scm-manager/ui-types";
import NamespaceStrategySelect from "./NamespaceStrategySelect";
type Props = {
realmDescription: string,
loginInfoUrl: string,
disableGroupingGrid: boolean,
dateFormat: string,
anonymousAccessEnabled: boolean,
skipFailedAuthenticators: boolean,
pluginUrl: string,
enabledXsrfProtection: boolean,
namespaceStrategy: string,
namespaceStrategies?: NamespaceStrategies,
onChange: (boolean, any, string) => void,
hasUpdatePermission: boolean,
// context props
t: string => string
};
class GeneralSettings extends React.Component<Props> {
render() {
const {
t,
realmDescription,
loginInfoUrl,
pluginUrl,
enabledXsrfProtection,
namespaceStrategy,
hasUpdatePermission,
namespaceStrategies
} = this.props;
return (
<div>
<div className="columns">
<div className="column is-half">
<InputField
label={t("general-settings.realm-description")}
onChange={this.handleRealmDescriptionChange}
value={realmDescription}
disabled={!hasUpdatePermission}
helpText={t("help.realmDescriptionHelpText")}
/>
</div>
<div className="column is-half">
<NamespaceStrategySelect
label={t("general-settings.namespace-strategy")}
onChange={this.handleNamespaceStrategyChange}
value={namespaceStrategy}
disabled={!hasUpdatePermission}
namespaceStrategies={namespaceStrategies}
helpText={t("help.nameSpaceStrategyHelpText")}
/>
</div>
</div>
<div className="columns">
<div className="column is-half">
<InputField
label={t("general-settings.login-info-url")}
onChange={this.handleLoginInfoUrlChange}
value={loginInfoUrl}
disabled={!hasUpdatePermission}
helpText={t("help.loginInfoUrlHelpText")}
/>
</div>
<div className="column is-half">
<Checkbox
checked={enabledXsrfProtection}
label={t("general-settings.enabled-xsrf-protection")}
onChange={this.handleEnabledXsrfProtectionChange}
disabled={!hasUpdatePermission}
helpText={t("help.enableXsrfProtectionHelpText")}
/>
</div>
</div>
<div className="columns">
<div className="column is-half">
<InputField
label={t("general-settings.plugin-url")}
onChange={this.handlePluginCenterUrlChange}
value={pluginUrl}
disabled={!hasUpdatePermission}
helpText={t("help.pluginUrlHelpText")}
/>
</div>
</div>
</div>
);
}
handleLoginInfoUrlChange = (value: string) => {
this.props.onChange(true, value, "loginInfoUrl");
};
handleRealmDescriptionChange = (value: string) => {
this.props.onChange(true, value, "realmDescription");
};
handleEnabledXsrfProtectionChange = (value: boolean) => {
this.props.onChange(true, value, "enabledXsrfProtection");
};
handleNamespaceStrategyChange = (value: string) => {
this.props.onChange(true, value, "namespaceStrategy");
};
handlePluginCenterUrlChange = (value: string) => {
this.props.onChange(true, value, "pluginUrl");
};
}
export default translate("config")(GeneralSettings);

View File

@@ -0,0 +1,97 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import {
InputField,
Subtitle,
validation as validator
} from "@scm-manager/ui-components";
type Props = {
loginAttemptLimit: number,
loginAttemptLimitTimeout: number,
t: string => string,
onChange: (boolean, any, string) => void,
hasUpdatePermission: boolean
};
type State = {
loginAttemptLimitError: boolean,
loginAttemptLimitTimeoutError: boolean
};
class LoginAttempt extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loginAttemptLimitError: false,
loginAttemptLimitTimeoutError: false
};
}
render() {
const {
t,
loginAttemptLimit,
loginAttemptLimitTimeout,
hasUpdatePermission
} = this.props;
return (
<div>
<Subtitle subtitle={t("login-attempt.name")} />
<div className="columns">
<div className="column is-half">
<InputField
label={t("login-attempt.login-attempt-limit")}
onChange={this.handleLoginAttemptLimitChange}
value={loginAttemptLimit}
disabled={!hasUpdatePermission}
validationError={this.state.loginAttemptLimitError}
errorMessage={t("validation.login-attempt-limit-invalid")}
helpText={t("help.loginAttemptLimitHelpText")}
/>
</div>
<div className="column is-half">
<InputField
label={t("login-attempt.login-attempt-limit-timeout")}
onChange={this.handleLoginAttemptLimitTimeoutChange}
value={loginAttemptLimitTimeout}
disabled={!hasUpdatePermission}
validationError={this.state.loginAttemptLimitTimeoutError}
errorMessage={t("validation.login-attempt-limit-timeout-invalid")}
helpText={t("help.loginAttemptLimitTimeoutHelpText")}
/>
</div>
</div>
</div>
);
}
//TODO: set Error in ConfigForm to disable Submit Button!
handleLoginAttemptLimitChange = (value: string) => {
this.setState({
...this.state,
loginAttemptLimitError: !validator.isNumberValid(value)
});
this.props.onChange(
validator.isNumberValid(value),
value,
"loginAttemptLimit"
);
};
handleLoginAttemptLimitTimeoutChange = (value: string) => {
this.setState({
...this.state,
loginAttemptLimitTimeoutError: !validator.isNumberValid(value)
});
this.props.onChange(
validator.isNumberValid(value),
value,
"loginAttemptLimitTimeout"
);
};
}
export default translate("config")(LoginAttempt);

View File

@@ -0,0 +1,67 @@
//@flow
import React from "react";
import { translate, type TFunction } from "react-i18next";
import { Select } from "@scm-manager/ui-components";
import type { NamespaceStrategies } from "@scm-manager/ui-types";
type Props = {
namespaceStrategies: NamespaceStrategies,
label: string,
value?: string,
disabled?: boolean,
helpText?: string,
onChange: (value: string, name?: string) => void,
// context props
t: TFunction
};
class NamespaceStrategySelect extends React.Component<Props> {
createNamespaceOptions = () => {
const { namespaceStrategies, t } = this.props;
let available = [];
if (namespaceStrategies && namespaceStrategies.available) {
available = namespaceStrategies.available;
}
return available.map(ns => {
const key = "namespaceStrategies." + ns;
let label = t(key);
if (label === key) {
label = ns;
}
return {
value: ns,
label: label
};
});
};
findSelected = () => {
const { namespaceStrategies, value } = this.props;
if (
!namespaceStrategies ||
!namespaceStrategies.available ||
namespaceStrategies.available.indexOf(value) < 0
) {
return namespaceStrategies.current;
}
return value;
};
render() {
const { label, helpText, disabled, onChange } = this.props;
const nsOptions = this.createNamespaceOptions();
return (
<Select
label={label}
onChange={onChange}
value={this.findSelected()}
disabled={disabled}
options={nsOptions}
helpText={helpText}
/>
);
}
}
export default translate("plugins")(NamespaceStrategySelect);

View File

@@ -0,0 +1,146 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import {
Checkbox,
InputField,
Subtitle,
AddEntryToTableField
} from "@scm-manager/ui-components";
import ProxyExcludesTable from "../table/ProxyExcludesTable";
type Props = {
proxyPassword: string,
proxyPort: number,
proxyServer: string,
proxyUser: string,
enableProxy: boolean,
proxyExcludes: string[],
t: string => string,
onChange: (boolean, any, string) => void,
hasUpdatePermission: boolean
};
class ProxySettings extends React.Component<Props> {
render() {
const {
t,
proxyPassword,
proxyPort,
proxyServer,
proxyUser,
enableProxy,
proxyExcludes,
hasUpdatePermission
} = this.props;
return (
<div>
<Subtitle subtitle={t("proxy-settings.name")} />
<div className="columns">
<div className="column is-full">
<Checkbox
checked={enableProxy}
label={t("proxy-settings.enable-proxy")}
onChange={this.handleEnableProxyChange}
disabled={!hasUpdatePermission}
helpText={t("help.enableProxyHelpText")}
/>
</div>
</div>
<div className="columns">
<div className="column is-half">
<InputField
label={t("proxy-settings.proxy-password")}
onChange={this.handleProxyPasswordChange}
value={proxyPassword}
type="password"
disabled={!enableProxy || !hasUpdatePermission}
helpText={t("help.proxyPasswordHelpText")}
/>
</div>
<div className="column is-half">
<InputField
label={t("proxy-settings.proxy-port")}
value={proxyPort}
onChange={this.handleProxyPortChange}
disabled={!enableProxy || !hasUpdatePermission}
helpText={t("help.proxyPortHelpText")}
/>
</div>
</div>
<div className="columns">
<div className="column is-half">
<InputField
label={t("proxy-settings.proxy-server")}
value={proxyServer}
onChange={this.handleProxyServerChange}
disabled={!enableProxy || !hasUpdatePermission}
helpText={t("help.proxyServerHelpText")}
/>
</div>
<div className="column is-half">
<InputField
label={t("proxy-settings.proxy-user")}
value={proxyUser}
onChange={this.handleProxyUserChange}
disabled={!enableProxy || !hasUpdatePermission}
helpText={t("help.proxyUserHelpText")}
/>
</div>
</div>
<div className="columns">
<div className="column is-full">
<ProxyExcludesTable
proxyExcludes={proxyExcludes}
onChange={(isValid, changedValue, name) =>
this.props.onChange(isValid, changedValue, name)
}
disabled={!enableProxy || !hasUpdatePermission}
/>
<AddEntryToTableField
addEntry={this.addProxyExclude}
disabled={!enableProxy || !hasUpdatePermission}
buttonLabel={t("proxy-settings.add-proxy-exclude-button")}
fieldLabel={t("proxy-settings.add-proxy-exclude-textfield")}
errorMessage={t("proxy-settings.add-proxy-exclude-error")}
/>
</div>
</div>
</div>
);
}
handleProxyPasswordChange = (value: string) => {
this.props.onChange(true, value, "proxyPassword");
};
handleProxyPortChange = (value: string) => {
this.props.onChange(true, value, "proxyPort");
};
handleProxyServerChange = (value: string) => {
this.props.onChange(true, value, "proxyServer");
};
handleProxyUserChange = (value: string) => {
this.props.onChange(true, value, "proxyUser");
};
handleEnableProxyChange = (value: string) => {
this.props.onChange(true, value, "enableProxy");
};
addProxyExclude = (proxyExcludeName: string) => {
if (this.isProxyExcludeMember(proxyExcludeName)) {
return;
}
this.props.onChange(
true,
[...this.props.proxyExcludes, proxyExcludeName],
"proxyExcludes"
);
};
isProxyExcludeMember = (proxyExcludeName: string) => {
return this.props.proxyExcludes.includes(proxyExcludeName);
};
}
export default translate("config")(ProxySettings);

View File

@@ -0,0 +1,49 @@
//@flow
import React from "react";
import { RemoveEntryOfTableButton, LabelWithHelpIcon } from "@scm-manager/ui-components";
type Props = {
items: string[],
label: string,
removeLabel: string,
onRemove: (string[], string) => void,
disabled: boolean,
helpText: string
};
class ArrayConfigTable extends React.Component<Props> {
render() {
const { label, disabled, removeLabel, items, helpText } = this.props;
return (
<div>
<LabelWithHelpIcon label={label} helpText={helpText}/>
<table className="table is-hoverable is-fullwidth">
<tbody>
{items.map(item => {
return (
<tr key={item}>
<td>{item}</td>
<td>
<RemoveEntryOfTableButton
entryname={item}
removeEntry={this.removeEntry}
disabled={disabled}
label={removeLabel}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
removeEntry = (item: string) => {
const newItems = this.props.items.filter(name => name !== item);
this.props.onRemove(newItems, item);
};
}
export default ArrayConfigTable;

View File

@@ -0,0 +1,35 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import ArrayConfigTable from "./ArrayConfigTable";
type Props = {
proxyExcludes: string[],
t: string => string,
onChange: (boolean, any, string) => void,
disabled: boolean
};
type State = {};
class ProxyExcludesTable extends React.Component<Props, State> {
render() {
const { proxyExcludes, disabled, t } = this.props;
return (
<ArrayConfigTable
items={proxyExcludes}
label={t("proxy-settings.proxy-excludes")}
removeLabel={t("proxy-settings.remove-proxy-exclude-button")}
onRemove={this.removeEntry}
disabled={disabled}
helpText={t("help.proxyExcludesHelpText")}
/>
);
}
removeEntry = (newExcludes: string[]) => {
this.props.onChange(true, newExcludes, "proxyExcludes");
};
}
export default translate("config")(ProxyExcludesTable);

View File

@@ -0,0 +1,236 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { Redirect, Route, Switch } from "react-router-dom";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import type { History } from "history";
import { connect } from "react-redux";
import { compose } from "redux";
import type { Links } from "@scm-manager/ui-types";
import {
Page,
Navigation,
NavLink,
Section,
SubNavigation
} from "@scm-manager/ui-components";
import {
getLinks,
getAvailablePluginsLink,
getInstalledPluginsLink
} from "../../modules/indexResource";
import AdminDetails from "./AdminDetails";
import PluginsOverview from "../plugins/containers/PluginsOverview";
import GlobalConfig from "./GlobalConfig";
import RepositoryRoles from "../roles/containers/RepositoryRoles";
import SingleRepositoryRole from "../roles/containers/SingleRepositoryRole";
import CreateRepositoryRole from "../roles/containers/CreateRepositoryRole";
type Props = {
links: Links,
availablePluginsLink: string,
installedPluginsLink: string,
// context objects
t: string => string,
match: any,
history: History
};
class Admin extends React.Component<Props> {
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
if (url.includes("role")) {
return url.substring(0, url.length - 2);
}
return url.substring(0, url.length - 1);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
matchesRoles = (route: any) => {
const url = this.matchedUrl();
const regex = new RegExp(`${url}/role/`);
return route.location.pathname.match(regex);
};
render() {
const { links, availablePluginsLink, installedPluginsLink, t } = this.props;
const url = this.matchedUrl();
const extensionProps = {
links,
url
};
return (
<Page>
<div className="columns">
<div className="column is-three-quarters">
<Switch>
<Redirect exact from={url} to={`${url}/info`} />
<Route path={`${url}/info`} exact component={AdminDetails} />
<Route
path={`${url}/settings/general`}
exact
component={GlobalConfig}
/>
<Redirect
exact
from={`${url}/plugins`}
to={`${url}/plugins/installed/`}
/>
<Route
path={`${url}/plugins/installed`}
exact
render={() => (
<PluginsOverview
baseUrl={`${url}/plugins/installed`}
installed={true}
/>
)}
/>
<Route
path={`${url}/plugins/installed/:page`}
exact
render={() => (
<PluginsOverview
baseUrl={`${url}/plugins/installed`}
installed={true}
/>
)}
/>
<Route
path={`${url}/plugins/available`}
exact
render={() => (
<PluginsOverview
baseUrl={`${url}/plugins/available`}
installed={false}
/>
)}
/>
<Route
path={`${url}/plugins/available/:page`}
exact
render={() => (
<PluginsOverview
baseUrl={`${url}/plugins/available`}
installed={false}
/>
)}
/>
<Route
path={`${url}/role/:role`}
render={() => (
<SingleRepositoryRole
baseUrl={`${url}/roles`}
history={this.props.history}
/>
)}
/>
<Route
path={`${url}/roles`}
exact
render={() => <RepositoryRoles baseUrl={`${url}/roles`} />}
/>
<Route
path={`${url}/roles/create`}
render={() => (
<CreateRepositoryRole history={this.props.history} />
)}
/>
<Route
path={`${url}/roles/:page`}
exact
render={() => <RepositoryRoles baseUrl={`${url}/roles`} />}
/>
<ExtensionPoint
name="admin.route"
props={extensionProps}
renderAll={true}
/>
</Switch>
</div>
<div className="column is-one-quarter">
<Navigation>
<Section label={t("admin.menu.navigationLabel")}>
<NavLink
to={`${url}/info`}
icon="fas fa-info-circle"
label={t("admin.menu.informationNavLink")}
/>
{(availablePluginsLink || installedPluginsLink) && (
<SubNavigation
to={`${url}/plugins/`}
icon="fas fa-puzzle-piece"
label={t("plugins.menu.pluginsNavLink")}
>
{installedPluginsLink && (
<NavLink
to={`${url}/plugins/installed/`}
label={t("plugins.menu.installedNavLink")}
/>
)}
{availablePluginsLink && (
<NavLink
to={`${url}/plugins/available/`}
label={t("plugins.menu.availableNavLink")}
/>
)}
</SubNavigation>
)}
<NavLink
to={`${url}/roles/`}
icon="fas fa-user-shield"
label={t("repositoryRole.navLink")}
activeWhenMatch={this.matchesRoles}
activeOnlyWhenExact={false}
/>
<ExtensionPoint
name="admin.navigation"
props={extensionProps}
renderAll={true}
/>
<SubNavigation
to={`${url}/settings/general`}
label={t("admin.menu.settingsNavLink")}
>
<NavLink
to={`${url}/settings/general`}
label={t("admin.menu.generalNavLink")}
/>
<ExtensionPoint
name="admin.setting"
props={extensionProps}
renderAll={true}
/>
</SubNavigation>
</Section>
</Navigation>
</div>
</div>
</Page>
);
}
}
const mapStateToProps = (state: any) => {
const links = getLinks(state);
const availablePluginsLink = getAvailablePluginsLink(state);
const installedPluginsLink = getInstalledPluginsLink(state);
return {
links,
availablePluginsLink,
installedPluginsLink
};
};
export default compose(
connect(mapStateToProps),
translate("admin")
)(Admin);

View File

@@ -0,0 +1,93 @@
// @flow
import React from "react";
import {connect} from "react-redux";
import injectSheet from "react-jss";
import {translate} from "react-i18next";
import classNames from "classnames";
import {Image, Loading, Subtitle, Title} from "@scm-manager/ui-components";
import {getAppVersion} from "../../modules/indexResource";
type Props = {
loading: boolean,
error: Error,
version: string,
// context props
classes: any,
t: string => string
};
const styles = {
boxShadow: {
boxShadow: "0 2px 3px rgba(40, 177, 232, 0.1), 0 0 0 2px rgb(40, 177, 232, 0.2)"
},
boxTitle: {
fontWeight: "500 !important"
},
imagePadding: {
padding: "0.2rem 0.4rem"
}
};
class AdminDetails extends React.Component<Props> {
render() {
const {loading, classes, t} = this.props;
if (loading) {
return <Loading/>;
}
return (
<>
<Title title={t("admin.info.currentAppVersion")}/>
<Subtitle subtitle={this.props.version}/>
<div className={classNames("box", classes.boxShadow)}>
<article className="media">
<div className={classNames("media-left", classes.imagePadding)}>
<Image
src="/images/iconCommunitySupport.png"
alt={t("admin.info.communityIconAlt")}
/>
</div>
<div className="media-content">
<div className="content">
<h3 className={classes.boxTitle}>{t("admin.info.communityTitle")}</h3>
<p>{t("admin.info.communityInfo")}</p>
<a className="button is-info is-pulled-right" target="_blank"
href="https://scm-manager.org/support/">{t("admin.info.communityButton")}</a>
</div>
</div>
</article>
</div>
<div className={classNames("box", classes.boxShadow)}>
<article className="media">
<div className={classNames("media-left", classes.imagePadding)}>
<Image
src="/images/iconEnterpriseSupport.png"
alt={t("admin.info.enterpriseIconAlt")}
/>
</div>
<div className="media-content">
<div className="content">
<h3 className={classes.boxTitle}>{t("admin.info.enterpriseTitle")}</h3>
<p>{t("admin.info.enterpriseInfo")}<br/><strong>{t("admin.info.enterprisePartner")}</strong></p>
<a className="button is-info is-pulled-right is-normal" target="_blank"
href={t("admin.info.enterpriseLink")}>{t("admin.info.enterpriseButton")}</a>
</div>
</div>
</article>
</div>
</>
);
}
}
const mapStateToProps = (state: any) => {
const version = getAppVersion(state);
return {
version
};
};
export default connect(mapStateToProps)(injectSheet(styles)(translate("admin")(AdminDetails)));

View File

@@ -0,0 +1,179 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { Title, Loading, ErrorNotification } from "@scm-manager/ui-components";
import {
fetchConfig,
getFetchConfigFailure,
isFetchConfigPending,
getConfig,
modifyConfig,
isModifyConfigPending,
getConfigUpdatePermission,
getModifyConfigFailure,
modifyConfigReset
} from "../modules/config";
import { connect } from "react-redux";
import type { Config, NamespaceStrategies } from "@scm-manager/ui-types";
import ConfigForm from "../components/form/ConfigForm";
import { getConfigLink } from "../../modules/indexResource";
import {
fetchNamespaceStrategiesIfNeeded,
getFetchNamespaceStrategiesFailure,
getNamespaceStrategies,
isFetchNamespaceStrategiesPending
} from "../modules/namespaceStrategies";
type Props = {
loading: boolean,
error: Error,
config: Config,
configUpdatePermission: boolean,
configLink: string,
namespaceStrategies?: NamespaceStrategies,
// dispatch functions
modifyConfig: (config: Config, callback?: () => void) => void,
fetchConfig: (link: string) => void,
configReset: void => void,
fetchNamespaceStrategiesIfNeeded: void => void,
// context objects
t: string => string
};
type State = {
configReadPermission: boolean,
configChanged: boolean
};
class GlobalConfig extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
configReadPermission: true,
configChanged: false
};
}
componentDidMount() {
this.props.configReset();
this.props.fetchNamespaceStrategiesIfNeeded();
if (this.props.configLink) {
this.props.fetchConfig(this.props.configLink);
} else {
this.setState({configReadPermission: false});
}
}
modifyConfig = (config: Config) => {
this.props.modifyConfig(config);
this.setState({ configChanged: true });
};
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, loading } = this.props;
if (loading) {
return <Loading />;
}
return (
<div>
<Title title={t("config.title")} />
{this.renderError()}
{this.renderContent()}
</div>
);
}
renderError = () => {
const { error } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
return null;
};
renderContent = () => {
const { error, loading, config, configUpdatePermission, namespaceStrategies } = this.props;
const { configReadPermission } = this.state;
if (!error) {
return (
<>
{this.renderConfigChangedNotification()}
<ConfigForm
submitForm={config => this.modifyConfig(config)}
config={config}
loading={loading}
namespaceStrategies={namespaceStrategies}
configUpdatePermission={configUpdatePermission}
configReadPermission={configReadPermission}
/>
</>
);
}
return null;
};
}
const mapDispatchToProps = dispatch => {
return {
fetchConfig: (link: string) => {
dispatch(fetchConfig(link));
},
modifyConfig: (config: Config, callback?: () => void) => {
dispatch(modifyConfig(config, callback));
},
configReset: () => {
dispatch(modifyConfigReset());
},
fetchNamespaceStrategiesIfNeeded: () => {
dispatch(fetchNamespaceStrategiesIfNeeded());
}
};
};
const mapStateToProps = state => {
const loading = isFetchConfigPending(state)
|| isModifyConfigPending(state)
|| isFetchNamespaceStrategiesPending(state);
const error = getFetchConfigFailure(state)
|| getModifyConfigFailure(state)
|| getFetchNamespaceStrategiesFailure(state);
const config = getConfig(state);
const configUpdatePermission = getConfigUpdatePermission(state);
const configLink = getConfigLink(state);
const namespaceStrategies = getNamespaceStrategies(state);
return {
loading,
error,
config,
configUpdatePermission,
configLink,
namespaceStrategies
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("config")(GlobalConfig));

View File

@@ -0,0 +1,171 @@
// @flow
import { apiClient } from "@scm-manager/ui-components";
import * as types from "../../modules/types";
import type { Action } from "@scm-manager/ui-types";
import { isPending } from "../../modules/pending";
import { getFailure } from "../../modules/failure";
import { Dispatch } from "redux";
import type { Config } from "@scm-manager/ui-types";
export const FETCH_CONFIG = "scm/config/FETCH_CONFIG";
export const FETCH_CONFIG_PENDING = `${FETCH_CONFIG}_${types.PENDING_SUFFIX}`;
export const FETCH_CONFIG_SUCCESS = `${FETCH_CONFIG}_${types.SUCCESS_SUFFIX}`;
export const FETCH_CONFIG_FAILURE = `${FETCH_CONFIG}_${types.FAILURE_SUFFIX}`;
export const MODIFY_CONFIG = "scm/config/MODIFY_CONFIG";
export const MODIFY_CONFIG_PENDING = `${MODIFY_CONFIG}_${types.PENDING_SUFFIX}`;
export const MODIFY_CONFIG_SUCCESS = `${MODIFY_CONFIG}_${types.SUCCESS_SUFFIX}`;
export const MODIFY_CONFIG_FAILURE = `${MODIFY_CONFIG}_${types.FAILURE_SUFFIX}`;
export const MODIFY_CONFIG_RESET = `${MODIFY_CONFIG}_${types.RESET_SUFFIX}`;
const CONTENT_TYPE_CONFIG = "application/vnd.scmm-config+json;v=2";
//fetch config
export function fetchConfig(link: string) {
return function(dispatch: any) {
dispatch(fetchConfigPending());
return apiClient
.get(link)
.then(response => {
return response.json();
})
.then(data => {
dispatch(fetchConfigSuccess(data));
})
.catch(err => {
dispatch(fetchConfigFailure(err));
});
};
}
export function fetchConfigPending(): Action {
return {
type: FETCH_CONFIG_PENDING
};
}
export function fetchConfigSuccess(config: Config): Action {
return {
type: FETCH_CONFIG_SUCCESS,
payload: config
};
}
export function fetchConfigFailure(error: Error): Action {
return {
type: FETCH_CONFIG_FAILURE,
payload: {
error
}
};
}
// modify config
export function modifyConfig(config: Config, callback?: () => void) {
return function(dispatch: Dispatch) {
dispatch(modifyConfigPending(config));
return apiClient
.put(config._links.update.href, config, CONTENT_TYPE_CONFIG)
.then(() => {
dispatch(modifyConfigSuccess(config));
if (callback) {
callback();
}
})
.catch(err => {
dispatch(modifyConfigFailure(config, err));
});
};
}
export function modifyConfigPending(config: Config): Action {
return {
type: MODIFY_CONFIG_PENDING,
payload: config
};
}
export function modifyConfigSuccess(config: Config): Action {
return {
type: MODIFY_CONFIG_SUCCESS,
payload: config
};
}
export function modifyConfigFailure(config: Config, error: Error): Action {
return {
type: MODIFY_CONFIG_FAILURE,
payload: {
error,
config
}
};
}
export function modifyConfigReset() {
return {
type: MODIFY_CONFIG_RESET
};
}
//reducer
function removeNullValues(config: Config) {
if (!config.adminGroups) {
config.adminGroups = [];
}
if (!config.adminUsers) {
config.adminUsers = [];
}
if (!config.proxyExcludes) {
config.proxyExcludes = [];
}
return config;
}
function reducer(state: any = {}, action: any = {}) {
switch (action.type) {
case MODIFY_CONFIG_SUCCESS:
case FETCH_CONFIG_SUCCESS:
const config = removeNullValues(action.payload);
return {
...state,
entries: config,
configUpdatePermission: action.payload._links.update ? true : false
};
default:
return state;
}
}
export default reducer;
// selectors
export function isFetchConfigPending(state: Object) {
return isPending(state, FETCH_CONFIG);
}
export function getFetchConfigFailure(state: Object) {
return getFailure(state, FETCH_CONFIG);
}
export function isModifyConfigPending(state: Object) {
return isPending(state, MODIFY_CONFIG);
}
export function getModifyConfigFailure(state: Object) {
return getFailure(state, MODIFY_CONFIG);
}
export function getConfig(state: Object) {
if (state.config && state.config.entries) {
return state.config.entries;
}
}
export function getConfigUpdatePermission(state: Object) {
if (state.config && state.config.configUpdatePermission) {
return state.config.configUpdatePermission;
}
}

View File

@@ -0,0 +1,303 @@
//@flow
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
FETCH_CONFIG,
FETCH_CONFIG_PENDING,
FETCH_CONFIG_SUCCESS,
FETCH_CONFIG_FAILURE,
MODIFY_CONFIG,
MODIFY_CONFIG_PENDING,
MODIFY_CONFIG_SUCCESS,
MODIFY_CONFIG_FAILURE,
fetchConfig,
fetchConfigSuccess,
getFetchConfigFailure,
isFetchConfigPending,
modifyConfig,
isModifyConfigPending,
getModifyConfigFailure,
getConfig,
getConfigUpdatePermission
} from "./config";
const CONFIG_URL = "/config";
const URL = "/api/v2" + CONFIG_URL;
const error = new Error("You have an error!");
const config = {
proxyPassword: null,
proxyPort: 8080,
proxyServer: "proxy.mydomain.com",
proxyUser: null,
enableProxy: false,
realmDescription: "SONIA :: SCM Manager",
disableGroupingGrid: false,
dateFormat: "YYYY-MM-DD HH:mm:ss",
anonymousAccessEnabled: false,
adminGroups: [],
adminUsers: [],
baseUrl: "http://localhost:8081",
forceBaseUrl: false,
loginAttemptLimit: -1,
proxyExcludes: [],
skipFailedAuthenticators: false,
pluginUrl:
"http://plugins.scm-manager.org/scm-plugin-backend/api/{version}/plugins?os={os}&arch={arch}&snapshot=false",
loginAttemptLimitTimeout: 300,
enabledXsrfProtection: true,
namespaceStrategy: "UsernameNamespaceStrategy",
_links: {
self: { href: "http://localhost:8081/api/v2/config" },
update: { href: "http://localhost:8081/api/v2/config" }
}
};
const configWithNullValues = {
proxyPassword: null,
proxyPort: 8080,
proxyServer: "proxy.mydomain.com",
proxyUser: null,
enableProxy: false,
realmDescription: "SONIA :: SCM Manager",
disableGroupingGrid: false,
dateFormat: "YYYY-MM-DD HH:mm:ss",
anonymousAccessEnabled: false,
adminGroups: null,
adminUsers: null,
baseUrl: "http://localhost:8081",
forceBaseUrl: false,
loginAttemptLimit: -1,
proxyExcludes: null,
skipFailedAuthenticators: false,
pluginUrl:
"http://plugins.scm-manager.org/scm-plugin-backend/api/{version}/plugins?os={os}&arch={arch}&snapshot=false",
loginAttemptLimitTimeout: 300,
enabledXsrfProtection: true,
namespaceStrategy: "UsernameNamespaceStrategy",
_links: {
self: { href: "http://localhost:8081/api/v2/config" },
update: { href: "http://localhost:8081/api/v2/config" }
}
};
const responseBody = {
entries: config,
configUpdatePermission: false
};
const response = {
headers: { "content-type": "application/json" },
responseBody
};
describe("config fetch()", () => {
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should successfully fetch config", () => {
fetchMock.getOnce(URL, response);
const expectedActions = [
{ type: FETCH_CONFIG_PENDING },
{
type: FETCH_CONFIG_SUCCESS,
payload: response
}
];
const store = mockStore({
indexResources: {
links: {
config: {
href: CONFIG_URL
}
}
}
});
return store.dispatch(fetchConfig(CONFIG_URL)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fail getting config on HTTP 500", () => {
fetchMock.getOnce(URL, {
status: 500
});
const store = mockStore({
indexResources: {
links: {
config: {
href: CONFIG_URL
}
}
}
});
return store.dispatch(fetchConfig(CONFIG_URL)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_CONFIG_PENDING);
expect(actions[1].type).toEqual(FETCH_CONFIG_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should successfully modify config", () => {
fetchMock.putOnce("http://localhost:8081/api/v2/config", {
status: 204
});
const store = mockStore({});
return store.dispatch(modifyConfig(config)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_CONFIG_PENDING);
expect(actions[1].type).toEqual(MODIFY_CONFIG_SUCCESS);
expect(actions[1].payload).toEqual(config);
});
});
it("should call the callback after modifying config", () => {
fetchMock.putOnce("http://localhost:8081/api/v2/config", {
status: 204
});
let called = false;
const callback = () => {
called = true;
};
const store = mockStore({});
return store.dispatch(modifyConfig(config, callback)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_CONFIG_PENDING);
expect(actions[1].type).toEqual(MODIFY_CONFIG_SUCCESS);
expect(called).toBe(true);
});
});
it("should fail modifying config on HTTP 500", () => {
fetchMock.putOnce("http://localhost:8081/api/v2/config", {
status: 500
});
const store = mockStore({});
return store.dispatch(modifyConfig(config)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_CONFIG_PENDING);
expect(actions[1].type).toEqual(MODIFY_CONFIG_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
});
describe("config reducer", () => {
it("should update state correctly according to FETCH_CONFIG_SUCCESS action", () => {
const newState = reducer({}, fetchConfigSuccess(config));
expect(newState).toEqual({
entries: config,
configUpdatePermission: true
});
});
it("should set configUpdatePermission to true if update link is present", () => {
const newState = reducer({}, fetchConfigSuccess(config));
expect(newState.configUpdatePermission).toBeTruthy();
});
it("should update state according to FETCH_CONFIG_SUCCESS action", () => {
const newState = reducer({}, fetchConfigSuccess(config));
expect(newState.entries).toBe(config);
});
it("should return empty arrays for null values", () => {
// $FlowFixMe
const config = reducer({}, fetchConfigSuccess(configWithNullValues))
.entries;
expect(config.adminUsers).toEqual([]);
expect(config.adminGroups).toEqual([]);
expect(config.proxyExcludes).toEqual([]);
});
});
describe("selector tests", () => {
it("should return true, when fetch config is pending", () => {
const state = {
pending: {
[FETCH_CONFIG]: true
}
};
expect(isFetchConfigPending(state)).toEqual(true);
});
it("should return false, when fetch config is not pending", () => {
expect(isFetchConfigPending({})).toEqual(false);
});
it("should return error when fetch config did fail", () => {
const state = {
failure: {
[FETCH_CONFIG]: error
}
};
expect(getFetchConfigFailure(state)).toEqual(error);
});
it("should return undefined when fetch config did not fail", () => {
expect(getFetchConfigFailure({})).toBe(undefined);
});
it("should return true, when modify group is pending", () => {
const state = {
pending: {
[MODIFY_CONFIG]: true
}
};
expect(isModifyConfigPending(state)).toEqual(true);
});
it("should return false, when modify config is not pending", () => {
expect(isModifyConfigPending({})).toEqual(false);
});
it("should return error when modify config did fail", () => {
const state = {
failure: {
[MODIFY_CONFIG]: error
}
};
expect(getModifyConfigFailure(state)).toEqual(error);
});
it("should return undefined when modify config did not fail", () => {
expect(getModifyConfigFailure({})).toBe(undefined);
});
it("should return config", () => {
const state = {
config: {
entries: config
}
};
expect(getConfig(state)).toEqual(config);
});
it("should return configUpdatePermission", () => {
const state = {
config: {
configUpdatePermission: true
}
};
expect(getConfigUpdatePermission(state)).toEqual(true);
});
});

View File

@@ -0,0 +1,114 @@
// @flow
import * as types from "../../modules/types";
import type { Action, NamespaceStrategies } from "@scm-manager/ui-types";
import { apiClient } from "@scm-manager/ui-components";
import { isPending } from "../../modules/pending";
import { getFailure } from "../../modules/failure";
import { MODIFY_CONFIG_SUCCESS } from "./config";
export const FETCH_NAMESPACESTRATEGIES_TYPES =
"scm/config/FETCH_NAMESPACESTRATEGIES_TYPES";
export const FETCH_NAMESPACESTRATEGIES_TYPES_PENDING = `${FETCH_NAMESPACESTRATEGIES_TYPES}_${
types.PENDING_SUFFIX
}`;
export const FETCH_NAMESPACESTRATEGIES_TYPES_SUCCESS = `${FETCH_NAMESPACESTRATEGIES_TYPES}_${
types.SUCCESS_SUFFIX
}`;
export const FETCH_NAMESPACESTRATEGIES_TYPES_FAILURE = `${FETCH_NAMESPACESTRATEGIES_TYPES}_${
types.FAILURE_SUFFIX
}`;
export function fetchNamespaceStrategiesIfNeeded() {
return function(dispatch: any, getState: () => Object) {
const state = getState();
if (shouldFetchNamespaceStrategies(state)) {
return fetchNamespaceStrategies(
dispatch,
state.indexResources.links.namespaceStrategies.href
);
}
};
}
function fetchNamespaceStrategies(dispatch: any, url: string) {
dispatch(fetchNamespaceStrategiesPending());
return apiClient
.get(url)
.then(response => response.json())
.then(namespaceStrategies => {
dispatch(fetchNamespaceStrategiesSuccess(namespaceStrategies));
})
.catch(error => {
dispatch(fetchNamespaceStrategiesFailure(error));
});
}
export function shouldFetchNamespaceStrategies(state: Object) {
if (
isFetchNamespaceStrategiesPending(state) ||
getFetchNamespaceStrategiesFailure(state)
) {
return false;
}
return !state.namespaceStrategies || !state.namespaceStrategies.current;
}
export function fetchNamespaceStrategiesPending(): Action {
return {
type: FETCH_NAMESPACESTRATEGIES_TYPES_PENDING
};
}
export function fetchNamespaceStrategiesSuccess(
namespaceStrategies: NamespaceStrategies
): Action {
return {
type: FETCH_NAMESPACESTRATEGIES_TYPES_SUCCESS,
payload: namespaceStrategies
};
}
export function fetchNamespaceStrategiesFailure(error: Error): Action {
return {
type: FETCH_NAMESPACESTRATEGIES_TYPES_FAILURE,
payload: error
};
}
// reducers
export default function reducer(
state: Object = {},
action: Action = { type: "UNKNOWN" }
): Object {
if (
action.type === FETCH_NAMESPACESTRATEGIES_TYPES_SUCCESS &&
action.payload
) {
return action.payload;
} else if (action.type === MODIFY_CONFIG_SUCCESS && action.payload) {
const config = action.payload;
return {
...state,
current: config.namespaceStrategy
};
}
return state;
}
// selectors
export function getNamespaceStrategies(state: Object) {
if (state.namespaceStrategies) {
return state.namespaceStrategies;
}
return {};
}
export function isFetchNamespaceStrategiesPending(state: Object) {
return isPending(state, FETCH_NAMESPACESTRATEGIES_TYPES);
}
export function getFetchNamespaceStrategiesFailure(state: Object) {
return getFailure(state, FETCH_NAMESPACESTRATEGIES_TYPES);
}

View File

@@ -0,0 +1,199 @@
// @flow
import fetchMock from "fetch-mock";
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import {
FETCH_NAMESPACESTRATEGIES_TYPES,
FETCH_NAMESPACESTRATEGIES_TYPES_FAILURE,
FETCH_NAMESPACESTRATEGIES_TYPES_PENDING,
FETCH_NAMESPACESTRATEGIES_TYPES_SUCCESS,
fetchNamespaceStrategiesIfNeeded,
fetchNamespaceStrategiesSuccess,
shouldFetchNamespaceStrategies,
default as reducer,
getNamespaceStrategies,
isFetchNamespaceStrategiesPending,
getFetchNamespaceStrategiesFailure
} from "./namespaceStrategies";
import { MODIFY_CONFIG_SUCCESS } from "./config";
const strategies = {
current: "UsernameNamespaceStrategy",
available: [
"UsernameNamespaceStrategy",
"CustomNamespaceStrategy",
"CurrentYearNamespaceStrategy",
"RepositoryTypeNamespaceStrategy"
],
_links: {
self: {
href: "http://localhost:8081/scm/api/v2/namespaceStrategies"
}
}
};
describe("namespace strategy caching", () => {
it("should fetch strategies, on empty state", () => {
expect(shouldFetchNamespaceStrategies({})).toBe(true);
});
it("should fetch strategies, on empty namespaceStrategies node", () => {
const state = {
namespaceStrategies: {}
};
expect(shouldFetchNamespaceStrategies(state)).toBe(true);
});
it("should not fetch strategies, on pending state", () => {
const state = {
pending: {
[FETCH_NAMESPACESTRATEGIES_TYPES]: true
}
};
expect(shouldFetchNamespaceStrategies(state)).toBe(false);
});
it("should not fetch strategies, on failure state", () => {
const state = {
failure: {
[FETCH_NAMESPACESTRATEGIES_TYPES]: new Error("no...")
}
};
expect(shouldFetchNamespaceStrategies(state)).toBe(false);
});
it("should not fetch strategies, if they are already fetched", () => {
const state = {
namespaceStrategies: {
current: "some"
}
};
expect(shouldFetchNamespaceStrategies(state)).toBe(false);
});
});
describe("namespace strategies fetch", () => {
const URL = "http://scm.hitchhiker.com/api/v2/namespaceStrategies";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
const createStore = (initialState = {}) => {
return mockStore({
...initialState,
indexResources: {
links: {
namespaceStrategies: {
href: URL
}
}
}
});
};
it("should successfully fetch strategies", () => {
fetchMock.getOnce(URL, strategies);
const expectedActions = [
{ type: FETCH_NAMESPACESTRATEGIES_TYPES_PENDING },
{
type: FETCH_NAMESPACESTRATEGIES_TYPES_SUCCESS,
payload: strategies
}
];
const store = createStore();
return store.dispatch(fetchNamespaceStrategiesIfNeeded()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_NAMESPACESTRATEGIES_TYPES_FAILURE on server error", () => {
fetchMock.getOnce(URL, {
status: 500
});
const store = createStore();
return store.dispatch(fetchNamespaceStrategiesIfNeeded()).then(() => {
const actions = store.getActions();
expect(actions[0].type).toBe(FETCH_NAMESPACESTRATEGIES_TYPES_PENDING);
expect(actions[1].type).toBe(FETCH_NAMESPACESTRATEGIES_TYPES_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should not dispatch any action, if the strategies are already fetched", () => {
const store = createStore({
namespaceStrategies: strategies
});
store.dispatch(fetchNamespaceStrategiesIfNeeded());
expect(store.getActions().length).toBe(0);
});
});
describe("namespace strategies reducer", () => {
it("should return unmodified state on unknown action", () => {
const state = {};
expect(reducer(state)).toBe(state);
});
it("should store the strategies on success", () => {
const newState = reducer({}, fetchNamespaceStrategiesSuccess(strategies));
expect(newState).toBe(strategies);
});
it("should clear store if config was modified", () => {
const modifyConfigAction = {
type: MODIFY_CONFIG_SUCCESS,
payload: {
namespaceStrategy: "CustomNamespaceStrategy"
}
};
const newState = reducer(strategies, modifyConfigAction);
expect(newState.current).toEqual("CustomNamespaceStrategy");
});
});
describe("namespace strategy selectors", () => {
const error = new Error("The end of the universe");
it("should return an empty object", () => {
expect(getNamespaceStrategies({})).toEqual({});
});
it("should return the namespace strategies", () => {
const state = {
namespaceStrategies: strategies
};
expect(getNamespaceStrategies(state)).toBe(strategies);
});
it("should return true, when fetch namespace strategies is pending", () => {
const state = {
pending: {
[FETCH_NAMESPACESTRATEGIES_TYPES]: true
}
};
expect(isFetchNamespaceStrategiesPending(state)).toEqual(true);
});
it("should return false, when fetch strategies is not pending", () => {
expect(isFetchNamespaceStrategiesPending({})).toEqual(false);
});
it("should return error when fetch namespace strategies did fail", () => {
const state = {
failure: {
[FETCH_NAMESPACESTRATEGIES_TYPES]: error
}
};
expect(getFetchNamespaceStrategiesFailure(state)).toEqual(error);
});
it("should return undefined when fetch strategies did not fail", () => {
expect(getFetchNamespaceStrategiesFailure({})).toBe(undefined);
});
});

View File

@@ -0,0 +1,68 @@
// @flow
import React from "react";
import { Button } from "@scm-manager/ui-components";
import type { PendingPlugins } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
import ExecutePendingModal from "./ExecutePendingModal";
type Props = {
pendingPlugins: PendingPlugins,
// context props
t: string => string
};
type State = {
showModal: boolean
};
class ExecutePendingAction extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
showModal: false
};
}
openModal = () => {
this.setState({
showModal: true
});
};
closeModal = () => {
this.setState({
showModal: false
});
};
renderModal = () => {
const { showModal } = this.state;
const { pendingPlugins } = this.props;
if (showModal) {
return (
<ExecutePendingModal
pendingPlugins={pendingPlugins}
onClose={this.closeModal}
/>
);
}
return null;
};
render() {
const { t } = this.props;
return (
<>
{this.renderModal()}
<Button
color="primary"
label={t("plugins.executePending")}
action={this.openModal}
/>
</>
);
}
}
export default translate("admin")(ExecutePendingAction);

View File

@@ -0,0 +1,185 @@
// @flow
import React from "react";
import {
apiClient,
Button,
ButtonGroup,
ErrorNotification,
Modal,
Notification
} from "@scm-manager/ui-components";
import type { PendingPlugins } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
import waitForRestart from "./waitForRestart";
import SuccessNotification from "./SuccessNotification";
type Props = {
onClose: () => void,
pendingPlugins: PendingPlugins,
// context props
t: string => string
};
type State = {
loading: boolean,
success: boolean,
error?: Error
};
class ExecutePendingModal extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loading: false,
success: false
};
}
renderNotifications = () => {
const { t } = this.props;
const { error, success } = this.state;
if (error) {
return <ErrorNotification error={error} />;
} else if (success) {
return <SuccessNotification />;
} else {
return (
<Notification type="warning">
{t("plugins.modal.restartNotification")}
</Notification>
);
}
};
executeAndRestart = () => {
const { pendingPlugins } = this.props;
this.setState({
loading: true
});
apiClient
.post(pendingPlugins._links.execute.href)
.then(waitForRestart)
.then(() => {
this.setState({
success: true,
loading: false,
error: undefined
});
})
.catch(error => {
this.setState({
success: false,
loading: false,
error: error
});
});
};
renderInstallQueue = () => {
const { pendingPlugins, t } = this.props;
return (
<>
{pendingPlugins._embedded &&
pendingPlugins._embedded.new.length > 0 && (
<>
<strong>{t("plugins.modal.installQueue")}</strong>
<ul>
{pendingPlugins._embedded.new.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
</ul>
</>
)}
</>
);
};
renderUpdateQueue = () => {
const { pendingPlugins, t } = this.props;
return (
<>
{pendingPlugins._embedded &&
pendingPlugins._embedded.update.length > 0 && (
<>
<strong>{t("plugins.modal.updateQueue")}</strong>
<ul>
{pendingPlugins._embedded.update.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
</ul>
</>
)}
</>
);
};
renderUninstallQueue = () => {
const { pendingPlugins, t } = this.props;
return (
<>
{pendingPlugins._embedded &&
pendingPlugins._embedded.uninstall.length > 0 && (
<>
<strong>{t("plugins.modal.uninstallQueue")}</strong>
<ul>
{pendingPlugins._embedded.uninstall.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
</ul>
</>
)}
</>
);
};
renderBody = () => {
const { t } = this.props;
return (
<>
<div className="media">
<div className="content">
<p>{t("plugins.modal.executePending")}</p>
{this.renderInstallQueue()}
{this.renderUpdateQueue()}
{this.renderUninstallQueue()}
</div>
</div>
<div className="media">{this.renderNotifications()}</div>
</>
);
};
renderFooter = () => {
const { onClose, t } = this.props;
const { loading, error, success } = this.state;
return (
<ButtonGroup>
<Button
color="warning"
label={t("plugins.modal.executeAndRestart")}
loading={loading}
action={this.executeAndRestart}
disabled={error || success}
/>
<Button label={t("plugins.modal.abort")} action={onClose} />
</ButtonGroup>
);
};
render() {
const { onClose, t } = this.props;
return (
<Modal
title={t("plugins.modal.executeAndRestart")}
closeFunction={onClose}
body={this.renderBody()}
footer={this.renderFooter()}
active={true}
/>
);
}
}
export default translate("admin")(ExecutePendingModal);

View File

@@ -0,0 +1,22 @@
//@flow
import React from "react";
import {ExtensionPoint} from "@scm-manager/ui-extensions";
import type {Plugin} from "@scm-manager/ui-types";
import {Image} from "@scm-manager/ui-components";
type Props = {
plugin: Plugin
};
export default class PluginAvatar extends React.Component<Props> {
render() {
const { plugin } = this.props;
return (
<p className="image is-64x64">
<ExtensionPoint name="plugins.plugin-avatar" props={{ plugin }}>
<Image src={plugin.avatarUrl ? plugin.avatarUrl : "/images/blib.jpg"} alt="Logo" />
</ExtensionPoint>
</p>
);
}
}

View File

@@ -0,0 +1,30 @@
// @flow
import * as React from "react";
import classNames from "classnames";
import injectSheet from "react-jss";
const styles = {
container: {
border: "2px solid #e9f7fd",
padding: "1em 1em",
marginTop: "2em",
display: "flex",
justifyContent: "center"
}
};
type Props = {
children?: React.Node,
// context props
classes: any
};
class PluginBottomActions extends React.Component<Props> {
render() {
const { children, classes } = this.props;
return <div className={classNames(classes.container)}>{children}</div>;
}
}
export default injectSheet(styles)(PluginBottomActions);

View File

@@ -0,0 +1,205 @@
//@flow
import React from "react";
import injectSheet from "react-jss";
import { translate } from "react-i18next";
import type { Plugin } from "@scm-manager/ui-types";
import { CardColumn, Icon } from "@scm-manager/ui-components";
import PluginAvatar from "./PluginAvatar";
import classNames from "classnames";
import PluginModal from "./PluginModal";
export const PluginAction = {
INSTALL: "install",
UPDATE: "update",
UNINSTALL: "uninstall"
};
type Props = {
plugin: Plugin,
refresh: () => void,
// context props
classes: any,
t: string => string
};
type State = {
showInstallModal: boolean,
showUpdateModal: boolean,
showUninstallModal: boolean
};
const styles = {
link: {
cursor: "pointer",
pointerEvents: "all",
marginBottom: "0 !important",
padding: "0.5rem",
border: "solid 1px #cdcdcd", // $dark-25
borderRadius: "4px",
"&:hover": {
borderColor: "#9a9a9a" // $dark-50
}
},
actionbar: {
display: "flex",
"& span + span": {
marginLeft: "0.5rem"
}
}
};
class PluginEntry extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
showInstallModal: false,
showUpdateModal: false,
showUninstallModal: false
};
}
createAvatar = (plugin: Plugin) => {
return <PluginAvatar plugin={plugin} />;
};
toggleModal = (showModal: string) => {
const oldValue = this.state[showModal];
this.setState({ [showModal]: !oldValue });
};
createFooterRight = (plugin: Plugin) => {
return <small className="level-item">{plugin.author}</small>;
};
isInstallable = () => {
const { plugin } = this.props;
return plugin._links && plugin._links.install && plugin._links.install.href;
};
isUpdatable = () => {
const { plugin } = this.props;
return plugin._links && plugin._links.update && plugin._links.update.href;
};
isUninstallable = () => {
const { plugin } = this.props;
return (
plugin._links && plugin._links.uninstall && plugin._links.uninstall.href
);
};
createActionbar = () => {
const { classes, t } = this.props;
return (
<div className={classes.actionbar}>
{this.isInstallable() && (
<span
className={classNames(classes.link, "level-item")}
onClick={() => this.toggleModal("showInstallModal")}
>
<Icon title={t("plugins.modal.install")} name="download" color="info" />
</span>
)}
{this.isUninstallable() && (
<span
className={classNames(classes.link, "level-item")}
onClick={() => this.toggleModal("showUninstallModal")}
>
<Icon title={t("plugins.modal.uninstall")} name="trash" color="info" />
</span>
)}
{this.isUpdatable() && (
<span
className={classNames(classes.link, "level-item")}
onClick={() => this.toggleModal("showUpdateModal")}
>
<Icon title={t("plugins.modal.update")} name="sync-alt" color="info" />
</span>
)}
</div>
);
};
renderModal = () => {
const { plugin, refresh } = this.props;
if (this.state.showInstallModal && this.isInstallable()) {
return (
<PluginModal
plugin={plugin}
pluginAction={PluginAction.INSTALL}
refresh={refresh}
onClose={() => this.toggleModal("showInstallModal")}
/>
);
} else if (this.state.showUpdateModal && this.isUpdatable()) {
return (
<PluginModal
plugin={plugin}
pluginAction={PluginAction.UPDATE}
refresh={refresh}
onClose={() => this.toggleModal("showUpdateModal")}
/>
);
} else if (this.state.showUninstallModal && this.isUninstallable()) {
return (
<PluginModal
plugin={plugin}
pluginAction={PluginAction.UNINSTALL}
refresh={refresh}
onClose={() => this.toggleModal("showUninstallModal")}
/>
);
} else {
return null;
}
};
createPendingSpinner = () => {
const { plugin, classes } = this.props;
return (
<span className={classes.topRight}>
<i
className={classNames(
"fas fa-spinner fa-lg fa-spin",
plugin.markedForUninstall ? "has-text-danger" : "has-text-info"
)}
/>
</span>
);
};
render() {
const { plugin } = this.props;
const avatar = this.createAvatar(plugin);
const actionbar = this.createActionbar();
const footerRight = this.createFooterRight(plugin);
const modal = this.renderModal();
return (
<>
<CardColumn
action={
this.isInstallable()
? () => this.toggleModal("showInstallModal")
: null
}
avatar={avatar}
title={plugin.displayName ? plugin.displayName : plugin.name}
description={plugin.description}
contentRight={
plugin.pending || plugin.markedForUninstall
? this.createPendingSpinner()
: actionbar
}
footerRight={footerRight}
/>
{modal}
</>
);
}
}
export default injectSheet(styles)(translate("admin")(PluginEntry));

View File

@@ -0,0 +1,22 @@
//@flow
import React from "react";
import { CardColumnGroup } from "@scm-manager/ui-components";
import type { PluginGroup } from "@scm-manager/ui-types";
import PluginEntry from "./PluginEntry";
type Props = {
group: PluginGroup,
refresh: () => void
};
class PluginGroupEntry extends React.Component<Props> {
render() {
const { group, refresh } = this.props;
const entries = group.plugins.map(plugin => {
return <PluginEntry plugin={plugin} key={plugin.name} refresh={refresh} />;
});
return <CardColumnGroup name={group.name} elements={entries} />;
}
}
export default PluginGroupEntry;

View File

@@ -0,0 +1,27 @@
//@flow
import React from "react";
import type { Plugin } from "@scm-manager/ui-types";
import PluginGroupEntry from "../components/PluginGroupEntry";
import groupByCategory from "./groupByCategory";
type Props = {
plugins: Plugin[],
refresh: () => void
};
class PluginList extends React.Component<Props> {
render() {
const { plugins, refresh } = this.props;
const groups = groupByCategory(plugins);
return (
<div className="content is-plugin-page">
{groups.map(group => {
return <PluginGroupEntry group={group} key={group.name} refresh={refresh} />;
})}
</div>
);
}
}
export default PluginList;

View File

@@ -0,0 +1,339 @@
//@flow
import React from "react";
import { compose } from "redux";
import { translate } from "react-i18next";
import injectSheet from "react-jss";
import type { Plugin } from "@scm-manager/ui-types";
import {
apiClient,
Button,
ButtonGroup,
Checkbox,
ErrorNotification,
Modal,
Notification
} from "@scm-manager/ui-components";
import classNames from "classnames";
import waitForRestart from "./waitForRestart";
import SuccessNotification from "./SuccessNotification";
import { PluginAction } from "./PluginEntry";
type Props = {
plugin: Plugin,
pluginAction: string,
refresh: () => void,
onClose: () => void,
// context props
classes: any,
t: (key: string, params?: Object) => string
};
type State = {
success: boolean,
restart: boolean,
loading: boolean,
error?: Error
};
const styles = {
userLabelAlignment: {
textAlign: "left",
marginRight: 0
},
userLabelMarginSmall: {
minWidth: "5.5em"
},
userLabelMarginLarge: {
minWidth: "10em"
},
userFieldFlex: {
flexGrow: 4
}
};
class PluginModal extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loading: false,
restart: false,
success: false
};
}
onSuccess = () => {
const { restart } = this.state;
const { refresh, onClose } = this.props;
const newState = {
loading: false,
error: undefined
};
if (restart) {
waitForRestart()
.then(() => {
this.setState({
...newState,
success: true
});
})
.catch(error => {
this.setState({
loading: false,
success: false,
error
});
});
} else {
this.setState(newState, () => {
refresh();
onClose();
});
}
};
createPluginActionLink = () => {
const { plugin, pluginAction } = this.props;
const { restart } = this.state;
let pluginActionLink = "";
if (pluginAction === PluginAction.INSTALL) {
pluginActionLink = plugin._links.install.href;
} else if (pluginAction === PluginAction.UPDATE) {
pluginActionLink = plugin._links.update.href;
} else if (pluginAction === PluginAction.UNINSTALL) {
pluginActionLink = plugin._links.uninstall.href;
}
return pluginActionLink + "?restart=" + restart.toString();
};
handlePluginAction = (e: Event) => {
this.setState({
loading: true
});
e.preventDefault();
apiClient
.post(this.createPluginActionLink())
.then(this.onSuccess)
.catch(error => {
this.setState({
loading: false,
error: error
});
});
};
footer = () => {
const { pluginAction, onClose, t } = this.props;
const { loading, error, restart, success } = this.state;
let color = pluginAction === PluginAction.UNINSTALL ? "warning" : "primary";
let label = `plugins.modal.${pluginAction}`;
if (restart) {
color = "warning";
label = `plugins.modal.${pluginAction}AndRestart`;
}
return (
<ButtonGroup>
<Button
label={t(label)}
color={color}
action={this.handlePluginAction}
loading={loading}
disabled={!!error || success}
/>
<Button label={t("plugins.modal.abort")} action={onClose} />
</ButtonGroup>
);
};
renderDependencies() {
const { plugin, classes, t } = this.props;
let dependencies = null;
if (plugin.dependencies && plugin.dependencies.length > 0) {
dependencies = (
<div className="media">
<Notification type="warning">
<strong>{t("plugins.modal.dependencyNotification")}</strong>
<ul className={classes.listSpacing}>
{plugin.dependencies.map((dependency, index) => {
return <li key={index}>{dependency}</li>;
})}
</ul>
</Notification>
</div>
);
}
return dependencies;
}
renderNotifications = () => {
const { t } = this.props;
const { restart, error, success } = this.state;
if (error) {
return (
<div className="media">
<ErrorNotification error={error} />
</div>
);
} else if (success) {
return (
<div className="media">
<SuccessNotification />
</div>
);
} else if (restart) {
return (
<div className="media">
<Notification type="warning">
{t("plugins.modal.restartNotification")}
</Notification>
</div>
);
}
return null;
};
handleRestartChange = (value: boolean) => {
this.setState({
restart: value
});
};
render() {
const { restart } = this.state;
const { plugin, pluginAction, onClose, classes, t } = this.props;
const body = (
<>
<div className="media">
<div className="media-content">
<p>{plugin.description}</p>
</div>
</div>
<div className="media">
<div className="media-content">
<div className="field is-horizontal">
<div
className={classNames(
classes.userLabelAlignment,
pluginAction === PluginAction.INSTALL
? classes.userLabelMarginSmall
: classes.userLabelMarginLarge,
"field-label is-inline-flex"
)}
>
{t("plugins.modal.author")}:
</div>
<div
className={classNames(
classes.userFieldFlex,
"field-body is-inline-flex"
)}
>
{plugin.author}
</div>
</div>
{pluginAction === PluginAction.INSTALL && (
<div className="field is-horizontal">
<div
className={classNames(
classes.userLabelAlignment,
classes.userLabelMarginSmall,
"field-label is-inline-flex"
)}
>
{t("plugins.modal.version")}:
</div>
<div
className={classNames(
classes.userFieldFlex,
"field-body is-inline-flex"
)}
>
{plugin.version}
</div>
</div>
)}
{(pluginAction === PluginAction.UPDATE ||
pluginAction === PluginAction.UNINSTALL) && (
<div className="field is-horizontal">
<div
className={classNames(
classes.userLabelAlignment,
classes.userLabelMarginLarge,
"field-label is-inline-flex"
)}
>
{t("plugins.modal.currentVersion")}:
</div>
<div
className={classNames(
classes.userFieldFlex,
"field-body is-inline-flex"
)}
>
{plugin.version}
</div>
</div>
)}
{pluginAction === PluginAction.UPDATE && (
<div className="field is-horizontal">
<div
className={classNames(
classes.userLabelAlignment,
classes.userLabelMarginLarge,
"field-label is-inline-flex"
)}
>
{t("plugins.modal.newVersion")}:
</div>
<div
className={classNames(
classes.userFieldFlex,
"field-body is-inline-flex"
)}
>
{plugin.newVersion}
</div>
</div>
)}
{this.renderDependencies()}
</div>
</div>
<div className="media">
<div className="media-content">
<Checkbox
checked={restart}
label={t("plugins.modal.restart")}
onChange={this.handleRestartChange}
disabled={false}
/>
</div>
</div>
{this.renderNotifications()}
</>
);
return (
<Modal
title={t(`plugins.modal.title.${pluginAction}`, {
name: plugin.displayName ? plugin.displayName : plugin.name
})}
closeFunction={() => onClose()}
body={body}
footer={this.footer()}
active={true}
/>
);
}
}
export default compose(
injectSheet(styles),
translate("admin")
)(PluginModal);

View File

@@ -0,0 +1,32 @@
// @flow
import * as React from "react";
import classNames from "classnames";
import injectSheet from "react-jss";
const styles = {
container: {
display: "flex",
justifyContent: "flex-end",
alignItems: "center"
}
};
type Props = {
children?: React.Node,
// context props
classes: any
};
class PluginTopActions extends React.Component<Props> {
render() {
const { children, classes } = this.props;
return (
<div className={classNames(classes.container, "column", "is-one-fifths", "is-mobile-action-spacing")}>
{children}
</div>
);
}
}
export default injectSheet(styles)(PluginTopActions);

View File

@@ -0,0 +1,25 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { Notification } from "@scm-manager/ui-components";
type Props = {
// context props
t: string => string
};
class InstallSuccessNotification extends React.Component<Props> {
render() {
const { t } = this.props;
return (
<Notification type="success">
{t("plugins.modal.successNotification")}{" "}
<a onClick={e => window.location.reload(true)}>
{t("plugins.modal.reload")}
</a>
</Notification>
);
}
}
export default translate("admin")(InstallSuccessNotification);

View File

@@ -0,0 +1,39 @@
// @flow
import type { Plugin, PluginGroup } from "@scm-manager/ui-types";
export default function groupByCategory(
plugins: Plugin[]
): PluginGroup[] {
let groups = {};
for (let plugin of plugins) {
const groupName = plugin.category;
let group = groups[groupName];
if (!group) {
group = {
name: groupName,
plugins: []
};
groups[groupName] = group;
}
group.plugins.push(plugin);
}
let groupArray = [];
for (let groupName in groups) {
const group = groups[groupName];
group.plugins.sort(sortByName);
groupArray.push(groups[groupName]);
}
groupArray.sort(sortByName);
return groupArray;
}
function sortByName(a, b) {
if (a.name < b.name) {
return -1;
} else if (a.name > b.name) {
return 1;
}
return 0;
}

View File

@@ -0,0 +1,30 @@
// @flow
import { apiClient } from "@scm-manager/ui-components";
const waitForRestart = () => {
const endTime = Number(new Date()) + 10000;
let started = false;
const executor = (resolve, reject) => {
// we need some initial delay
if (!started) {
started = true;
setTimeout(executor, 1000, resolve, reject);
} else {
apiClient
.get("")
.then(resolve)
.catch(() => {
if (Number(new Date()) < endTime) {
setTimeout(executor, 500, resolve, reject);
} else {
reject(new Error("timeout reached"));
}
});
}
};
return new Promise<void>(executor);
};
export default waitForRestart;

View File

@@ -0,0 +1,190 @@
// @flow
import * as React from "react";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import { compose } from "redux";
import type { PendingPlugins, PluginCollection } from "@scm-manager/ui-types";
import {
ErrorNotification,
Loading,
Notification,
Subtitle,
Title
} from "@scm-manager/ui-components";
import {
fetchPendingPlugins,
fetchPluginsByLink,
getFetchPluginsFailure,
getPendingPlugins,
getPluginCollection,
isFetchPluginsPending
} from "../modules/plugins";
import PluginsList from "../components/PluginList";
import {
getAvailablePluginsLink,
getInstalledPluginsLink,
getPendingPluginsLink
} from "../../../modules/indexResource";
import PluginTopActions from "../components/PluginTopActions";
import PluginBottomActions from "../components/PluginBottomActions";
import ExecutePendingAction from "../components/ExecutePendingAction";
type Props = {
loading: boolean,
error: Error,
collection: PluginCollection,
baseUrl: string,
installed: boolean,
availablePluginsLink: string,
installedPluginsLink: string,
pendingPluginsLink: string,
pendingPlugins: PendingPlugins,
// context objects
t: string => string,
// dispatched functions
fetchPluginsByLink: (link: string) => void,
fetchPendingPlugins: (link: string) => void
};
class PluginsOverview extends React.Component<Props> {
componentDidMount() {
this.fetchPlugins();
}
componentDidUpdate(prevProps) {
const { installed } = this.props;
if (prevProps.installed !== installed) {
this.fetchPlugins();
}
}
fetchPlugins = () => {
const {
installed,
fetchPluginsByLink,
availablePluginsLink,
installedPluginsLink,
pendingPluginsLink,
fetchPendingPlugins
} = this.props;
fetchPluginsByLink(installed ? installedPluginsLink : availablePluginsLink);
if (pendingPluginsLink) {
fetchPendingPlugins(pendingPluginsLink);
}
};
renderHeader = (actions: React.Node) => {
const { installed, t } = this.props;
return (
<div className="columns">
<div className="column">
<Title title={t("plugins.title")} />
<Subtitle
subtitle={
installed
? t("plugins.installedSubtitle")
: t("plugins.availableSubtitle")
}
/>
</div>
<PluginTopActions>{actions}</PluginTopActions>
</div>
);
};
renderFooter = (actions: React.Node) => {
if (actions) {
return <PluginBottomActions>{actions}</PluginBottomActions>;
}
return null;
};
createActions = () => {
const { pendingPlugins } = this.props;
if (
pendingPlugins &&
pendingPlugins._links &&
pendingPlugins._links.execute
) {
return <ExecutePendingAction pendingPlugins={pendingPlugins} />;
}
return null;
};
render() {
const { loading, error, collection } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (!collection || loading) {
return <Loading />;
}
const actions = this.createActions();
return (
<>
{this.renderHeader(actions)}
<hr className="header-with-actions" />
{this.renderPluginsList()}
{this.renderFooter(actions)}
</>
);
}
renderPluginsList() {
const { collection, t } = this.props;
if (collection._embedded && collection._embedded.plugins.length > 0) {
return (
<PluginsList
plugins={collection._embedded.plugins}
refresh={this.fetchPlugins}
/>
);
}
return <Notification type="info">{t("plugins.noPlugins")}</Notification>;
}
}
const mapStateToProps = state => {
const collection = getPluginCollection(state);
const loading = isFetchPluginsPending(state);
const error = getFetchPluginsFailure(state);
const availablePluginsLink = getAvailablePluginsLink(state);
const installedPluginsLink = getInstalledPluginsLink(state);
const pendingPluginsLink = getPendingPluginsLink(state);
const pendingPlugins = getPendingPlugins(state);
return {
collection,
loading,
error,
availablePluginsLink,
installedPluginsLink,
pendingPluginsLink,
pendingPlugins
};
};
const mapDispatchToProps = dispatch => {
return {
fetchPluginsByLink: (link: string) => {
dispatch(fetchPluginsByLink(link));
},
fetchPendingPlugins: (link: string) => {
dispatch(fetchPendingPlugins(link));
}
};
};
export default compose(
translate("admin"),
connect(
mapStateToProps,
mapDispatchToProps
)
)(PluginsOverview);

View File

@@ -0,0 +1,255 @@
// @flow
import * as types from "../../../modules/types";
import { isPending } from "../../../modules/pending";
import { getFailure } from "../../../modules/failure";
import type { Action, Plugin, PluginCollection } from "@scm-manager/ui-types";
import { apiClient } from "@scm-manager/ui-components";
export const FETCH_PLUGINS = "scm/plugins/FETCH_PLUGINS";
export const FETCH_PLUGINS_PENDING = `${FETCH_PLUGINS}_${types.PENDING_SUFFIX}`;
export const FETCH_PLUGINS_SUCCESS = `${FETCH_PLUGINS}_${types.SUCCESS_SUFFIX}`;
export const FETCH_PLUGINS_FAILURE = `${FETCH_PLUGINS}_${types.FAILURE_SUFFIX}`;
export const FETCH_PLUGIN = "scm/plugins/FETCH_PLUGIN";
export const FETCH_PLUGIN_PENDING = `${FETCH_PLUGIN}_${types.PENDING_SUFFIX}`;
export const FETCH_PLUGIN_SUCCESS = `${FETCH_PLUGIN}_${types.SUCCESS_SUFFIX}`;
export const FETCH_PLUGIN_FAILURE = `${FETCH_PLUGIN}_${types.FAILURE_SUFFIX}`;
export const FETCH_PENDING_PLUGINS = "scm/plugins/FETCH_PENDING_PLUGINS";
export const FETCH_PENDING_PLUGINS_PENDING = `${FETCH_PENDING_PLUGINS}_${
types.PENDING_SUFFIX
}`;
export const FETCH_PENDING_PLUGINS_SUCCESS = `${FETCH_PENDING_PLUGINS}_${
types.SUCCESS_SUFFIX
}`;
export const FETCH_PENDING_PLUGINS_FAILURE = `${FETCH_PENDING_PLUGINS}_${
types.FAILURE_SUFFIX
}`;
// fetch plugins
export function fetchPluginsByLink(link: string) {
return function(dispatch: any) {
dispatch(fetchPluginsPending());
return apiClient
.get(link)
.then(response => response.json())
.then(plugins => {
dispatch(fetchPluginsSuccess(plugins));
})
.catch(err => {
dispatch(fetchPluginsFailure(err));
});
};
}
export function fetchPluginsPending(): Action {
return {
type: FETCH_PLUGINS_PENDING
};
}
export function fetchPluginsSuccess(plugins: PluginCollection): Action {
return {
type: FETCH_PLUGINS_SUCCESS,
payload: plugins
};
}
export function fetchPluginsFailure(err: Error): Action {
return {
type: FETCH_PLUGINS_FAILURE,
payload: err
};
}
// fetch plugin
export function fetchPluginByLink(plugin: Plugin) {
return fetchPlugin(plugin._links.self.href, plugin.name);
}
export function fetchPluginByName(link: string, name: string) {
const pluginUrl = link.endsWith("/") ? link : link + "/";
return fetchPlugin(pluginUrl + name, name);
}
function fetchPlugin(link: string, name: string) {
return function(dispatch: any) {
dispatch(fetchPluginPending(name));
return apiClient
.get(link)
.then(response => response.json())
.then(plugin => {
dispatch(fetchPluginSuccess(plugin));
})
.catch(err => {
dispatch(fetchPluginFailure(name, err));
});
};
}
export function fetchPluginPending(name: string): Action {
return {
type: FETCH_PLUGIN_PENDING,
payload: {
name
},
itemId: name
};
}
export function fetchPluginSuccess(plugin: Plugin): Action {
return {
type: FETCH_PLUGIN_SUCCESS,
payload: plugin,
itemId: plugin.name
};
}
export function fetchPluginFailure(name: string, error: Error): Action {
return {
type: FETCH_PLUGIN_FAILURE,
payload: {
name,
error
},
itemId: name
};
}
// fetch pending plugins
export function fetchPendingPlugins(link: string) {
return function(dispatch: any) {
dispatch(fetchPendingPluginsPending());
return apiClient
.get(link)
.then(response => response.json())
.then(PendingPlugins => {
dispatch(fetchPendingPluginsSuccess(PendingPlugins));
})
.catch(err => {
dispatch(fetchPendingPluginsFailure(err));
});
};
}
export function fetchPendingPluginsPending(): Action {
return {
type: FETCH_PENDING_PLUGINS_PENDING
};
}
export function fetchPendingPluginsSuccess(PendingPlugins: {}): Action {
return {
type: FETCH_PENDING_PLUGINS_SUCCESS,
payload: PendingPlugins
};
}
export function fetchPendingPluginsFailure(err: Error): Action {
return {
type: FETCH_PENDING_PLUGINS_FAILURE,
payload: err
};
}
// reducer
function normalizeByName(state: Object, pluginCollection: PluginCollection) {
const names = [];
const byNames = {};
for (const plugin of pluginCollection._embedded.plugins) {
names.push(plugin.name);
byNames[plugin.name] = plugin;
}
return {
...state,
list: {
...pluginCollection,
_embedded: {
plugins: names
}
},
byNames: byNames
};
}
const reducerByNames = (state: Object, plugin: Plugin) => {
return {
...state,
byNames: {
...state.byNames,
[plugin.name]: plugin
}
};
};
export default function reducer(
state: Object = {},
action: Action = { type: "UNKNOWN" }
): Object {
if (!action.payload) {
return state;
}
switch (action.type) {
case FETCH_PLUGINS_SUCCESS:
return normalizeByName(state, action.payload);
case FETCH_PLUGIN_SUCCESS:
return reducerByNames(state, action.payload);
case FETCH_PENDING_PLUGINS_SUCCESS:
return { ...state, pending: action.payload };
default:
return state;
}
}
// selectors
export function getPluginCollection(state: Object) {
if (state.plugins && state.plugins.list && state.plugins.byNames) {
const plugins = [];
for (let pluginName of state.plugins.list._embedded.plugins) {
plugins.push(state.plugins.byNames[pluginName]);
}
return {
...state.plugins.list,
_embedded: {
plugins
}
};
}
}
export function isFetchPluginsPending(state: Object) {
return isPending(state, FETCH_PLUGINS);
}
export function getFetchPluginsFailure(state: Object) {
return getFailure(state, FETCH_PLUGINS);
}
export function getPlugin(state: Object, name: string) {
if (state.plugins && state.plugins.byNames) {
return state.plugins.byNames[name];
}
}
export function isFetchPluginPending(state: Object, name: string) {
return isPending(state, FETCH_PLUGIN, name);
}
export function getFetchPluginFailure(state: Object, name: string) {
return getFailure(state, FETCH_PLUGIN, name);
}
export function getPendingPlugins(state: Object) {
if (state.plugins && state.plugins.pending) {
return state.plugins.pending;
}
}
export function isFetchPendingPluginsPending(state: Object) {
return isPending(state, FETCH_PENDING_PLUGINS);
}
export function getFetchPendingPluginsFailure(state: Object) {
return getFailure(state, FETCH_PENDING_PLUGINS);
}

View File

@@ -0,0 +1,353 @@
// @flow
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
FETCH_PLUGINS,
FETCH_PLUGINS_PENDING,
FETCH_PLUGINS_SUCCESS,
FETCH_PLUGINS_FAILURE,
FETCH_PLUGIN,
FETCH_PLUGIN_PENDING,
FETCH_PLUGIN_SUCCESS,
FETCH_PLUGIN_FAILURE,
fetchPluginsByLink,
fetchPluginsSuccess,
getPluginCollection,
isFetchPluginsPending,
getFetchPluginsFailure,
fetchPluginByLink,
fetchPluginByName,
fetchPluginSuccess,
getPlugin,
isFetchPluginPending,
getFetchPluginFailure
} from "./plugins";
import type { Plugin, PluginCollection } from "@scm-manager/ui-types";
const groupManagerPlugin: Plugin = {
name: "scm-groupmanager-plugin",
bundles: ["/scm/groupmanager-plugin.bundle.js"],
type: "Administration",
version: "2.0.0-SNAPSHOT",
author: "Sebastian Sdorra",
description: "Notify a remote webserver whenever a plugin is pushed to.",
_links: {
self: {
href: "http://localhost:8081/api/v2/ui/plugins/scm-groupmanager-plugin"
}
}
};
const scriptPlugin: Plugin = {
name: "scm-script-plugin",
bundles: ["/scm/script-plugin.bundle.js"],
type: "Miscellaneous",
version: "2.0.0-SNAPSHOT",
author: "Sebastian Sdorra",
description: "Script support for scm-manager.",
_links: {
self: {
href: "http://localhost:8081/api/v2/ui/plugins/scm-script-plugin"
}
}
};
const branchwpPlugin: Plugin = {
name: "scm-branchwp-plugin",
bundles: ["/scm/branchwp-plugin.bundle.js"],
type: "Miscellaneous",
version: "2.0.0-SNAPSHOT",
author: "Sebastian Sdorra",
description: "This plugin adds branch write protection for plugins.",
_links: {
self: {
href: "http://localhost:8081/api/v2/ui/plugins/scm-branchwp-plugin"
}
}
};
const pluginCollectionWithNames: PluginCollection = {
_links: {
self: {
href: "http://localhost:8081/api/v2/ui/plugins"
}
},
_embedded: {
plugins: [groupManagerPlugin.name, scriptPlugin.name, branchwpPlugin.name]
}
};
const pluginCollection: PluginCollection = {
_links: {
self: {
href: "http://localhost:8081/api/v2/ui/plugins"
}
},
_embedded: {
plugins: [groupManagerPlugin, scriptPlugin, branchwpPlugin]
}
};
describe("plugins fetch", () => {
const URL = "ui/plugins";
const PLUGINS_URL = "/api/v2/ui/plugins";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should successfully fetch plugins from link", () => {
fetchMock.getOnce(PLUGINS_URL, pluginCollection);
const expectedActions = [
{ type: FETCH_PLUGINS_PENDING },
{
type: FETCH_PLUGINS_SUCCESS,
payload: pluginCollection
}
];
const store = mockStore({});
return store.dispatch(fetchPluginsByLink(URL)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_PLUGINS_FAILURE if request fails", () => {
fetchMock.getOnce(PLUGINS_URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchPluginsByLink(URL)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_PLUGINS_PENDING);
expect(actions[1].type).toEqual(FETCH_PLUGINS_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should successfully fetch scm-groupmanager-plugin by name", () => {
fetchMock.getOnce(
PLUGINS_URL + "/scm-groupmanager-plugin",
groupManagerPlugin
);
const expectedActions = [
{
type: FETCH_PLUGIN_PENDING,
payload: {
name: "scm-groupmanager-plugin"
},
itemId: "scm-groupmanager-plugin"
},
{
type: FETCH_PLUGIN_SUCCESS,
payload: groupManagerPlugin,
itemId: "scm-groupmanager-plugin"
}
];
const store = mockStore({});
return store
.dispatch(fetchPluginByName(URL, "scm-groupmanager-plugin"))
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_PLUGIN_FAILURE, if the request for scm-groupmanager-plugin by name fails", () => {
fetchMock.getOnce(PLUGINS_URL + "/scm-groupmanager-plugin", {
status: 500
});
const store = mockStore({});
return store
.dispatch(fetchPluginByName(URL, "scm-groupmanager-plugin"))
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_PLUGIN_PENDING);
expect(actions[1].type).toEqual(FETCH_PLUGIN_FAILURE);
expect(actions[1].payload.name).toBe("scm-groupmanager-plugin");
expect(actions[1].payload.error).toBeDefined();
expect(actions[1].itemId).toBe("scm-groupmanager-plugin");
});
});
it("should successfully fetch scm-groupmanager-plugin", () => {
fetchMock.getOnce(
"http://localhost:8081/api/v2/ui/plugins/scm-groupmanager-plugin",
groupManagerPlugin
);
const expectedActions = [
{
type: FETCH_PLUGIN_PENDING,
payload: {
name: "scm-groupmanager-plugin"
},
itemId: "scm-groupmanager-plugin"
},
{
type: FETCH_PLUGIN_SUCCESS,
payload: groupManagerPlugin,
itemId: "scm-groupmanager-plugin"
}
];
const store = mockStore({});
return store.dispatch(fetchPluginByLink(groupManagerPlugin)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_PLUGIN_FAILURE, it the request for scm-groupmanager-plugin fails", () => {
fetchMock.getOnce(
"http://localhost:8081/api/v2/ui/plugins/scm-groupmanager-plugin",
{
status: 500
}
);
const store = mockStore({});
return store.dispatch(fetchPluginByLink(groupManagerPlugin)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_PLUGIN_PENDING);
expect(actions[1].type).toEqual(FETCH_PLUGIN_FAILURE);
expect(actions[1].payload.name).toBe("scm-groupmanager-plugin");
expect(actions[1].payload.error).toBeDefined();
expect(actions[1].itemId).toBe("scm-groupmanager-plugin");
});
});
});
describe("plugins reducer", () => {
it("should return empty object, if state and action is undefined", () => {
expect(reducer()).toEqual({});
});
it("should return the same state, if the action is undefined", () => {
const state = { x: true };
expect(reducer(state)).toBe(state);
});
it("should return the same state, if the action is unknown to the reducer", () => {
const state = { x: true };
expect(reducer(state, { type: "EL_SPECIALE" })).toBe(state);
});
it("should store the plugins by it's type and name on FETCH_PLUGINS_SUCCESS", () => {
const newState = reducer({}, fetchPluginsSuccess(pluginCollection));
expect(newState.list._embedded.plugins).toEqual([
"scm-groupmanager-plugin",
"scm-script-plugin",
"scm-branchwp-plugin"
]);
expect(newState.byNames["scm-groupmanager-plugin"]).toBe(
groupManagerPlugin
);
expect(newState.byNames["scm-script-plugin"]).toBe(scriptPlugin);
expect(newState.byNames["scm-branchwp-plugin"]).toBe(branchwpPlugin);
});
it("should store the plugin at byNames", () => {
const newState = reducer({}, fetchPluginSuccess(groupManagerPlugin));
expect(newState.byNames["scm-groupmanager-plugin"]).toBe(
groupManagerPlugin
);
});
});
describe("plugins selectors", () => {
const error = new Error("something went wrong");
it("should return the plugins collection", () => {
const state = {
plugins: {
list: pluginCollectionWithNames,
byNames: {
"scm-groupmanager-plugin": groupManagerPlugin,
"scm-script-plugin": scriptPlugin,
"scm-branchwp-plugin": branchwpPlugin
}
}
};
const collection = getPluginCollection(state);
expect(collection).toEqual(pluginCollection);
});
it("should return true, when fetch plugins is pending", () => {
const state = {
pending: {
[FETCH_PLUGINS]: true
}
};
expect(isFetchPluginsPending(state)).toEqual(true);
});
it("should return false, when fetch plugins is not pending", () => {
expect(isFetchPluginsPending({})).toEqual(false);
});
it("should return error when fetch plugins did fail", () => {
const state = {
failure: {
[FETCH_PLUGINS]: error
}
};
expect(getFetchPluginsFailure(state)).toEqual(error);
});
it("should return undefined when fetch plugins did not fail", () => {
expect(getFetchPluginsFailure({})).toBe(undefined);
});
it("should return the plugin collection", () => {
const state = {
plugins: {
byNames: {
"scm-groupmanager-plugin": groupManagerPlugin
}
}
};
const plugin = getPlugin(state, "scm-groupmanager-plugin");
expect(plugin).toEqual(groupManagerPlugin);
});
it("should return true, when fetch plugin is pending", () => {
const state = {
pending: {
[FETCH_PLUGIN + "/scm-groupmanager-plugin"]: true
}
};
expect(isFetchPluginPending(state, "scm-groupmanager-plugin")).toEqual(
true
);
});
it("should return false, when fetch plugin is not pending", () => {
expect(isFetchPluginPending({}, "scm-groupmanager-plugin")).toEqual(false);
});
it("should return error when fetch plugin did fail", () => {
const state = {
failure: {
[FETCH_PLUGIN + "/scm-groupmanager-plugin"]: error
}
};
expect(getFetchPluginFailure(state, "scm-groupmanager-plugin")).toEqual(
error
);
});
it("should return undefined when fetch plugin did not fail", () => {
expect(getFetchPluginFailure({}, "scm-groupmanager-plugin")).toBe(
undefined
);
});
});

View File

@@ -0,0 +1,49 @@
//@flow
import React from "react";
import { compose } from "redux";
import injectSheet from "react-jss";
import { translate } from "react-i18next";
import type { RepositoryRole } from "@scm-manager/ui-types";
type Props = {
role: RepositoryRole,
// context props
classes: any,
t: string => string
};
const styles = {
spacing: {
padding: "0 !important"
}
};
class AvailableVerbs extends React.Component<Props> {
render() {
const { role, t, classes } = this.props;
let verbs = null;
if (role.verbs.length > 0) {
verbs = (
<tr>
<td className={classes.spacing}>
<ul>
{role.verbs.map(verb => {
return (
<li>{t("verbs.repository." + verb + ".displayName")}</li>
);
})}
</ul>
</td>
</tr>
);
}
return verbs;
}
}
export default compose(
injectSheet(styles),
translate("plugins")
)(AvailableVerbs);

View File

@@ -0,0 +1,50 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import type { RepositoryRole } from "@scm-manager/ui-types";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import PermissionRoleDetailsTable from "./PermissionRoleDetailsTable";
import { Button } from "@scm-manager/ui-components";
type Props = {
role: RepositoryRole,
url: string,
// context props
t: string => string
};
class PermissionRoleDetails extends React.Component<Props> {
renderEditButton() {
const { t, url } = this.props;
if (!!this.props.role._links.update) {
return (
<Button
label={t("repositoryRole.editButton")}
link={`${url}/edit`}
color="primary"
/>
);
}
return null;
}
render() {
const { role } = this.props;
return (
<>
<PermissionRoleDetailsTable role={role} />
<hr />
{this.renderEditButton()}
<ExtensionPoint
name="repositoryRole.role-details.information"
renderAll={true}
props={{ role }}
/>
</>
);
}
}
export default translate("admin")(PermissionRoleDetails);

View File

@@ -0,0 +1,38 @@
//@flow
import React from "react";
import type { RepositoryRole } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
import AvailableVerbs from "./AvailableVerbs";
type Props = {
role: RepositoryRole,
// context props
t: string => string
};
class PermissionRoleDetailsTable extends React.Component<Props> {
render() {
const { role, t } = this.props;
return (
<table className="table content">
<tbody>
<tr>
<th>{t("repositoryRole.name")}</th>
<td>{role.name}</td>
</tr>
<tr>
<th>{t("repositoryRole.type")}</th>
<td>{role.type}</td>
</tr>
<tr>
<th>{t("repositoryRole.verbs")}</th>
<AvailableVerbs role={role} />
</tr>
</tbody>
</table>
);
}
}
export default translate("admin")(PermissionRoleDetailsTable);

View File

@@ -0,0 +1,33 @@
// @flow
import React from "react";
import { Link } from "react-router-dom";
import type { RepositoryRole } from "@scm-manager/ui-types";
import SystemRoleTag from "./SystemRoleTag";
type Props = {
baseUrl: string,
role: RepositoryRole
};
class PermissionRoleRow extends React.Component<Props> {
renderLink(to: string, label: string, system?: boolean) {
return (
<Link to={to}>
{label} <SystemRoleTag system={system} />
</Link>
);
}
render() {
const { baseUrl, role } = this.props;
const singleRepoRoleUrl = baseUrl.substring(0, baseUrl.length - 1);
const to = `${singleRepoRoleUrl}/${encodeURIComponent(role.name)}/info`;
return (
<tr>
<td>{this.renderLink(to, role.name, !role._links.update)}</td>
</tr>
);
}
}
export default PermissionRoleRow;

View File

@@ -0,0 +1,37 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import type { RepositoryRole } from "@scm-manager/ui-types";
import PermissionRoleRow from "./PermissionRoleRow";
type Props = {
baseUrl: string,
roles: RepositoryRole[],
// context props
t: string => string
};
class PermissionRoleTable extends React.Component<Props> {
render() {
const { baseUrl, roles, t } = this.props;
return (
<table className="card-table table is-hoverable is-fullwidth">
<thead>
<tr>
<th>{t("repositoryRole.name")}</th>
</tr>
</thead>
<tbody>
{roles.map((role, index) => {
return (
<PermissionRoleRow key={index} baseUrl={baseUrl} role={role} />
);
})}
</tbody>
</table>
);
}
}
export default translate("admin")(PermissionRoleTable);

View File

@@ -0,0 +1,39 @@
//@flow
import React from "react";
import injectSheet from "react-jss";
import { translate } from "react-i18next";
import { Tag } from "@scm-manager/ui-components";
type Props = {
system?: boolean,
// context props
classes: any,
t: string => string
};
const styles = {
tag: {
marginLeft: "0.75rem",
verticalAlign: "inherit"
}
};
class SystemRoleTag extends React.Component<Props> {
render() {
const { system, classes, t } = this.props;
if (system) {
return (
<Tag
className={classes.tag}
color="dark"
label={t("repositoryRole.system")}
/>
);
}
return null;
}
}
export default injectSheet(styles)(translate("admin")(SystemRoleTag));

View File

@@ -0,0 +1,88 @@
// @flow
import React from "react";
import RepositoryRoleForm from "./RepositoryRoleForm";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import {ErrorNotification, Subtitle, Title} from "@scm-manager/ui-components";
import {
createRole,
getCreateRoleFailure,
getFetchVerbsFailure,
isFetchVerbsPending
} from "../modules/roles";
import type { RepositoryRole } from "@scm-manager/ui-types";
import {
getRepositoryRolesLink,
getRepositoryVerbsLink
} from "../../../modules/indexResource";
import type {History} from "history";
type Props = {
repositoryRolesLink: string,
error?: Error,
history: History,
//dispatch function
addRole: (link: string, role: RepositoryRole, callback?: () => void) => void,
// context objects
t: string => string
};
class CreateRepositoryRole extends React.Component<Props> {
repositoryRoleCreated = (role: RepositoryRole) => {
const { history } = this.props;
history.push("/admin/role/" + role.name + "/info");
};
createRepositoryRole = (role: RepositoryRole) => {
this.props.addRole(this.props.repositoryRolesLink, role, () =>
this.repositoryRoleCreated(role)
);
};
render() {
const { t, error } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
return (
<>
<Title title={t("repositoryRole.title")} />
<Subtitle subtitle={t("repositoryRole.createSubtitle")} />
<RepositoryRoleForm
submitForm={role => this.createRepositoryRole(role)}
/>
</>
);
}
}
const mapStateToProps = (state, ownProps) => {
const loading = isFetchVerbsPending(state);
const error = getFetchVerbsFailure(state) || getCreateRoleFailure(state);
const verbsLink = getRepositoryVerbsLink(state);
const repositoryRolesLink = getRepositoryRolesLink(state);
return {
loading,
error,
verbsLink,
repositoryRolesLink
};
};
const mapDispatchToProps = dispatch => {
return {
addRole: (link: string, role: RepositoryRole, callback?: () => void) => {
dispatch(createRole(link, role, callback));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("admin")(CreateRepositoryRole));

View File

@@ -0,0 +1,113 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import type { RepositoryRole } from "@scm-manager/ui-types";
import {
Subtitle,
DeleteButton,
confirmAlert,
ErrorNotification
} from "@scm-manager/ui-components";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import type { History } from "history";
import {
deleteRole,
getDeleteRoleFailure,
isDeleteRolePending
} from "../modules/roles";
type Props = {
loading: boolean,
error: Error,
role: RepositoryRole,
confirmDialog?: boolean,
deleteRole: (role: RepositoryRole, callback?: () => void) => void,
// context props
history: History,
t: string => string
};
class DeleteRepositoryRole extends React.Component<Props> {
static defaultProps = {
confirmDialog: true
};
roleDeleted = () => {
this.props.history.push("/admin/roles/");
};
deleteRole = () => {
this.props.deleteRole(this.props.role, this.roleDeleted);
};
confirmDelete = () => {
const { t } = this.props;
confirmAlert({
title: t("repositoryRole.delete.confirmAlert.title"),
message: t("repositoryRole.delete.confirmAlert.message"),
buttons: [
{
label: t("repositoryRole.delete.confirmAlert.submit"),
onClick: () => this.deleteRole()
},
{
label: t("repositoryRole.delete.confirmAlert.cancel"),
onClick: () => null
}
]
});
};
isDeletable = () => {
return this.props.role._links.delete;
};
render() {
const { loading, error, confirmDialog, t } = this.props;
const action = confirmDialog ? this.confirmDelete : this.deleteRole;
if (!this.isDeletable()) {
return null;
}
return (
<>
<Subtitle subtitle={t("repositoryRole.delete.subtitle")} />
<div className="columns">
<div className="column">
<ErrorNotification error={error} />
<DeleteButton
label={t("repositoryRole.delete.button")}
action={action}
loading={loading}
/>
</div>
</div>
</>
);
}
}
const mapStateToProps = (state, ownProps) => {
const loading = isDeleteRolePending(state, ownProps.role.name);
const error = getDeleteRoleFailure(state, ownProps.role.name);
return {
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
deleteRole: (role: RepositoryRole, callback?: () => void) => {
dispatch(deleteRole(role, callback));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(withRouter(translate("admin")(DeleteRepositoryRole)));

View File

@@ -0,0 +1,81 @@
// @flow
import React from "react";
import RepositoryRoleForm from "./RepositoryRoleForm";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import {
getModifyRoleFailure,
isModifyRolePending,
modifyRole
} from "../modules/roles";
import { ErrorNotification, Subtitle } from "@scm-manager/ui-components";
import type { RepositoryRole } from "@scm-manager/ui-types";
import type { History } from "history";
import DeleteRepositoryRole from "./DeleteRepositoryRole";
type Props = {
disabled: boolean,
role: RepositoryRole,
repositoryRolesLink: string,
error?: Error,
// context objects
t: string => string,
history: History,
//dispatch function
updateRole: (role: RepositoryRole, callback?: () => void) => void
};
class EditRepositoryRole extends React.Component<Props> {
repositoryRoleUpdated = () => {
this.props.history.push("/admin/roles/");
};
updateRepositoryRole = (role: RepositoryRole) => {
this.props.updateRole(role, this.repositoryRoleUpdated);
};
render() {
const { error, t } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
return (
<>
<Subtitle subtitle={t("repositoryRole.editSubtitle")} />
<RepositoryRoleForm
role={this.props.role}
submitForm={role => this.updateRepositoryRole(role)}
/>
<hr/>
<DeleteRepositoryRole role={this.props.role}/>
</>
);
}
}
const mapStateToProps = (state, ownProps) => {
const loading = isModifyRolePending(state, ownProps.role.name);
const error = getModifyRoleFailure(state, ownProps.role.name);
return {
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
updateRole: (role: RepositoryRole, callback?: () => void) => {
dispatch(modifyRole(role, callback));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("admin")(EditRepositoryRole));

View File

@@ -0,0 +1,172 @@
// @flow
import React from "react";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import type { RepositoryRole } from "@scm-manager/ui-types";
import { InputField, SubmitButton } from "@scm-manager/ui-components";
import PermissionCheckbox from "../../../repos/permissions/components/PermissionCheckbox";
import {
fetchAvailableVerbs,
getFetchVerbsFailure,
getVerbsFromState,
isFetchVerbsPending
} from "../modules/roles";
import {
getRepositoryRolesLink,
getRepositoryVerbsLink
} from "../../../modules/indexResource";
type Props = {
role?: RepositoryRole,
loading?: boolean,
availableVerbs: string[],
verbsLink: string,
submitForm: RepositoryRole => void,
// context objects
t: string => string,
// dispatch functions
fetchAvailableVerbs: (link: string) => void
};
type State = {
role: RepositoryRole
};
class RepositoryRoleForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
role: {
name: "",
verbs: [],
system: false,
_links: {}
}
};
}
componentDidMount() {
const { fetchAvailableVerbs, verbsLink } = this.props;
fetchAvailableVerbs(verbsLink);
if (this.props.role) {
this.setState({ role: this.props.role });
}
}
isFalsy(value) {
return !value;
}
isValid = () => {
const { role } = this.state;
return !(
this.isFalsy(role) ||
this.isFalsy(role.name) ||
this.isFalsy(role.verbs.length > 0)
);
};
handleNameChange = (name: string) => {
this.setState({
role: {
...this.state.role,
name
}
});
};
handleVerbChange = (value: boolean, name: string) => {
const { role } = this.state;
const newVerbs = value
? [...role.verbs, name]
: role.verbs.filter(v => v !== name);
this.setState({
...this.state,
role: {
...role,
verbs: newVerbs
}
});
};
submit = (event: Event) => {
event.preventDefault();
if (this.isValid()) {
this.props.submitForm(this.state.role);
}
};
render() {
const { loading, availableVerbs, t } = this.props;
const { role } = this.state;
const verbSelectBoxes = !availableVerbs
? null
: availableVerbs.map(verb => (
<PermissionCheckbox
key={verb}
name={verb}
checked={role.verbs.includes(verb)}
onChange={this.handleVerbChange}
/>
));
return (
<form onSubmit={this.submit}>
<InputField
name="name"
label={t("repositoryRole.form.name")}
onChange={this.handleNameChange}
value={role.name ? role.name : ""}
disabled={!!this.props.role}
/>
<div className="field">
<label className="label">
{t("repositoryRole.form.permissions")}
</label>
{verbSelectBoxes}
</div>
<hr />
<SubmitButton
loading={loading}
label={t("repositoryRole.form.submit")}
disabled={!this.isValid()}
/>
</form>
);
}
}
const mapStateToProps = (state, ownProps) => {
const loading = isFetchVerbsPending(state);
const error = getFetchVerbsFailure(state);
const verbsLink = getRepositoryVerbsLink(state);
const availableVerbs = getVerbsFromState(state);
const repositoryRolesLink = getRepositoryRolesLink(state);
return {
loading,
error,
verbsLink,
availableVerbs,
repositoryRolesLink
};
};
const mapDispatchToProps = dispatch => {
return {
fetchAvailableVerbs: (link: string) => {
dispatch(fetchAvailableVerbs(link));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("admin")(RepositoryRoleForm));

View File

@@ -0,0 +1,155 @@
// @flow
import React from "react";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import { translate } from "react-i18next";
import type { History } from "history";
import type { RepositoryRole, PagedCollection } from "@scm-manager/ui-types";
import {
Title,
Subtitle,
Loading,
Notification,
LinkPaginator,
urls,
CreateButton
} from "@scm-manager/ui-components";
import {
fetchRolesByPage,
getRolesFromState,
selectListAsCollection,
isPermittedToCreateRoles,
isFetchRolesPending,
getFetchRolesFailure
} from "../modules/roles";
import PermissionRoleTable from "../components/PermissionRoleTable";
import { getRepositoryRolesLink } from "../../../modules/indexResource";
type Props = {
baseUrl: string,
roles: RepositoryRole[],
loading: boolean,
error: Error,
canAddRoles: boolean,
list: PagedCollection,
page: number,
rolesLink: string,
// context objects
t: string => string,
history: History,
location: any,
// dispatch functions
fetchRolesByPage: (link: string, page: number) => void
};
class RepositoryRoles extends React.Component<Props> {
componentDidMount() {
const { fetchRolesByPage, rolesLink, page } = this.props;
fetchRolesByPage(rolesLink, page);
}
componentDidUpdate = (prevProps: Props) => {
const {
loading,
list,
page,
rolesLink,
location,
fetchRolesByPage
} = this.props;
if (list && page && !loading) {
const statePage: number = list.page + 1;
if (page !== statePage || prevProps.location.search !== location.search) {
fetchRolesByPage(
rolesLink,
page
);
}
}
};
render() {
const { t, loading } = this.props;
if (loading) {
return <Loading />;
}
return (
<>
<Title title={t("repositoryRole.title")} />
<Subtitle subtitle={t("repositoryRole.overview.title")} />
{this.renderPermissionsTable()}
{this.renderCreateButton()}
</>
);
}
renderPermissionsTable() {
const { baseUrl, roles, list, page, t } = this.props;
if (roles && roles.length > 0) {
return (
<>
<PermissionRoleTable baseUrl={baseUrl} roles={roles} />
<LinkPaginator collection={list} page={page} />
</>
);
}
return (
<Notification type="info">
{t("repositoryRole.overview.noPermissionRoles")}
</Notification>
);
}
renderCreateButton() {
const { canAddRoles, baseUrl, t } = this.props;
if (canAddRoles) {
return (
<CreateButton
label={t("repositoryRole.overview.createButton")}
link={`${baseUrl}/create`}
/>
);
}
return null;
}
}
const mapStateToProps = (state, ownProps) => {
const { match } = ownProps;
const roles = getRolesFromState(state);
const loading = isFetchRolesPending(state);
const error = getFetchRolesFailure(state);
const page = urls.getPageFromMatch(match);
const canAddRoles = isPermittedToCreateRoles(state);
const list = selectListAsCollection(state);
const rolesLink = getRepositoryRolesLink(state);
return {
roles,
loading,
error,
canAddRoles,
list,
page,
rolesLink
};
};
const mapDispatchToProps = dispatch => {
return {
fetchRolesByPage: (link: string, page: number) => {
dispatch(fetchRolesByPage(link, page));
}
};
};
export default withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(translate("admin")(RepositoryRoles))
);

View File

@@ -0,0 +1,133 @@
//@flow
import React from "react";
import { connect } from "react-redux";
import { Loading, ErrorPage, Title } from "@scm-manager/ui-components";
import { Route } from "react-router";
import type { History } from "history";
import { translate } from "react-i18next";
import type { RepositoryRole } from "@scm-manager/ui-types";
import { getRepositoryRolesLink } from "../../../modules/indexResource";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import {
fetchRoleByName,
getFetchRoleFailure,
getRoleByName,
isFetchRolePending
} from "../modules/roles";
import { withRouter } from "react-router-dom";
import PermissionRoleDetail from "../components/PermissionRoleDetails";
import EditRepositoryRole from "./EditRepositoryRole";
type Props = {
roleName: string,
role: RepositoryRole,
loading: boolean,
error: Error,
repositoryRolesLink: string,
disabled: boolean,
// dispatcher function
fetchRoleByName: (string, string) => void,
// context objects
t: string => string,
match: any,
history: History
};
class SingleRepositoryRole extends React.Component<Props> {
componentDidMount() {
this.props.fetchRoleByName(
this.props.repositoryRolesLink,
this.props.roleName
);
}
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 2);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { t, loading, error, role } = this.props;
if (error) {
return (
<ErrorPage
title={t("repositoryRole.errorTitle")}
subtitle={t("repositoryRole.errorSubtitle")}
error={error}
/>
);
}
if (!role || loading) {
return <Loading />;
}
const url = this.matchedUrl();
const extensionProps = {
role,
url
};
return (
<>
<Title title={t("repositoryRole.title")} />
<Route
path={`${url}/info`}
component={() => <PermissionRoleDetail role={role} url={url} />}
/>
<Route
path={`${url}/edit`}
exact
component={() => (
<EditRepositoryRole role={role} history={this.props.history} />
)}
/>
<ExtensionPoint
name="roles.route"
props={extensionProps}
renderAll={true}
/>
</>
);
}
}
const mapStateToProps = (state, ownProps) => {
const roleName = ownProps.match.params.role;
const role = getRoleByName(state, roleName);
const loading = isFetchRolePending(state, roleName);
const error = getFetchRoleFailure(state, roleName);
const repositoryRolesLink = getRepositoryRolesLink(state);
return {
repositoryRolesLink,
roleName,
role,
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
fetchRoleByName: (link: string, name: string) => {
dispatch(fetchRoleByName(link, name));
}
};
};
export default withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(translate("admin")(SingleRepositoryRole))
);

View File

@@ -0,0 +1,542 @@
// @flow
import { apiClient } from "@scm-manager/ui-components";
import { isPending } from "../../../modules/pending";
import { getFailure } from "../../../modules/failure";
import * as types from "../../../modules/types";
import { combineReducers, Dispatch } from "redux";
import type {
Action,
PagedCollection,
RepositoryRole
} from "@scm-manager/ui-types";
export const FETCH_ROLES = "scm/roles/FETCH_ROLES";
export const FETCH_ROLES_PENDING = `${FETCH_ROLES}_${types.PENDING_SUFFIX}`;
export const FETCH_ROLES_SUCCESS = `${FETCH_ROLES}_${types.SUCCESS_SUFFIX}`;
export const FETCH_ROLES_FAILURE = `${FETCH_ROLES}_${types.FAILURE_SUFFIX}`;
export const FETCH_ROLE = "scm/roles/FETCH_ROLE";
export const FETCH_ROLE_PENDING = `${FETCH_ROLE}_${types.PENDING_SUFFIX}`;
export const FETCH_ROLE_SUCCESS = `${FETCH_ROLE}_${types.SUCCESS_SUFFIX}`;
export const FETCH_ROLE_FAILURE = `${FETCH_ROLE}_${types.FAILURE_SUFFIX}`;
export const CREATE_ROLE = "scm/roles/CREATE_ROLE";
export const CREATE_ROLE_PENDING = `${CREATE_ROLE}_${types.PENDING_SUFFIX}`;
export const CREATE_ROLE_SUCCESS = `${CREATE_ROLE}_${types.SUCCESS_SUFFIX}`;
export const CREATE_ROLE_FAILURE = `${CREATE_ROLE}_${types.FAILURE_SUFFIX}`;
export const CREATE_ROLE_RESET = `${CREATE_ROLE}_${types.RESET_SUFFIX}`;
export const MODIFY_ROLE = "scm/roles/MODIFY_ROLE";
export const MODIFY_ROLE_PENDING = `${MODIFY_ROLE}_${types.PENDING_SUFFIX}`;
export const MODIFY_ROLE_SUCCESS = `${MODIFY_ROLE}_${types.SUCCESS_SUFFIX}`;
export const MODIFY_ROLE_FAILURE = `${MODIFY_ROLE}_${types.FAILURE_SUFFIX}`;
export const MODIFY_ROLE_RESET = `${MODIFY_ROLE}_${types.RESET_SUFFIX}`;
export const DELETE_ROLE = "scm/roles/DELETE_ROLE";
export const DELETE_ROLE_PENDING = `${DELETE_ROLE}_${types.PENDING_SUFFIX}`;
export const DELETE_ROLE_SUCCESS = `${DELETE_ROLE}_${types.SUCCESS_SUFFIX}`;
export const DELETE_ROLE_FAILURE = `${DELETE_ROLE}_${types.FAILURE_SUFFIX}`;
export const FETCH_VERBS = "scm/roles/FETCH_VERBS";
export const FETCH_VERBS_PENDING = `${FETCH_VERBS}_${types.PENDING_SUFFIX}`;
export const FETCH_VERBS_SUCCESS = `${FETCH_VERBS}_${types.SUCCESS_SUFFIX}`;
export const FETCH_VERBS_FAILURE = `${FETCH_VERBS}_${types.FAILURE_SUFFIX}`;
const CONTENT_TYPE_ROLE = "application/vnd.scmm-repositoryRole+json;v=2";
// fetch roles
export function fetchRolesPending(): Action {
return {
type: FETCH_ROLES_PENDING
};
}
export function fetchRolesSuccess(roles: any): Action {
return {
type: FETCH_ROLES_SUCCESS,
payload: roles
};
}
export function fetchRolesFailure(url: string, error: Error): Action {
return {
type: FETCH_ROLES_FAILURE,
payload: {
error,
url
}
};
}
export function fetchRolesByLink(link: string) {
return function(dispatch: any) {
dispatch(fetchRolesPending());
return apiClient
.get(link)
.then(response => response.json())
.then(data => {
dispatch(fetchRolesSuccess(data));
})
.catch(error => {
dispatch(fetchRolesFailure(link, error));
});
};
}
export function fetchRoles(link: string) {
return fetchRolesByLink(link);
}
export function fetchRolesByPage(link: string, page: number) {
// backend start counting by 0
return fetchRolesByLink(`${link}?page=${page - 1}`);
}
// fetch role
export function fetchRolePending(name: string): Action {
return {
type: FETCH_ROLE_PENDING,
payload: name,
itemId: name
};
}
export function fetchRoleSuccess(role: any): Action {
return {
type: FETCH_ROLE_SUCCESS,
payload: role,
itemId: role.name
};
}
export function fetchRoleFailure(name: string, error: Error): Action {
return {
type: FETCH_ROLE_FAILURE,
payload: {
name,
error
},
itemId: name
};
}
function fetchRole(link: string, name: string) {
return function(dispatch: any) {
dispatch(fetchRolePending(name));
return apiClient
.get(link)
.then(response => {
return response.json();
})
.then(data => {
dispatch(fetchRoleSuccess(data));
})
.catch(error => {
dispatch(fetchRoleFailure(name, error));
});
};
}
export function fetchRoleByName(link: string, name: string) {
const roleUrl = link.endsWith("/") ? link + name : link + "/" + name;
return fetchRole(roleUrl, name);
}
export function fetchRoleByLink(role: RepositoryRole) {
return fetchRole(role._links.self.href, role.name);
}
// create role
export function createRolePending(role: RepositoryRole): Action {
return {
type: CREATE_ROLE_PENDING,
role
};
}
export function createRoleSuccess(): Action {
return {
type: CREATE_ROLE_SUCCESS
};
}
export function createRoleFailure(error: Error): Action {
return {
type: CREATE_ROLE_FAILURE,
payload: error
};
}
export function createRoleReset() {
return {
type: CREATE_ROLE_RESET
};
}
export function createRole(
link: string,
role: RepositoryRole,
callback?: () => void
) {
return function(dispatch: Dispatch) {
dispatch(createRolePending(role));
return apiClient
.post(link, role, CONTENT_TYPE_ROLE)
.then(() => {
dispatch(createRoleSuccess());
if (callback) {
callback();
}
})
.catch(error => dispatch(createRoleFailure(error)));
};
}
//fetch verbs
export function fetchVerbsPending(): Action {
return {
type: FETCH_VERBS_PENDING
};
}
export function fetchVerbsSuccess(verbs: any): Action {
return {
type: FETCH_VERBS_SUCCESS,
payload: verbs
};
}
export function fetchVerbsFailure(error: Error): Action {
return {
type: FETCH_VERBS_FAILURE,
payload: error
};
}
export function fetchAvailableVerbs(link: string) {
return function(dispatch: any) {
dispatch(fetchVerbsPending());
return apiClient
.get(link)
.then(response => {
return response.json();
})
.then(data => {
dispatch(fetchVerbsSuccess(data));
})
.catch(error => {
dispatch(fetchVerbsFailure(error));
});
};
}
function verbReducer(state: any = {}, action: any = {}) {
switch (action.type) {
case FETCH_VERBS_SUCCESS:
const verbs = action.payload.verbs;
return { ...state, verbs };
default:
return state;
}
}
// modify role
export function modifyRolePending(role: RepositoryRole): Action {
return {
type: MODIFY_ROLE_PENDING,
payload: role,
itemId: role.name
};
}
export function modifyRoleSuccess(role: RepositoryRole): Action {
return {
type: MODIFY_ROLE_SUCCESS,
payload: role,
itemId: role.name
};
}
export function modifyRoleFailure(role: RepositoryRole, error: Error): Action {
return {
type: MODIFY_ROLE_FAILURE,
payload: {
error,
role
},
itemId: role.name
};
}
export function modifyRoleReset(role: RepositoryRole): Action {
return {
type: MODIFY_ROLE_RESET,
itemId: role.name
};
}
export function modifyRole(role: RepositoryRole, callback?: () => void) {
return function(dispatch: Dispatch) {
dispatch(modifyRolePending(role));
return apiClient
.put(role._links.update.href, role, CONTENT_TYPE_ROLE)
.then(() => {
dispatch(modifyRoleSuccess(role));
if (callback) {
callback();
}
})
.then(() => {
dispatch(fetchRoleByLink(role));
})
.catch(err => {
dispatch(modifyRoleFailure(role, err));
});
};
}
// delete role
export function deleteRolePending(role: RepositoryRole): Action {
return {
type: DELETE_ROLE_PENDING,
payload: role,
itemId: role.name
};
}
export function deleteRoleSuccess(role: RepositoryRole): Action {
return {
type: DELETE_ROLE_SUCCESS,
payload: role,
itemId: role.name
};
}
export function deleteRoleFailure(role: RepositoryRole, error: Error): Action {
return {
type: DELETE_ROLE_FAILURE,
payload: {
error,
role
},
itemId: role.name
};
}
export function deleteRole(role: RepositoryRole, callback?: () => void) {
return function(dispatch: any) {
dispatch(deleteRolePending(role));
return apiClient
.delete(role._links.delete.href)
.then(() => {
dispatch(deleteRoleSuccess(role));
if (callback) {
callback();
}
})
.catch(error => {
dispatch(deleteRoleFailure(role, error));
});
};
}
function extractRolesByNames(
roles: RepositoryRole[],
roleNames: string[],
oldRolesByNames: Object
) {
const rolesByNames = {};
for (let role of roles) {
rolesByNames[role.name] = role;
}
for (let roleName in oldRolesByNames) {
rolesByNames[roleName] = oldRolesByNames[roleName];
}
return rolesByNames;
}
function deleteRoleInRolesByNames(roles: {}, roleName: string) {
let newRoles = {};
for (let rolename in roles) {
if (rolename !== roleName) newRoles[rolename] = roles[rolename];
}
return newRoles;
}
function deleteRoleInEntries(roles: [], roleName: string) {
let newRoles = [];
for (let role of roles) {
if (role !== roleName) newRoles.push(role);
}
return newRoles;
}
const reducerByName = (state: any, rolename: string, newRoleState: any) => {
return {
...state,
[rolename]: newRoleState
};
};
function listReducer(state: any = {}, action: any = {}) {
switch (action.type) {
case FETCH_ROLES_SUCCESS:
const roles = action.payload._embedded.repositoryRoles;
const roleNames = roles.map(role => role.name);
return {
...state,
entries: roleNames,
entry: {
roleCreatePermission: !!action.payload._links.create,
page: action.payload.page,
pageTotal: action.payload.pageTotal,
_links: action.payload._links
}
};
// Delete single role actions
case DELETE_ROLE_SUCCESS:
const newRoleEntries = deleteRoleInEntries(
state.entries,
action.payload.name
);
return {
...state,
entries: newRoleEntries
};
default:
return state;
}
}
function byNamesReducer(state: any = {}, action: any = {}) {
switch (action.type) {
// Fetch all roles actions
case FETCH_ROLES_SUCCESS:
const roles = action.payload._embedded.repositoryRoles;
const roleNames = roles.map(role => role.name);
const byNames = extractRolesByNames(roles, roleNames, state.byNames);
return {
...byNames
};
// Fetch single role actions
case FETCH_ROLE_SUCCESS:
return reducerByName(state, action.payload.name, action.payload);
case DELETE_ROLE_SUCCESS:
return deleteRoleInRolesByNames(state, action.payload.name);
default:
return state;
}
}
export default combineReducers({
list: listReducer,
byNames: byNamesReducer,
verbs: verbReducer
});
// selectors
const selectList = (state: Object) => {
if (state.roles && state.roles.list) {
return state.roles.list;
}
return {};
};
const selectListEntry = (state: Object): Object => {
const list = selectList(state);
if (list.entry) {
return list.entry;
}
return {};
};
export const selectListAsCollection = (state: Object): PagedCollection => {
return selectListEntry(state);
};
export const isPermittedToCreateRoles = (state: Object): boolean => {
return !!selectListEntry(state).roleCreatePermission;
};
export function getRolesFromState(state: Object) {
const roleNames = selectList(state).entries;
if (!roleNames) {
return null;
}
const roleEntries: RepositoryRole[] = [];
for (let roleName of roleNames) {
roleEntries.push(state.roles.byNames[roleName]);
}
return roleEntries;
}
export function getRoleCreateLink(state: Object) {
if (state && state.list && state.list._links && state.list._links.create) {
return state.list._links.create.href;
}
}
export function getVerbsFromState(state: Object) {
return state.roles.verbs.verbs;
}
export function isFetchRolesPending(state: Object) {
return isPending(state, FETCH_ROLES);
}
export function getFetchRolesFailure(state: Object) {
return getFailure(state, FETCH_ROLES);
}
export function isFetchVerbsPending(state: Object) {
return isPending(state, FETCH_VERBS);
}
export function getFetchVerbsFailure(state: Object) {
return getFailure(state, FETCH_VERBS);
}
export function isCreateRolePending(state: Object) {
return isPending(state, CREATE_ROLE);
}
export function getCreateRoleFailure(state: Object) {
return getFailure(state, CREATE_ROLE);
}
export function getRoleByName(state: Object, name: string) {
if (state.roles && state.roles.byNames) {
return state.roles.byNames[name];
}
}
export function isFetchRolePending(state: Object, name: string) {
return isPending(state, FETCH_ROLE, name);
}
export function getFetchRoleFailure(state: Object, name: string) {
return getFailure(state, FETCH_ROLE, name);
}
export function isModifyRolePending(state: Object, name: string) {
return isPending(state, MODIFY_ROLE, name);
}
export function getModifyRoleFailure(state: Object, name: string) {
return getFailure(state, MODIFY_ROLE, name);
}
export function isDeleteRolePending(state: Object, name: string) {
return isPending(state, DELETE_ROLE, name);
}
export function getDeleteRoleFailure(state: Object, name: string) {
return getFailure(state, DELETE_ROLE, name);
}

View File

@@ -0,0 +1,653 @@
// @flow
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
FETCH_ROLES,
FETCH_ROLES_PENDING,
FETCH_ROLES_SUCCESS,
FETCH_ROLES_FAILURE,
FETCH_ROLE,
FETCH_ROLE_PENDING,
FETCH_ROLE_SUCCESS,
FETCH_ROLE_FAILURE,
CREATE_ROLE,
CREATE_ROLE_PENDING,
CREATE_ROLE_SUCCESS,
CREATE_ROLE_FAILURE,
MODIFY_ROLE,
MODIFY_ROLE_PENDING,
MODIFY_ROLE_SUCCESS,
MODIFY_ROLE_FAILURE,
DELETE_ROLE,
DELETE_ROLE_PENDING,
DELETE_ROLE_SUCCESS,
DELETE_ROLE_FAILURE,
fetchRoles,
getFetchRolesFailure,
getRolesFromState,
isFetchRolesPending,
fetchRolesSuccess,
fetchRoleByLink,
fetchRoleByName,
fetchRoleSuccess,
isFetchRolePending,
getFetchRoleFailure,
createRole,
isCreateRolePending,
getCreateRoleFailure,
getRoleByName,
modifyRole,
isModifyRolePending,
getModifyRoleFailure,
deleteRole,
isDeleteRolePending,
deleteRoleSuccess,
getDeleteRoleFailure,
selectListAsCollection,
isPermittedToCreateRoles
} from "./roles";
const role1 = {
name: "specialrole",
verbs: ["read", "pull", "push", "readPullRequest"],
system: false,
_links: {
self: {
href: "http://localhost:8081/scm/api/v2/repositoryRoles/specialrole"
},
delete: {
href: "http://localhost:8081/scm/api/v2/repositoryRoles/specialrole"
},
update: {
href: "http://localhost:8081/scm/api/v2/repositoryRoles/specialrole"
}
}
};
const role2 = {
name: "WRITE",
verbs: [
"read",
"pull",
"push",
"createPullRequest",
"readPullRequest",
"commentPullRequest",
"mergePullRequest"
],
system: true,
_links: {
self: {
href: "http://localhost:8081/scm/api/v2/repositoryRoles/WRITE"
}
}
};
const responseBody = {
page: 0,
pageTotal: 1,
_links: {
self: {
href:
"http://localhost:8081/scm/api/v2/repositoryRoles/?page=0&pageSize=10"
},
first: {
href:
"http://localhost:8081/scm/api/v2/repositoryRoles/?page=0&pageSize=10"
},
last: {
href:
"http://localhost:8081/scm/api/v2/repositoryRoles/?page=0&pageSize=10"
},
create: {
href: "http://localhost:8081/scm/api/v2/repositoryRoles/"
}
},
_embedded: {
repositoryRoles: [role1, role2]
}
};
const response = {
headers: { "content-type": "application/json" },
responseBody
};
const URL = "repositoryRoles";
const ROLES_URL = "/api/v2/repositoryRoles";
const ROLE1_URL =
"http://localhost:8081/scm/api/v2/repositoryRoles/specialrole";
const error = new Error("FEHLER!");
describe("repository roles fetch", () => {
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should successfully fetch repository roles", () => {
fetchMock.getOnce(ROLES_URL, response);
const expectedActions = [
{ type: FETCH_ROLES_PENDING },
{
type: FETCH_ROLES_SUCCESS,
payload: response
}
];
const store = mockStore({});
return store.dispatch(fetchRoles(URL)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fail getting repository roles on HTTP 500", () => {
fetchMock.getOnce(ROLES_URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchRoles(URL)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_ROLES_PENDING);
expect(actions[1].type).toEqual(FETCH_ROLES_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should sucessfully fetch single role by name", () => {
fetchMock.getOnce(ROLES_URL + "/specialrole", role1);
const store = mockStore({});
return store.dispatch(fetchRoleByName(URL, "specialrole")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_ROLE_PENDING);
expect(actions[1].type).toEqual(FETCH_ROLE_SUCCESS);
expect(actions[1].payload).toBeDefined();
});
});
it("should fail fetching single role by name on HTTP 500", () => {
fetchMock.getOnce(ROLES_URL + "/specialrole", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchRoleByName(URL, "specialrole")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_ROLE_PENDING);
expect(actions[1].type).toEqual(FETCH_ROLE_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should sucessfully fetch single role", () => {
fetchMock.getOnce(ROLE1_URL, role1);
const store = mockStore({});
return store.dispatch(fetchRoleByLink(role1)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_ROLE_PENDING);
expect(actions[1].type).toEqual(FETCH_ROLE_SUCCESS);
expect(actions[1].payload).toBeDefined();
});
});
it("should fail fetching single role on HTTP 500", () => {
fetchMock.getOnce(ROLE1_URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchRoleByLink(role1)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_ROLE_PENDING);
expect(actions[1].type).toEqual(FETCH_ROLE_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should add a role successfully", () => {
// unmatched
fetchMock.postOnce(ROLES_URL, {
status: 204
});
// after create, the roles are fetched again
fetchMock.getOnce(ROLES_URL, response);
const store = mockStore({});
return store.dispatch(createRole(URL, role1)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_ROLE_PENDING);
expect(actions[1].type).toEqual(CREATE_ROLE_SUCCESS);
});
});
it("should fail adding a role on HTTP 500", () => {
fetchMock.postOnce(ROLES_URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(createRole(URL, role1)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_ROLE_PENDING);
expect(actions[1].type).toEqual(CREATE_ROLE_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should call the callback after role successfully created", () => {
// unmatched
fetchMock.postOnce(ROLES_URL, {
status: 204
});
let callMe = "not yet";
const callback = () => {
callMe = "yeah";
};
const store = mockStore({});
return store.dispatch(createRole(URL, role1, callback)).then(() => {
expect(callMe).toBe("yeah");
});
});
it("successfully update role", () => {
fetchMock.putOnce(ROLE1_URL, {
status: 204
});
fetchMock.getOnce(ROLE1_URL, role1);
const store = mockStore({});
return store.dispatch(modifyRole(role1)).then(() => {
const actions = store.getActions();
expect(actions.length).toBe(3);
expect(actions[0].type).toEqual(MODIFY_ROLE_PENDING);
expect(actions[1].type).toEqual(MODIFY_ROLE_SUCCESS);
expect(actions[2].type).toEqual(FETCH_ROLE_PENDING);
});
});
it("should call callback, after successful modified role", () => {
fetchMock.putOnce(ROLE1_URL, {
status: 204
});
fetchMock.getOnce(ROLE1_URL, role1);
let called = false;
const callMe = () => {
called = true;
};
const store = mockStore({});
return store.dispatch(modifyRole(role1, callMe)).then(() => {
expect(called).toBeTruthy();
});
});
it("should fail updating role on HTTP 500", () => {
fetchMock.putOnce(ROLE1_URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(modifyRole(role1)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_ROLE_PENDING);
expect(actions[1].type).toEqual(MODIFY_ROLE_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should delete successfully role1", () => {
fetchMock.deleteOnce(ROLE1_URL, {
status: 204
});
const store = mockStore({});
return store.dispatch(deleteRole(role1)).then(() => {
const actions = store.getActions();
expect(actions.length).toBe(2);
expect(actions[0].type).toEqual(DELETE_ROLE_PENDING);
expect(actions[0].payload).toBe(role1);
expect(actions[1].type).toEqual(DELETE_ROLE_SUCCESS);
});
});
it("should call the callback after successful delete", () => {
fetchMock.deleteOnce(ROLE1_URL, {
status: 204
});
let called = false;
const callMe = () => {
called = true;
};
const store = mockStore({});
return store.dispatch(deleteRole(role1, callMe)).then(() => {
expect(called).toBeTruthy();
});
});
it("should fail to delete role1", () => {
fetchMock.deleteOnce(ROLE1_URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(deleteRole(role1)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(DELETE_ROLE_PENDING);
expect(actions[0].payload).toBe(role1);
expect(actions[1].type).toEqual(DELETE_ROLE_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
});
describe("repository roles reducer", () => {
it("should update state correctly according to FETCH_ROLES_SUCCESS action", () => {
const newState = reducer({}, fetchRolesSuccess(responseBody));
expect(newState.list).toEqual({
entries: ["specialrole", "WRITE"],
entry: {
roleCreatePermission: true,
page: 0,
pageTotal: 1,
_links: responseBody._links
}
});
expect(newState.byNames).toEqual({
specialrole: role1,
WRITE: role2
});
expect(newState.list.entry.roleCreatePermission).toBeTruthy();
});
it("should set roleCreatePermission to true if update link is present", () => {
const newState = reducer({}, fetchRolesSuccess(responseBody));
expect(newState.list.entry.roleCreatePermission).toBeTruthy();
});
it("should not replace whole byNames map when fetching roles", () => {
const oldState = {
byNames: {
WRITE: role2
}
};
const newState = reducer(oldState, fetchRolesSuccess(responseBody));
expect(newState.byNames["specialrole"]).toBeDefined();
expect(newState.byNames["WRITE"]).toBeDefined();
});
it("should remove role from state when delete succeeds", () => {
const state = {
list: {
entries: ["WRITE", "specialrole"]
},
byNames: {
specialrole: role1,
WRITE: role2
}
};
const newState = reducer(state, deleteRoleSuccess(role2));
expect(newState.byNames["specialrole"]).toBeDefined();
expect(newState.byNames["WRITE"]).toBeFalsy();
expect(newState.list.entries).toEqual(["specialrole"]);
});
it("should set roleCreatePermission to true if create link is present", () => {
const newState = reducer({}, fetchRolesSuccess(responseBody));
expect(newState.list.entry.roleCreatePermission).toBeTruthy();
expect(newState.list.entries).toEqual(["specialrole", "WRITE"]);
expect(newState.byNames["WRITE"]).toBeTruthy();
expect(newState.byNames["specialrole"]).toBeTruthy();
});
it("should update state according to FETCH_ROLE_SUCCESS action", () => {
const newState = reducer({}, fetchRoleSuccess(role2));
expect(newState.byNames["WRITE"]).toBe(role2);
});
it("should affect roles state nor the state of other roles", () => {
const newState = reducer(
{
list: {
entries: ["specialrole"]
}
},
fetchRoleSuccess(role2)
);
expect(newState.byNames["WRITE"]).toBe(role2);
expect(newState.list.entries).toEqual(["specialrole"]);
});
});
describe("repository roles selector", () => {
it("should return an empty object", () => {
expect(selectListAsCollection({})).toEqual({});
expect(selectListAsCollection({ roles: { a: "a" } })).toEqual({});
});
it("should return a state slice collection", () => {
const collection = {
page: 3,
totalPages: 42
};
const state = {
roles: {
list: {
entry: collection
}
}
};
expect(selectListAsCollection(state)).toBe(collection);
});
it("should return false", () => {
expect(isPermittedToCreateRoles({})).toBe(false);
expect(isPermittedToCreateRoles({ roles: { list: { entry: {} } } })).toBe(
false
);
expect(
isPermittedToCreateRoles({
roles: { list: { entry: { roleCreatePermission: false } } }
})
).toBe(false);
});
it("should return true", () => {
const state = {
roles: {
list: {
entry: {
roleCreatePermission: true
}
}
}
};
expect(isPermittedToCreateRoles(state)).toBe(true);
});
it("should get repositoryRoles from state", () => {
const state = {
roles: {
list: {
entries: ["a", "b"]
},
byNames: {
a: { name: "a" },
b: { name: "b" }
}
}
};
expect(getRolesFromState(state)).toEqual([{ name: "a" }, { name: "b" }]);
});
it("should return true, when fetch repositoryRoles is pending", () => {
const state = {
pending: {
[FETCH_ROLES]: true
}
};
expect(isFetchRolesPending(state)).toEqual(true);
});
it("should return false, when fetch repositoryRoles is not pending", () => {
expect(isFetchRolesPending({})).toEqual(false);
});
it("should return error when fetch repositoryRoles did fail", () => {
const state = {
failure: {
[FETCH_ROLES]: error
}
};
expect(getFetchRolesFailure(state)).toEqual(error);
});
it("should return undefined when fetch repositoryRoles did not fail", () => {
expect(getFetchRolesFailure({})).toBe(undefined);
});
it("should return true if create role is pending", () => {
const state = {
pending: {
[CREATE_ROLE]: true
}
};
expect(isCreateRolePending(state)).toBe(true);
});
it("should return false if create role is not pending", () => {
const state = {
pending: {
[CREATE_ROLE]: false
}
};
expect(isCreateRolePending(state)).toBe(false);
});
it("should return error when create role did fail", () => {
const state = {
failure: {
[CREATE_ROLE]: error
}
};
expect(getCreateRoleFailure(state)).toEqual(error);
});
it("should return undefined when create role did not fail", () => {
expect(getCreateRoleFailure({})).toBe(undefined);
});
it("should return role1", () => {
const state = {
roles: {
byNames: {
role1: role1
}
}
};
expect(getRoleByName(state, "role1")).toEqual(role1);
});
it("should return true, when fetch role2 is pending", () => {
const state = {
pending: {
[FETCH_ROLE + "/role2"]: true
}
};
expect(isFetchRolePending(state, "role2")).toEqual(true);
});
it("should return false, when fetch role2 is not pending", () => {
expect(isFetchRolePending({}, "role2")).toEqual(false);
});
it("should return error when fetch role2 did fail", () => {
const state = {
failure: {
[FETCH_ROLE + "/role2"]: error
}
};
expect(getFetchRoleFailure(state, "role2")).toEqual(error);
});
it("should return undefined when fetch role2 did not fail", () => {
expect(getFetchRoleFailure({}, "role2")).toBe(undefined);
});
it("should return true, when modify role1 is pending", () => {
const state = {
pending: {
[MODIFY_ROLE + "/role1"]: true
}
};
expect(isModifyRolePending(state, "role1")).toEqual(true);
});
it("should return false, when modify role1 is not pending", () => {
expect(isModifyRolePending({}, "role1")).toEqual(false);
});
it("should return error when modify role1 did fail", () => {
const state = {
failure: {
[MODIFY_ROLE + "/role1"]: error
}
};
expect(getModifyRoleFailure(state, "role1")).toEqual(error);
});
it("should return undefined when modify role1 did not fail", () => {
expect(getModifyRoleFailure({}, "role1")).toBe(undefined);
});
it("should return true, when delete role2 is pending", () => {
const state = {
pending: {
[DELETE_ROLE + "/role2"]: true
}
};
expect(isDeleteRolePending(state, "role2")).toEqual(true);
});
it("should return false, when delete role2 is not pending", () => {
expect(isDeleteRolePending({}, "role2")).toEqual(false);
});
it("should return error when delete role2 did fail", () => {
const state = {
failure: {
[DELETE_ROLE + "/role2"]: error
}
};
expect(getDeleteRoleFailure(state, "role2")).toEqual(error);
});
it("should return undefined when delete role2 did not fail", () => {
expect(getDeleteRoleFailure({}, "role2")).toBe(undefined);
});
});