mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-12 08:25:44 +01:00
scm-ui: new repository layout
This commit is contained in:
@@ -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);
|
||||
209
scm-ui/ui-webapp/src/admin/components/form/ConfigForm.js
Normal file
209
scm-ui/ui-webapp/src/admin/components/form/ConfigForm.js
Normal 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);
|
||||
113
scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.js
Normal file
113
scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.js
Normal 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);
|
||||
97
scm-ui/ui-webapp/src/admin/components/form/LoginAttempt.js
Normal file
97
scm-ui/ui-webapp/src/admin/components/form/LoginAttempt.js
Normal 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);
|
||||
@@ -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);
|
||||
146
scm-ui/ui-webapp/src/admin/components/form/ProxySettings.js
Normal file
146
scm-ui/ui-webapp/src/admin/components/form/ProxySettings.js
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
236
scm-ui/ui-webapp/src/admin/containers/Admin.js
Normal file
236
scm-ui/ui-webapp/src/admin/containers/Admin.js
Normal 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);
|
||||
93
scm-ui/ui-webapp/src/admin/containers/AdminDetails.js
Normal file
93
scm-ui/ui-webapp/src/admin/containers/AdminDetails.js
Normal 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)));
|
||||
179
scm-ui/ui-webapp/src/admin/containers/GlobalConfig.js
Normal file
179
scm-ui/ui-webapp/src/admin/containers/GlobalConfig.js
Normal 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));
|
||||
171
scm-ui/ui-webapp/src/admin/modules/config.js
Normal file
171
scm-ui/ui-webapp/src/admin/modules/config.js
Normal 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;
|
||||
}
|
||||
}
|
||||
303
scm-ui/ui-webapp/src/admin/modules/config.test.js
Normal file
303
scm-ui/ui-webapp/src/admin/modules/config.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
114
scm-ui/ui-webapp/src/admin/modules/namespaceStrategies.js
Normal file
114
scm-ui/ui-webapp/src/admin/modules/namespaceStrategies.js
Normal 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);
|
||||
}
|
||||
199
scm-ui/ui-webapp/src/admin/modules/namespaceStrategies.test.js
Normal file
199
scm-ui/ui-webapp/src/admin/modules/namespaceStrategies.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
205
scm-ui/ui-webapp/src/admin/plugins/components/PluginEntry.js
Normal file
205
scm-ui/ui-webapp/src/admin/plugins/components/PluginEntry.js
Normal 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));
|
||||
@@ -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;
|
||||
27
scm-ui/ui-webapp/src/admin/plugins/components/PluginList.js
Normal file
27
scm-ui/ui-webapp/src/admin/plugins/components/PluginList.js
Normal 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;
|
||||
339
scm-ui/ui-webapp/src/admin/plugins/components/PluginModal.js
Normal file
339
scm-ui/ui-webapp/src/admin/plugins/components/PluginModal.js
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
190
scm-ui/ui-webapp/src/admin/plugins/containers/PluginsOverview.js
Normal file
190
scm-ui/ui-webapp/src/admin/plugins/containers/PluginsOverview.js
Normal 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);
|
||||
255
scm-ui/ui-webapp/src/admin/plugins/modules/plugins.js
Normal file
255
scm-ui/ui-webapp/src/admin/plugins/modules/plugins.js
Normal 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);
|
||||
}
|
||||
353
scm-ui/ui-webapp/src/admin/plugins/modules/plugins.test.js
Normal file
353
scm-ui/ui-webapp/src/admin/plugins/modules/plugins.test.js
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
39
scm-ui/ui-webapp/src/admin/roles/components/SystemRoleTag.js
Normal file
39
scm-ui/ui-webapp/src/admin/roles/components/SystemRoleTag.js
Normal 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));
|
||||
@@ -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));
|
||||
@@ -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)));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
155
scm-ui/ui-webapp/src/admin/roles/containers/RepositoryRoles.js
Normal file
155
scm-ui/ui-webapp/src/admin/roles/containers/RepositoryRoles.js
Normal 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))
|
||||
);
|
||||
@@ -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))
|
||||
);
|
||||
542
scm-ui/ui-webapp/src/admin/roles/modules/roles.js
Normal file
542
scm-ui/ui-webapp/src/admin/roles/modules/roles.js
Normal 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);
|
||||
}
|
||||
653
scm-ui/ui-webapp/src/admin/roles/modules/roles.test.js
Normal file
653
scm-ui/ui-webapp/src/admin/roles/modules/roles.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
84
scm-ui/ui-webapp/src/components/InfoBox.js
Normal file
84
scm-ui/ui-webapp/src/components/InfoBox.js
Normal file
@@ -0,0 +1,84 @@
|
||||
//@flow
|
||||
import * as React from "react";
|
||||
import injectSheet from "react-jss";
|
||||
import classNames from "classnames";
|
||||
import { translate } from "react-i18next";
|
||||
import type { InfoItem } from "./InfoItem";
|
||||
|
||||
const styles = {
|
||||
image: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "column",
|
||||
width: 160,
|
||||
height: 160
|
||||
},
|
||||
icon: {
|
||||
color: "#bff1e6"
|
||||
},
|
||||
label: {
|
||||
marginTop: "0.5em"
|
||||
},
|
||||
content: {
|
||||
marginLeft: "1.5em",
|
||||
minHeight: "10.5rem"
|
||||
},
|
||||
link: {
|
||||
display: "block",
|
||||
marginBottom: "1.5rem"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
type: "plugin" | "feature",
|
||||
item: InfoItem,
|
||||
|
||||
// context props
|
||||
classes: any,
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class InfoBox extends React.Component<Props> {
|
||||
|
||||
renderBody = () => {
|
||||
const { item, t } = this.props;
|
||||
|
||||
const bodyClasses = classNames("media-content", "content", this.props.classes.content);
|
||||
const title = item ? item.title : t("login.loading");
|
||||
const summary = item ? item.summary : t("login.loading");
|
||||
|
||||
return (
|
||||
<div className={bodyClasses}>
|
||||
<h4 className="has-text-link">{title}</h4>
|
||||
<p>{summary}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
render() {
|
||||
const { item, type, classes, t } = this.props;
|
||||
const icon = type === "plugin" ? "puzzle-piece" : "star";
|
||||
return (
|
||||
<a href={item._links.self.href} className={classes.link}>
|
||||
<div className="box media">
|
||||
<figure className="media-left">
|
||||
<div
|
||||
className={classNames("image", "box", "has-background-info", "has-text-white", "has-text-weight-bold", classes.image)}>
|
||||
<i className={classNames("fas", "fa-" + icon, "fa-2x", classes.icon)}/>
|
||||
<div className={classNames("is-size-4", classes.label)}>{t("login." + type)}</div>
|
||||
<div className={classNames("is-size-4")}>{t("login.tip")}</div>
|
||||
</div>
|
||||
</figure>
|
||||
{this.renderBody()}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(translate("commons")(InfoBox));
|
||||
|
||||
|
||||
8
scm-ui/ui-webapp/src/components/InfoItem.js
Normal file
8
scm-ui/ui-webapp/src/components/InfoItem.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// @flow
|
||||
import type { Link } from "@scm-manager/ui-types";
|
||||
|
||||
export type InfoItem = {
|
||||
title: string,
|
||||
summary: string,
|
||||
_links: {[string]: Link}
|
||||
};
|
||||
120
scm-ui/ui-webapp/src/components/LoginForm.js
Normal file
120
scm-ui/ui-webapp/src/components/LoginForm.js
Normal file
@@ -0,0 +1,120 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import { Image, ErrorNotification, InputField, SubmitButton, UnauthorizedError } from "@scm-manager/ui-components";
|
||||
import classNames from "classnames";
|
||||
import injectSheet from "react-jss";
|
||||
|
||||
const styles = {
|
||||
avatar: {
|
||||
marginTop: "-70px",
|
||||
paddingBottom: "20px"
|
||||
},
|
||||
avatarImage: {
|
||||
border: "1px solid lightgray",
|
||||
padding: "5px",
|
||||
background: "#fff",
|
||||
borderRadius: "50%",
|
||||
width: "128px",
|
||||
height: "128px"
|
||||
},
|
||||
avatarSpacing: {
|
||||
marginTop: "5rem"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
error?: Error,
|
||||
loading: boolean,
|
||||
loginHandler: (username: string, password: string) => void,
|
||||
|
||||
// context props
|
||||
t: string => string,
|
||||
classes: any
|
||||
};
|
||||
|
||||
type State = {
|
||||
username: string,
|
||||
password: string
|
||||
};
|
||||
|
||||
class LoginForm extends React.Component<Props, State> {
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { username: "", password: "" };
|
||||
}
|
||||
|
||||
handleSubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (this.isValid()) {
|
||||
this.props.loginHandler(
|
||||
this.state.username,
|
||||
this.state.password
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleUsernameChange = (value: string) => {
|
||||
this.setState({ username: value });
|
||||
};
|
||||
|
||||
handlePasswordChange = (value: string) => {
|
||||
this.setState({ password: value });
|
||||
};
|
||||
|
||||
isValid() {
|
||||
return this.state.username && this.state.password;
|
||||
}
|
||||
|
||||
areCredentialsInvalid() {
|
||||
const { t, error } = this.props;
|
||||
if (error instanceof UnauthorizedError) {
|
||||
return new Error(t("errorNotification.wrongLoginCredentials"));
|
||||
} else {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loading, classes, t } = this.props;
|
||||
return (
|
||||
<div className="column is-4 box has-text-centered has-background-white-ter">
|
||||
<h3 className="title">{t("login.title")}</h3>
|
||||
<p className="subtitle">{t("login.subtitle")}</p>
|
||||
<div className={classNames("box", classes.avatarSpacing)}>
|
||||
<figure className={classes.avatar}>
|
||||
<Image
|
||||
className={classes.avatarImage}
|
||||
src="/images/blib.jpg"
|
||||
alt={t("login.logo-alt")}
|
||||
/>
|
||||
</figure>
|
||||
<ErrorNotification error={this.areCredentialsInvalid()}/>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<InputField
|
||||
placeholder={t("login.username-placeholder")}
|
||||
autofocus={true}
|
||||
onChange={this.handleUsernameChange}
|
||||
/>
|
||||
<InputField
|
||||
placeholder={t("login.password-placeholder")}
|
||||
type="password"
|
||||
onChange={this.handlePasswordChange}
|
||||
/>
|
||||
<SubmitButton
|
||||
label={t("login.submit")}
|
||||
fullWidth={true}
|
||||
loading={loading}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(translate("commons")(LoginForm));
|
||||
|
||||
|
||||
97
scm-ui/ui-webapp/src/components/LoginInfo.js
Normal file
97
scm-ui/ui-webapp/src/components/LoginInfo.js
Normal file
@@ -0,0 +1,97 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import InfoBox from "./InfoBox";
|
||||
import type { InfoItem } from "./InfoItem";
|
||||
import LoginForm from "./LoginForm";
|
||||
import { Loading } from "@scm-manager/ui-components";
|
||||
|
||||
type Props = {
|
||||
loginInfoLink?: string,
|
||||
loading?: boolean,
|
||||
error?: Error,
|
||||
loginHandler: (username: string, password: string) => void,
|
||||
};
|
||||
|
||||
type LoginInfoResponse = {
|
||||
plugin?: InfoItem,
|
||||
feature?: InfoItem
|
||||
};
|
||||
|
||||
type State = {
|
||||
info?: LoginInfoResponse,
|
||||
loading?: boolean,
|
||||
};
|
||||
|
||||
class LoginInfo extends React.Component<Props, State> {
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
loading: !!props.loginInfoLink
|
||||
};
|
||||
}
|
||||
|
||||
fetchLoginInfo = (url: string) => {
|
||||
return fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(info => {
|
||||
this.setState({
|
||||
info,
|
||||
loading: false
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
timeout = (ms: number, promise: Promise<any>) => {
|
||||
return new Promise<LoginInfoResponse>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error("timeout during fetch of login info"));
|
||||
}, ms);
|
||||
promise.then(resolve, reject);
|
||||
});
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { loginInfoLink } = this.props;
|
||||
if (!loginInfoLink) {
|
||||
return;
|
||||
}
|
||||
this.timeout(1000, this.fetchLoginInfo(loginInfoLink))
|
||||
.catch(() => {
|
||||
this.setState({
|
||||
loading: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createInfoPanel = (info: LoginInfoResponse) => (
|
||||
<div className="column is-7 is-offset-1 is-paddingless">
|
||||
<InfoBox item={info.feature} type="feature" />
|
||||
<InfoBox item={info.plugin} type="plugin" />
|
||||
</div>
|
||||
);
|
||||
|
||||
render() {
|
||||
const { info, loading } = this.state;
|
||||
if (loading) {
|
||||
return <Loading/>;
|
||||
}
|
||||
|
||||
let infoPanel;
|
||||
if (info) {
|
||||
infoPanel = this.createInfoPanel(info);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginForm {...this.props} />
|
||||
{infoPanel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default LoginInfo;
|
||||
|
||||
|
||||
124
scm-ui/ui-webapp/src/containers/App.js
Normal file
124
scm-ui/ui-webapp/src/containers/App.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// @flow
|
||||
import React, { Component } from "react";
|
||||
import Main from "./Main";
|
||||
import { connect } from "react-redux";
|
||||
import { translate } from "react-i18next";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import {
|
||||
fetchMe,
|
||||
isAuthenticated,
|
||||
getMe,
|
||||
isFetchMePending,
|
||||
getFetchMeFailure
|
||||
} from "../modules/auth";
|
||||
|
||||
import {
|
||||
PrimaryNavigation,
|
||||
Loading,
|
||||
ErrorPage,
|
||||
Footer,
|
||||
Header
|
||||
} from "@scm-manager/ui-components";
|
||||
import type { Links, Me } from "@scm-manager/ui-types";
|
||||
import {
|
||||
getFetchIndexResourcesFailure,
|
||||
getLinks,
|
||||
getMeLink,
|
||||
isFetchIndexResourcesPending
|
||||
} from "../modules/indexResource";
|
||||
|
||||
type Props = {
|
||||
me: Me,
|
||||
authenticated: boolean,
|
||||
error: Error,
|
||||
loading: boolean,
|
||||
links: Links,
|
||||
meLink: string,
|
||||
|
||||
// dispatcher functions
|
||||
fetchMe: (link: string) => void,
|
||||
|
||||
// context props
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class App extends Component<Props> {
|
||||
componentDidMount() {
|
||||
if (this.props.meLink) {
|
||||
this.props.fetchMe(this.props.meLink);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
me,
|
||||
loading,
|
||||
error,
|
||||
authenticated,
|
||||
links,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
let content;
|
||||
const navigation = authenticated ? (
|
||||
<PrimaryNavigation
|
||||
links={links}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
content = <Loading />;
|
||||
} else if (error) {
|
||||
content = (
|
||||
<ErrorPage
|
||||
title={t("app.error.title")}
|
||||
subtitle={t("app.error.subtitle")}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = <Main authenticated={authenticated} links={links} />;
|
||||
}
|
||||
return (
|
||||
<div className="App">
|
||||
<Header>{navigation}</Header>
|
||||
{content}
|
||||
<Footer me={me} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
fetchMe: (link: string) => dispatch(fetchMe(link))
|
||||
};
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const authenticated = isAuthenticated(state);
|
||||
const me = getMe(state);
|
||||
const loading =
|
||||
isFetchMePending(state) || isFetchIndexResourcesPending(state);
|
||||
const error =
|
||||
getFetchMeFailure(state) || getFetchIndexResourcesFailure(state);
|
||||
const links = getLinks(state);
|
||||
const meLink = getMeLink(state);
|
||||
return {
|
||||
authenticated,
|
||||
me,
|
||||
loading,
|
||||
error,
|
||||
links,
|
||||
meLink
|
||||
};
|
||||
};
|
||||
|
||||
export default withRouter(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("commons")(App))
|
||||
);
|
||||
159
scm-ui/ui-webapp/src/containers/ChangeUserPassword.js
Normal file
159
scm-ui/ui-webapp/src/containers/ChangeUserPassword.js
Normal file
@@ -0,0 +1,159 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import {
|
||||
ErrorNotification,
|
||||
InputField,
|
||||
Notification,
|
||||
PasswordConfirmation,
|
||||
SubmitButton
|
||||
} from "@scm-manager/ui-components";
|
||||
import { translate } from "react-i18next";
|
||||
import type { Me } from "@scm-manager/ui-types";
|
||||
import { changePassword } from "../modules/changePassword";
|
||||
|
||||
type Props = {
|
||||
me: Me,
|
||||
t: string => string
|
||||
};
|
||||
|
||||
type State = {
|
||||
oldPassword: string,
|
||||
password: string,
|
||||
loading: boolean,
|
||||
error?: Error,
|
||||
passwordChanged: boolean,
|
||||
passwordValid: boolean
|
||||
};
|
||||
|
||||
class ChangeUserPassword extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
oldPassword: "",
|
||||
password: "",
|
||||
loading: false,
|
||||
passwordConfirmationError: false,
|
||||
validatePasswordError: false,
|
||||
validatePassword: "",
|
||||
passwordChanged: false,
|
||||
passwordValid: false
|
||||
};
|
||||
}
|
||||
|
||||
setLoadingState = () => {
|
||||
this.setState({
|
||||
...this.state,
|
||||
loading: true
|
||||
});
|
||||
};
|
||||
|
||||
setErrorState = (error: Error) => {
|
||||
this.setState({
|
||||
...this.state,
|
||||
error: error,
|
||||
loading: false
|
||||
});
|
||||
};
|
||||
|
||||
setSuccessfulState = () => {
|
||||
this.setState({
|
||||
...this.state,
|
||||
loading: false,
|
||||
passwordChanged: true,
|
||||
oldPassword: "",
|
||||
password: ""
|
||||
});
|
||||
};
|
||||
|
||||
submit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (this.state.password) {
|
||||
const { oldPassword, password } = this.state;
|
||||
this.setLoadingState();
|
||||
changePassword(this.props.me._links.password.href, oldPassword, password)
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
this.setErrorState(result.error);
|
||||
} else {
|
||||
this.setSuccessfulState();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
this.setErrorState(err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
isValid = () => {
|
||||
return this.state.oldPassword && this.state.passwordValid;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { loading, passwordChanged, error } = this.state;
|
||||
|
||||
let message = null;
|
||||
|
||||
if (passwordChanged) {
|
||||
message = (
|
||||
<Notification
|
||||
type={"success"}
|
||||
children={t("password.changedSuccessfully")}
|
||||
onClose={() => this.onClose()}
|
||||
/>
|
||||
);
|
||||
} else if (error) {
|
||||
message = <ErrorNotification error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={this.submit}>
|
||||
{message}
|
||||
<div className="columns">
|
||||
<div className="column">
|
||||
<InputField
|
||||
label={t("password.currentPassword")}
|
||||
type="password"
|
||||
onChange={oldPassword =>
|
||||
this.setState({ ...this.state, oldPassword })
|
||||
}
|
||||
value={this.state.oldPassword ? this.state.oldPassword : ""}
|
||||
helpText={t("password.currentPasswordHelpText")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<PasswordConfirmation
|
||||
passwordChanged={this.passwordChanged}
|
||||
key={this.state.passwordChanged ? "changed" : "unchanged"}
|
||||
/>
|
||||
<div className="columns">
|
||||
<div className="column">
|
||||
<SubmitButton
|
||||
disabled={!this.isValid()}
|
||||
loading={loading}
|
||||
label={t("password.submit")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
passwordChanged = (password: string, passwordValid: boolean) => {
|
||||
this.setState({
|
||||
...this.state,
|
||||
password,
|
||||
passwordValid: !!password && passwordValid
|
||||
});
|
||||
};
|
||||
|
||||
onClose = () => {
|
||||
this.setState({
|
||||
...this.state,
|
||||
passwordChanged: false
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default translate("commons")(ChangeUserPassword);
|
||||
101
scm-ui/ui-webapp/src/containers/Index.js
Normal file
101
scm-ui/ui-webapp/src/containers/Index.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// @flow
|
||||
import React, { Component } from "react";
|
||||
import App from "./App";
|
||||
import { connect } from "react-redux";
|
||||
import { translate } from "react-i18next";
|
||||
import { withRouter } from "react-router-dom";
|
||||
|
||||
import { Loading, ErrorBoundary } from "@scm-manager/ui-components";
|
||||
import {
|
||||
fetchIndexResources,
|
||||
getFetchIndexResourcesFailure,
|
||||
getLinks,
|
||||
isFetchIndexResourcesPending
|
||||
} from "../modules/indexResource";
|
||||
import PluginLoader from "./PluginLoader";
|
||||
import type { IndexResources } from "@scm-manager/ui-types";
|
||||
import ScrollToTop from "./ScrollToTop";
|
||||
import IndexErrorPage from "./IndexErrorPage";
|
||||
|
||||
type Props = {
|
||||
error: Error,
|
||||
loading: boolean,
|
||||
indexResources: IndexResources,
|
||||
|
||||
// dispatcher functions
|
||||
fetchIndexResources: () => void,
|
||||
|
||||
// context props
|
||||
t: string => string
|
||||
};
|
||||
|
||||
type State = {
|
||||
pluginsLoaded: boolean
|
||||
};
|
||||
|
||||
class Index extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
pluginsLoaded: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchIndexResources();
|
||||
}
|
||||
|
||||
pluginLoaderCallback = () => {
|
||||
this.setState({
|
||||
pluginsLoaded: true
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { indexResources, loading, error } = this.props;
|
||||
const { pluginsLoaded } = this.state;
|
||||
|
||||
if (error) {
|
||||
return <IndexErrorPage error={error}/>;
|
||||
} else if (loading || !indexResources) {
|
||||
return <Loading />;
|
||||
} else {
|
||||
return (
|
||||
<ErrorBoundary fallback={IndexErrorPage}>
|
||||
<ScrollToTop>
|
||||
<PluginLoader
|
||||
loaded={pluginsLoaded}
|
||||
callback={this.pluginLoaderCallback}
|
||||
>
|
||||
<App />
|
||||
</PluginLoader>
|
||||
</ScrollToTop>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
fetchIndexResources: () => dispatch(fetchIndexResources())
|
||||
};
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const loading = isFetchIndexResourcesPending(state);
|
||||
const error = getFetchIndexResourcesFailure(state);
|
||||
const indexResources = getLinks(state);
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
indexResources
|
||||
};
|
||||
};
|
||||
|
||||
export default withRouter(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("commons")(Index))
|
||||
);
|
||||
26
scm-ui/ui-webapp/src/containers/IndexErrorPage.js
Normal file
26
scm-ui/ui-webapp/src/containers/IndexErrorPage.js
Normal file
@@ -0,0 +1,26 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate, type TFunction } from "react-i18next";
|
||||
import { ErrorPage } from "@scm-manager/ui-components";
|
||||
|
||||
type Props = {
|
||||
error: Error,
|
||||
t: TFunction
|
||||
}
|
||||
|
||||
class IndexErrorPage extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
const { error, t } = this.props;
|
||||
return (
|
||||
<ErrorPage
|
||||
title={t("app.error.title")}
|
||||
subtitle={t("app.error.subtitle")}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default translate("commons")(IndexErrorPage);
|
||||
100
scm-ui/ui-webapp/src/containers/Login.js
Normal file
100
scm-ui/ui-webapp/src/containers/Login.js
Normal file
@@ -0,0 +1,100 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { Redirect, withRouter } from "react-router-dom";
|
||||
import {
|
||||
login,
|
||||
isAuthenticated,
|
||||
isLoginPending,
|
||||
getLoginFailure
|
||||
} from "../modules/auth";
|
||||
import { connect } from "react-redux";
|
||||
import { getLoginLink, getLoginInfoLink } from "../modules/indexResource";
|
||||
import LoginInfo from "../components/LoginInfo";
|
||||
import classNames from "classnames";
|
||||
import injectSheet from "react-jss";
|
||||
|
||||
const styles = {
|
||||
section: {
|
||||
paddingTop: "2em"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
authenticated: boolean,
|
||||
loading: boolean,
|
||||
error?: Error,
|
||||
link: string,
|
||||
loginInfoLink?: string,
|
||||
|
||||
// dispatcher props
|
||||
login: (link: string, username: string, password: string) => void,
|
||||
|
||||
// context props
|
||||
classes: any,
|
||||
t: string => string,
|
||||
from: any,
|
||||
location: any
|
||||
};
|
||||
|
||||
class Login extends React.Component<Props> {
|
||||
|
||||
handleLogin = (username: string, password: string): void => {
|
||||
const { link, login } = this.props;
|
||||
login(link, username, password);
|
||||
};
|
||||
|
||||
renderRedirect = () => {
|
||||
const { from } = this.props.location.state || { from: { pathname: "/" } };
|
||||
return <Redirect to={from}/>;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { authenticated, classes, ...restProps } = this.props;
|
||||
|
||||
if (authenticated) {
|
||||
return this.renderRedirect();
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={classNames("hero", classes.section )}>
|
||||
<div className="hero-body">
|
||||
<div className="container">
|
||||
<div className="columns is-centered">
|
||||
<LoginInfo loginHandler={this.handleLogin} {...restProps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const authenticated = isAuthenticated(state);
|
||||
const loading = isLoginPending(state);
|
||||
const error = getLoginFailure(state);
|
||||
const link = getLoginLink(state);
|
||||
const loginInfoLink = getLoginInfoLink(state);
|
||||
return {
|
||||
authenticated,
|
||||
loading,
|
||||
error,
|
||||
link,
|
||||
loginInfoLink
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
login: (loginLink: string, username: string, password: string) =>
|
||||
dispatch(login(loginLink, username, password))
|
||||
};
|
||||
};
|
||||
|
||||
const StyledLogin = injectSheet(styles)(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(Login)
|
||||
);
|
||||
export default withRouter(StyledLogin);
|
||||
77
scm-ui/ui-webapp/src/containers/Logout.js
Normal file
77
scm-ui/ui-webapp/src/containers/Logout.js
Normal file
@@ -0,0 +1,77 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { translate } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
|
||||
import {
|
||||
logout,
|
||||
isAuthenticated,
|
||||
isLogoutPending,
|
||||
getLogoutFailure, isRedirecting
|
||||
} from "../modules/auth";
|
||||
import { Loading, ErrorPage } from "@scm-manager/ui-components";
|
||||
import { getLogoutLink } from "../modules/indexResource";
|
||||
|
||||
type Props = {
|
||||
authenticated: boolean,
|
||||
loading: boolean,
|
||||
redirecting: boolean,
|
||||
error: Error,
|
||||
logoutLink: string,
|
||||
|
||||
// dispatcher functions
|
||||
logout: (link: string) => void,
|
||||
|
||||
// context props
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class Logout extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.logout(this.props.logoutLink);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { authenticated, redirecting, loading, error, t } = this.props;
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title={t("logout.error.title")}
|
||||
subtitle={t("logout.error.subtitle")}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
} else if (loading || authenticated || redirecting) {
|
||||
return <Loading />;
|
||||
} else {
|
||||
return <Redirect to="/login" />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const authenticated = isAuthenticated(state);
|
||||
const loading = isLogoutPending(state);
|
||||
const redirecting = isRedirecting(state);
|
||||
const error = getLogoutFailure(state);
|
||||
const logoutLink = getLogoutLink(state);
|
||||
return {
|
||||
authenticated,
|
||||
loading,
|
||||
redirecting,
|
||||
error,
|
||||
logoutLink
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
logout: (link: string) => dispatch(logout(link))
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("commons")(Logout));
|
||||
138
scm-ui/ui-webapp/src/containers/Main.js
Normal file
138
scm-ui/ui-webapp/src/containers/Main.js
Normal file
@@ -0,0 +1,138 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
|
||||
import { Redirect, Route, Switch, withRouter } from "react-router-dom";
|
||||
import type { Links } from "@scm-manager/ui-types";
|
||||
|
||||
import Overview from "../repos/containers/Overview";
|
||||
import Users from "../users/containers/Users";
|
||||
import Login from "../containers/Login";
|
||||
import Logout from "../containers/Logout";
|
||||
|
||||
import { ProtectedRoute } from "@scm-manager/ui-components";
|
||||
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
|
||||
import CreateUser from "../users/containers/CreateUser";
|
||||
import SingleUser from "../users/containers/SingleUser";
|
||||
import RepositoryRoot from "../repos/containers/RepositoryRoot";
|
||||
import Create from "../repos/containers/Create";
|
||||
|
||||
import Groups from "../groups/containers/Groups";
|
||||
import SingleGroup from "../groups/containers/SingleGroup";
|
||||
import CreateGroup from "../groups/containers/CreateGroup";
|
||||
|
||||
import Admin from "../admin/containers/Admin";
|
||||
|
||||
import Profile from "./Profile";
|
||||
|
||||
type Props = {
|
||||
authenticated?: boolean,
|
||||
links: Links
|
||||
};
|
||||
|
||||
class Main extends React.Component<Props> {
|
||||
render() {
|
||||
const { authenticated, links } = this.props;
|
||||
const redirectUrlFactory = binder.getExtension("main.redirect", this.props);
|
||||
let url = "/repos/";
|
||||
if (redirectUrlFactory) {
|
||||
url = redirectUrlFactory(this.props);
|
||||
}
|
||||
return (
|
||||
<div className="main">
|
||||
<Switch>
|
||||
<Redirect exact from="/" to={url} />
|
||||
<Route exact path="/login" component={Login} />
|
||||
<Route path="/logout" component={Logout} />
|
||||
<Redirect exact strict from="/repos" to="/repos/" />
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/repos/"
|
||||
component={Overview}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/repos/create"
|
||||
component={Create}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/repos/:page"
|
||||
component={Overview}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/repo/:namespace/:name"
|
||||
component={RepositoryRoot}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<Redirect exact strict from="/users" to="/users/" />
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/users/"
|
||||
component={Users}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
authenticated={authenticated}
|
||||
path="/users/create"
|
||||
component={CreateUser}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/users/:page"
|
||||
component={Users}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
authenticated={authenticated}
|
||||
path="/user/:name"
|
||||
component={SingleUser}
|
||||
/>
|
||||
<Redirect exact strict from="/groups" to="/groups/" />
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/groups/"
|
||||
component={Groups}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
authenticated={authenticated}
|
||||
path="/group/:name"
|
||||
component={SingleGroup}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
authenticated={authenticated}
|
||||
path="/groups/create"
|
||||
component={CreateGroup}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/groups/:page"
|
||||
component={Groups}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/admin"
|
||||
component={Admin}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/me"
|
||||
component={Profile}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ExtensionPoint
|
||||
name="main.route"
|
||||
renderAll={true}
|
||||
props={{ authenticated, links }}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Main);
|
||||
125
scm-ui/ui-webapp/src/containers/PluginLoader.js
Normal file
125
scm-ui/ui-webapp/src/containers/PluginLoader.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { apiClient, Loading } from "@scm-manager/ui-components";
|
||||
import { getUiPluginsLink } from "../modules/indexResource";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
type Props = {
|
||||
loaded: boolean,
|
||||
children: React.Node,
|
||||
link: string,
|
||||
callback: () => void
|
||||
};
|
||||
|
||||
type State = {
|
||||
message: string
|
||||
};
|
||||
|
||||
type Plugin = {
|
||||
name: string,
|
||||
bundles: string[]
|
||||
};
|
||||
|
||||
class PluginLoader extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
message: "booting"
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { loaded } = this.props;
|
||||
if (!loaded) {
|
||||
this.setState({
|
||||
message: "loading plugin information"
|
||||
});
|
||||
|
||||
this.getPlugins(this.props.link);
|
||||
}
|
||||
}
|
||||
|
||||
getPlugins = (link: string): Promise<any> => {
|
||||
return apiClient
|
||||
.get(link)
|
||||
.then(response => response.text())
|
||||
.then(JSON.parse)
|
||||
.then(pluginCollection => pluginCollection._embedded.plugins)
|
||||
.then(this.loadPlugins)
|
||||
.then(this.props.callback);
|
||||
};
|
||||
|
||||
loadPlugins = (plugins: Plugin[]) => {
|
||||
this.setState({
|
||||
message: "loading plugins"
|
||||
});
|
||||
|
||||
const promises = [];
|
||||
const sortedPlugins = plugins.sort(comparePluginsByName);
|
||||
for (let plugin of sortedPlugins) {
|
||||
promises.push(this.loadPlugin(plugin));
|
||||
}
|
||||
return promises.reduce((chain, current) => {
|
||||
return chain.then(chainResults => {
|
||||
return current.then(currentResult => [...chainResults, currentResult]);
|
||||
});
|
||||
}, Promise.resolve([]));
|
||||
};
|
||||
|
||||
loadPlugin = (plugin: Plugin) => {
|
||||
this.setState({
|
||||
message: `loading ${plugin.name}`
|
||||
});
|
||||
|
||||
const promises = [];
|
||||
for (let bundle of plugin.bundles) {
|
||||
promises.push(this.loadBundle(bundle));
|
||||
}
|
||||
return Promise.all(promises);
|
||||
};
|
||||
|
||||
loadBundle = (bundle: string) => {
|
||||
return fetch(bundle, {
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Cache: "no-cache",
|
||||
// identify the request as ajax request
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
return response.text();
|
||||
})
|
||||
.then(script => {
|
||||
// TODO is this safe???
|
||||
// eslint-disable-next-line no-eval
|
||||
eval(script); // NOSONAR
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loaded } = this.props;
|
||||
const { message } = this.state;
|
||||
if (loaded) {
|
||||
return <div>{this.props.children}</div>;
|
||||
}
|
||||
return <Loading message={message} />;
|
||||
}
|
||||
}
|
||||
const comparePluginsByName = (a: Plugin, b: Plugin) => {
|
||||
if (a.name < b.name) {
|
||||
return -1;
|
||||
}
|
||||
if (a.name > b.name) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
const mapStateToProps = state => {
|
||||
const link = getUiPluginsLink(state);
|
||||
return {
|
||||
link
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(PluginLoader);
|
||||
123
scm-ui/ui-webapp/src/containers/Profile.js
Normal file
123
scm-ui/ui-webapp/src/containers/Profile.js
Normal file
@@ -0,0 +1,123 @@
|
||||
// @flow
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { Route, withRouter } from "react-router-dom";
|
||||
import { getMe } from "../modules/auth";
|
||||
import { compose } from "redux";
|
||||
import { connect } from "react-redux";
|
||||
import { translate } from "react-i18next";
|
||||
import type { Me } from "@scm-manager/ui-types";
|
||||
import {
|
||||
ErrorPage,
|
||||
Page,
|
||||
Navigation,
|
||||
SubNavigation,
|
||||
Section,
|
||||
NavLink
|
||||
} from "@scm-manager/ui-components";
|
||||
import ChangeUserPassword from "./ChangeUserPassword";
|
||||
import ProfileInfo from "./ProfileInfo";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
|
||||
type Props = {
|
||||
me: Me,
|
||||
|
||||
// Context props
|
||||
t: string => string,
|
||||
match: any
|
||||
};
|
||||
type State = {};
|
||||
|
||||
class Profile extends React.Component<Props, State> {
|
||||
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 url = this.matchedUrl();
|
||||
|
||||
const { me, t } = this.props;
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title={t("profile.error-title")}
|
||||
subtitle={t("profile.error-subtitle")}
|
||||
error={{
|
||||
name: t("profile.error"),
|
||||
message: t("profile.error-message")
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const extensionProps = {
|
||||
me,
|
||||
url
|
||||
};
|
||||
|
||||
return (
|
||||
<Page title={me.displayName}>
|
||||
<div className="columns">
|
||||
<div className="column is-three-quarters">
|
||||
<Route path={url} exact render={() => <ProfileInfo me={me} />} />
|
||||
<Route
|
||||
path={`${url}/settings/password`}
|
||||
render={() => <ChangeUserPassword me={me} />}
|
||||
/>
|
||||
<ExtensionPoint
|
||||
name="profile.route"
|
||||
props={extensionProps}
|
||||
renderAll={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="column">
|
||||
<Navigation>
|
||||
<Section label={t("profile.navigationLabel")}>
|
||||
<NavLink
|
||||
to={`${url}`}
|
||||
icon="fas fa-info-circle"
|
||||
label={t("profile.informationNavLink")}
|
||||
/>
|
||||
<SubNavigation
|
||||
to={`${url}/settings/password`}
|
||||
label={t("profile.settingsNavLink")}
|
||||
>
|
||||
<NavLink
|
||||
to={`${url}/settings/password`}
|
||||
label={t("profile.changePasswordNavLink")}
|
||||
/>
|
||||
<ExtensionPoint
|
||||
name="profile.setting"
|
||||
props={extensionProps}
|
||||
renderAll={true}
|
||||
/>
|
||||
</SubNavigation>
|
||||
</Section>
|
||||
</Navigation>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
me: getMe(state)
|
||||
};
|
||||
};
|
||||
|
||||
export default compose(
|
||||
translate("commons"),
|
||||
connect(mapStateToProps),
|
||||
withRouter
|
||||
)(Profile);
|
||||
89
scm-ui/ui-webapp/src/containers/ProfileInfo.js
Normal file
89
scm-ui/ui-webapp/src/containers/ProfileInfo.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import type { Me } from "@scm-manager/ui-types";
|
||||
import {
|
||||
MailLink,
|
||||
AvatarWrapper,
|
||||
AvatarImage
|
||||
} from "@scm-manager/ui-components";
|
||||
import { compose } from "redux";
|
||||
import { translate } from "react-i18next";
|
||||
import injectSheet from "react-jss";
|
||||
|
||||
type Props = {
|
||||
me: Me,
|
||||
|
||||
// Context props
|
||||
classes: any,
|
||||
t: string => string
|
||||
};
|
||||
|
||||
const styles = {
|
||||
spacing: {
|
||||
padding: "0 !important"
|
||||
}
|
||||
};
|
||||
|
||||
class ProfileInfo extends React.Component<Props> {
|
||||
render() {
|
||||
const { me, t } = this.props;
|
||||
return (
|
||||
<div className="media">
|
||||
<AvatarWrapper>
|
||||
<figure className="media-left">
|
||||
<p className="image is-64x64">
|
||||
<AvatarImage person={me} />
|
||||
</p>
|
||||
</figure>
|
||||
</AvatarWrapper>
|
||||
<div className="media-content">
|
||||
<table className="table content">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{t("profile.username")}</th>
|
||||
<td>{me.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("profile.displayName")}</th>
|
||||
<td>{me.displayName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("profile.mail")}</th>
|
||||
<td>
|
||||
<MailLink address={me.mail} />
|
||||
</td>
|
||||
</tr>
|
||||
{this.renderGroups()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderGroups() {
|
||||
const { me, t, classes } = this.props;
|
||||
|
||||
let groups = null;
|
||||
if (me.groups.length > 0) {
|
||||
groups = (
|
||||
<tr>
|
||||
<th>{t("profile.groups")}</th>
|
||||
<td className={classes.spacing}>
|
||||
<ul>
|
||||
{me.groups.map(group => {
|
||||
return <li>{group}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
|
||||
export default compose(
|
||||
injectSheet(styles),
|
||||
translate("commons")
|
||||
)(ProfileInfo);
|
||||
23
scm-ui/ui-webapp/src/containers/ScrollToTop.js
Normal file
23
scm-ui/ui-webapp/src/containers/ScrollToTop.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { withRouter } from "react-router-dom";
|
||||
|
||||
|
||||
type Props = {
|
||||
location: any,
|
||||
children: any
|
||||
}
|
||||
|
||||
class ScrollToTop extends React.Component<Props> {
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.location.pathname !== prevProps.location.pathname) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(ScrollToTop);
|
||||
56
scm-ui/ui-webapp/src/createReduxStore.js
Normal file
56
scm-ui/ui-webapp/src/createReduxStore.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// @flow
|
||||
import thunk from "redux-thunk";
|
||||
import logger from "redux-logger";
|
||||
import { createStore, compose, applyMiddleware, combineReducers } from "redux";
|
||||
import { routerReducer, routerMiddleware } from "react-router-redux";
|
||||
|
||||
import users from "./users/modules/users";
|
||||
import repos from "./repos/modules/repos";
|
||||
import repositoryTypes from "./repos/modules/repositoryTypes";
|
||||
import changesets from "./repos/modules/changesets";
|
||||
import sources from "./repos/sources/modules/sources";
|
||||
import groups from "./groups/modules/groups";
|
||||
import auth from "./modules/auth";
|
||||
import pending from "./modules/pending";
|
||||
import failure from "./modules/failure";
|
||||
import permissions from "./repos/permissions/modules/permissions";
|
||||
import config from "./admin/modules/config";
|
||||
import roles from "./admin/roles/modules/roles";
|
||||
import namespaceStrategies from "./admin/modules/namespaceStrategies";
|
||||
import indexResources from "./modules/indexResource";
|
||||
import plugins from "./admin/plugins/modules/plugins";
|
||||
|
||||
import type { BrowserHistory } from "history/createBrowserHistory";
|
||||
import branches from "./repos/branches/modules/branches";
|
||||
|
||||
function createReduxStore(history: BrowserHistory) {
|
||||
const composeEnhancers =
|
||||
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
|
||||
const reducer = combineReducers({
|
||||
router: routerReducer,
|
||||
pending,
|
||||
failure,
|
||||
indexResources,
|
||||
users,
|
||||
repos,
|
||||
repositoryTypes,
|
||||
changesets,
|
||||
branches,
|
||||
permissions,
|
||||
groups,
|
||||
auth,
|
||||
config,
|
||||
roles,
|
||||
sources,
|
||||
namespaceStrategies,
|
||||
plugins
|
||||
});
|
||||
|
||||
return createStore(
|
||||
reducer,
|
||||
composeEnhancers(applyMiddleware(routerMiddleware(history), thunk, logger))
|
||||
);
|
||||
}
|
||||
|
||||
export default createReduxStore;
|
||||
215
scm-ui/ui-webapp/src/groups/components/GroupForm.js
Normal file
215
scm-ui/ui-webapp/src/groups/components/GroupForm.js
Normal file
@@ -0,0 +1,215 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import {
|
||||
Subtitle,
|
||||
AutocompleteAddEntryToTableField,
|
||||
MemberNameTagGroup,
|
||||
InputField,
|
||||
SubmitButton,
|
||||
Textarea,
|
||||
Checkbox
|
||||
} from "@scm-manager/ui-components";
|
||||
import type { Group, SelectValue } from "@scm-manager/ui-types";
|
||||
|
||||
import * as validator from "./groupValidation";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
submitForm: Group => void,
|
||||
loading?: boolean,
|
||||
group?: Group,
|
||||
loadUserSuggestions: string => any
|
||||
};
|
||||
|
||||
type State = {
|
||||
group: Group,
|
||||
nameValidationError: boolean
|
||||
};
|
||||
|
||||
class GroupForm extends React.Component<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
group: {
|
||||
name: "",
|
||||
description: "",
|
||||
_embedded: {
|
||||
members: []
|
||||
},
|
||||
_links: {},
|
||||
members: [],
|
||||
type: "",
|
||||
external: false
|
||||
},
|
||||
nameValidationError: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { group } = this.props;
|
||||
if (group) {
|
||||
this.setState({ ...this.state, group: { ...group } });
|
||||
}
|
||||
}
|
||||
|
||||
isFalsy(value) {
|
||||
return !value;
|
||||
}
|
||||
|
||||
isValid = () => {
|
||||
const group = this.state.group;
|
||||
return !(this.state.nameValidationError || this.isFalsy(group.name));
|
||||
};
|
||||
|
||||
submit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (this.isValid()) {
|
||||
const { group } = this.state;
|
||||
if (group.external) {
|
||||
group.members = [];
|
||||
}
|
||||
this.props.submitForm(group);
|
||||
}
|
||||
};
|
||||
|
||||
renderMemberfields = (group: Group) => {
|
||||
if (group.external) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { loadUserSuggestions, t } = this.props;
|
||||
return (
|
||||
<>
|
||||
<MemberNameTagGroup
|
||||
members={group.members}
|
||||
memberListChanged={this.memberListChanged}
|
||||
/>
|
||||
<AutocompleteAddEntryToTableField
|
||||
addEntry={this.addMember}
|
||||
disabled={false}
|
||||
buttonLabel={t("add-member-button.label")}
|
||||
errorMessage={t("add-member-textfield.error")}
|
||||
loadSuggestions={loadUserSuggestions}
|
||||
placeholder={t("add-member-autocomplete.placeholder")}
|
||||
loadingMessage={t("add-member-autocomplete.loading")}
|
||||
noOptionsMessage={t("add-member-autocomplete.no-options")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
renderExternalField = (group: Group) => {
|
||||
const { t } = this.props;
|
||||
if (this.isExistingGroup()) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Checkbox
|
||||
label={t("group.external")}
|
||||
checked={group.external}
|
||||
helpText={t("groupForm.help.externalHelpText")}
|
||||
onChange={this.handleExternalChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
isExistingGroup = () => !!this.props.group;
|
||||
|
||||
render() {
|
||||
const { loading, t } = this.props;
|
||||
const { group } = this.state;
|
||||
let nameField = null;
|
||||
let subtitle = null;
|
||||
if (!this.isExistingGroup()) {
|
||||
// create new group
|
||||
nameField = (
|
||||
<InputField
|
||||
label={t("group.name")}
|
||||
errorMessage={t("groupForm.nameError")}
|
||||
onChange={this.handleGroupNameChange}
|
||||
value={group.name}
|
||||
validationError={this.state.nameValidationError}
|
||||
helpText={t("groupForm.help.nameHelpText")}
|
||||
/>
|
||||
);
|
||||
} else if (group.external) {
|
||||
subtitle = <Subtitle subtitle={t("groupForm.externalSubtitle")} />;
|
||||
} else {
|
||||
subtitle = <Subtitle subtitle={t("groupForm.subtitle")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{subtitle}
|
||||
<form onSubmit={this.submit}>
|
||||
{nameField}
|
||||
<Textarea
|
||||
label={t("group.description")}
|
||||
errorMessage={t("groupForm.descriptionError")}
|
||||
onChange={this.handleDescriptionChange}
|
||||
value={group.description}
|
||||
validationError={false}
|
||||
helpText={t("groupForm.help.descriptionHelpText")}
|
||||
/>
|
||||
{this.renderExternalField(group)}
|
||||
{this.renderMemberfields(group)}
|
||||
<SubmitButton
|
||||
disabled={!this.isValid()}
|
||||
label={t("groupForm.submit")}
|
||||
loading={loading}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
memberListChanged = membernames => {
|
||||
this.setState({
|
||||
...this.state,
|
||||
group: {
|
||||
...this.state.group,
|
||||
members: membernames
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
addMember = (value: SelectValue) => {
|
||||
if (this.isMember(value.value.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
...this.state,
|
||||
group: {
|
||||
...this.state.group,
|
||||
members: [...this.state.group.members, value.value.id]
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
isMember = (membername: string) => {
|
||||
return this.state.group.members.includes(membername);
|
||||
};
|
||||
|
||||
handleGroupNameChange = (name: string) => {
|
||||
this.setState({
|
||||
nameValidationError: !validator.isNameValid(name),
|
||||
group: { ...this.state.group, name }
|
||||
});
|
||||
};
|
||||
|
||||
handleDescriptionChange = (description: string) => {
|
||||
this.setState({
|
||||
group: { ...this.state.group, description }
|
||||
});
|
||||
};
|
||||
|
||||
handleExternalChange = (external: boolean) => {
|
||||
this.setState({
|
||||
group: { ...this.state.group, external }
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default translate("groups")(GroupForm);
|
||||
10
scm-ui/ui-webapp/src/groups/components/groupValidation.js
Normal file
10
scm-ui/ui-webapp/src/groups/components/groupValidation.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// @flow
|
||||
import { validation } from "@scm-manager/ui-components";
|
||||
|
||||
const isNameValid = validation.isNameValid;
|
||||
|
||||
export { isNameValid };
|
||||
|
||||
export const isMemberNameValid = (name: string) => {
|
||||
return isNameValid(name);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { Group } from "@scm-manager/ui-types";
|
||||
import { NavLink } from "@scm-manager/ui-components";
|
||||
import { translate } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
group: Group,
|
||||
editUrl: string,
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class EditGroupNavLink extends React.Component<Props> {
|
||||
isEditable = () => {
|
||||
return this.props.group._links.update;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, editUrl } = this.props;
|
||||
|
||||
if (!this.isEditable()) {
|
||||
return null;
|
||||
}
|
||||
return <NavLink to={editUrl} label={t("singleGroup.menu.generalNavLink")} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("groups")(EditGroupNavLink);
|
||||
@@ -0,0 +1,29 @@
|
||||
//@flow
|
||||
|
||||
import React from "react";
|
||||
import { shallow } from "enzyme";
|
||||
import "../../../tests/enzyme";
|
||||
import "../../../tests/i18n";
|
||||
import EditGroupNavLink from "./EditGroupNavLink";
|
||||
|
||||
it("should render nothing, if the edit link is missing", () => {
|
||||
const group = {
|
||||
_links: {}
|
||||
};
|
||||
|
||||
const navLink = shallow(<EditGroupNavLink group={group} editUrl='/group/edit'/>);
|
||||
expect(navLink.text()).toBe("");
|
||||
});
|
||||
|
||||
it("should render the navLink", () => {
|
||||
const group = {
|
||||
_links: {
|
||||
update: {
|
||||
href: "/groups"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navLink = shallow(<EditGroupNavLink group={group} editUrl='/group/edit'/>);
|
||||
expect(navLink.text()).not.toBe("");
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import type { Group } from "@scm-manager/ui-types";
|
||||
import { NavLink } from "@scm-manager/ui-components";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
group: Group,
|
||||
permissionsUrl: String
|
||||
};
|
||||
|
||||
class ChangePermissionNavLink extends React.Component<Props> {
|
||||
render() {
|
||||
const { t, permissionsUrl } = this.props;
|
||||
|
||||
if (!this.hasPermissionToSetPermission()) {
|
||||
return null;
|
||||
}
|
||||
return <NavLink to={permissionsUrl} label={t("singleGroup.menu.setPermissionsNavLink")} />;
|
||||
}
|
||||
|
||||
hasPermissionToSetPermission = () => {
|
||||
return this.props.group._links.permissions;
|
||||
};
|
||||
}
|
||||
|
||||
export default translate("groups")(ChangePermissionNavLink);
|
||||
2
scm-ui/ui-webapp/src/groups/components/navLinks/index.js
Normal file
2
scm-ui/ui-webapp/src/groups/components/navLinks/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as EditGroupNavLink } from "./EditGroupNavLink";
|
||||
export { default as SetPermissionsNavLink } from "./SetPermissionsNavLink";
|
||||
87
scm-ui/ui-webapp/src/groups/components/table/Details.js
Normal file
87
scm-ui/ui-webapp/src/groups/components/table/Details.js
Normal file
@@ -0,0 +1,87 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { Group } from "@scm-manager/ui-types";
|
||||
import GroupMember from "./GroupMember";
|
||||
import { DateFromNow, Checkbox } from "@scm-manager/ui-components";
|
||||
import { translate } from "react-i18next";
|
||||
import injectSheet from "react-jss";
|
||||
|
||||
type Props = {
|
||||
group: Group,
|
||||
|
||||
// Context props
|
||||
classes: any,
|
||||
t: string => string
|
||||
};
|
||||
|
||||
const styles = {
|
||||
spacing: {
|
||||
padding: "0 !important"
|
||||
}
|
||||
};
|
||||
|
||||
class Details extends React.Component<Props> {
|
||||
render() {
|
||||
const { group, t } = this.props;
|
||||
return (
|
||||
<table className="table content">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{t("group.name")}</th>
|
||||
<td>{group.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("group.description")}</th>
|
||||
<td>{group.description}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("group.external")}</th>
|
||||
<td>
|
||||
<Checkbox checked={group.external} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("group.type")}</th>
|
||||
<td>{group.type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("group.creationDate")}</th>
|
||||
<td>
|
||||
<DateFromNow date={group.creationDate} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("group.lastModified")}</th>
|
||||
<td>
|
||||
<DateFromNow date={group.lastModified} />
|
||||
</td>
|
||||
</tr>
|
||||
{this.renderMembers()}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
renderMembers() {
|
||||
const { group, t, classes } = this.props;
|
||||
|
||||
let member = null;
|
||||
if (group.members.length > 0) {
|
||||
member = (
|
||||
<tr>
|
||||
<th>{t("group.members")}</th>
|
||||
<td className={classes.spacing}>
|
||||
<ul>
|
||||
{group._embedded.members.map((member, index) => {
|
||||
return <GroupMember key={index} member={member}/>;
|
||||
})}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return member;
|
||||
}
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(translate("groups")(Details));
|
||||
28
scm-ui/ui-webapp/src/groups/components/table/GroupMember.js
Normal file
28
scm-ui/ui-webapp/src/groups/components/table/GroupMember.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Member } from "@scm-manager/ui-types";
|
||||
|
||||
type Props = {
|
||||
member: Member
|
||||
};
|
||||
|
||||
export default class GroupMember extends React.Component<Props> {
|
||||
renderLink(to: string, label: string) {
|
||||
return <Link to={to}>{label}</Link>;
|
||||
}
|
||||
|
||||
showName(to: any, member: Member) {
|
||||
if (member._links.self) {
|
||||
return this.renderLink(to, member.name);
|
||||
} else {
|
||||
return member.name;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { member } = this.props;
|
||||
const to = `/user/${member.name}`;
|
||||
return <li>{this.showName(to, member)}</li>;
|
||||
}
|
||||
}
|
||||
38
scm-ui/ui-webapp/src/groups/components/table/GroupRow.js
Normal file
38
scm-ui/ui-webapp/src/groups/components/table/GroupRow.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Group } from "@scm-manager/ui-types";
|
||||
import { Icon } from "@scm-manager/ui-components";
|
||||
|
||||
type Props = {
|
||||
group: Group,
|
||||
|
||||
// context props
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class GroupRow extends React.Component<Props> {
|
||||
renderLink(to: string, label: string) {
|
||||
return <Link to={to}>{label}</Link>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { group, t } = this.props;
|
||||
const to = `/group/${group.name}`;
|
||||
const iconType = group.external ? (
|
||||
<Icon title={t("group.external")} name="sign-out-alt fa-rotate-270" />
|
||||
) : (
|
||||
<Icon title={t("group.internal")} name="sign-in-alt fa-rotate-90" />
|
||||
);
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>{iconType} {this.renderLink(to, group.name)}</td>
|
||||
<td className="is-hidden-mobile">{group.description}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("groups")(GroupRow);
|
||||
33
scm-ui/ui-webapp/src/groups/components/table/GroupTable.js
Normal file
33
scm-ui/ui-webapp/src/groups/components/table/GroupTable.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import GroupRow from "./GroupRow";
|
||||
import type { Group } from "@scm-manager/ui-types";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
groups: Group[]
|
||||
};
|
||||
|
||||
class GroupTable extends React.Component<Props> {
|
||||
render() {
|
||||
const { groups, t } = this.props;
|
||||
return (
|
||||
<table className="card-table table is-hoverable is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("group.name")}</th>
|
||||
<th className="is-hidden-mobile">{t("group.description")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map((group, index) => {
|
||||
return <GroupRow key={index} group={group} />;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("groups")(GroupTable);
|
||||
3
scm-ui/ui-webapp/src/groups/components/table/index.js
Normal file
3
scm-ui/ui-webapp/src/groups/components/table/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Details } from "./Details";
|
||||
export { default as GroupRow } from "./GroupRow";
|
||||
export { default as GroupTable } from "./GroupTable";
|
||||
107
scm-ui/ui-webapp/src/groups/containers/CreateGroup.js
Normal file
107
scm-ui/ui-webapp/src/groups/containers/CreateGroup.js
Normal file
@@ -0,0 +1,107 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
|
||||
import { Page } from "@scm-manager/ui-components";
|
||||
import { translate } from "react-i18next";
|
||||
import GroupForm from "../components/GroupForm";
|
||||
import { connect } from "react-redux";
|
||||
import {
|
||||
createGroup,
|
||||
isCreateGroupPending,
|
||||
getCreateGroupFailure,
|
||||
createGroupReset
|
||||
} from "../modules/groups";
|
||||
import type { Group } from "@scm-manager/ui-types";
|
||||
import type { History } from "history";
|
||||
import {
|
||||
getGroupsLink,
|
||||
getUserAutoCompleteLink
|
||||
} from "../../modules/indexResource";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
createGroup: (link: string, group: Group, callback?: () => void) => void,
|
||||
history: History,
|
||||
loading?: boolean,
|
||||
error?: Error,
|
||||
resetForm: () => void,
|
||||
createLink: string,
|
||||
autocompleteLink: string
|
||||
};
|
||||
|
||||
type State = {};
|
||||
|
||||
class CreateGroup extends React.Component<Props, State> {
|
||||
componentDidMount() {
|
||||
this.props.resetForm();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { t, loading, error } = this.props;
|
||||
return (
|
||||
<Page
|
||||
title={t("add-group.title")}
|
||||
subtitle={t("add-group.subtitle")}
|
||||
error={error}
|
||||
>
|
||||
<div>
|
||||
<GroupForm
|
||||
submitForm={group => this.createGroup(group)}
|
||||
loading={loading}
|
||||
loadUserSuggestions={this.loadUserAutocompletion}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
loadUserAutocompletion = (inputValue: string) => {
|
||||
const url = this.props.autocompleteLink + "?q=";
|
||||
return fetch(url + inputValue)
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
return json.map(element => {
|
||||
return {
|
||||
value: element,
|
||||
label: `${element.displayName} (${element.id})`
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
groupCreated = (group: Group) => {
|
||||
this.props.history.push("/group/" + group.name);
|
||||
};
|
||||
createGroup = (group: Group) => {
|
||||
this.props.createGroup(this.props.createLink, group, () =>
|
||||
this.groupCreated(group)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
createGroup: (link: string, group: Group, callback?: () => void) =>
|
||||
dispatch(createGroup(link, group, callback)),
|
||||
resetForm: () => {
|
||||
dispatch(createGroupReset());
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const loading = isCreateGroupPending(state);
|
||||
const error = getCreateGroupFailure(state);
|
||||
const createLink = getGroupsLink(state);
|
||||
const autocompleteLink = getUserAutoCompleteLink(state);
|
||||
return {
|
||||
createLink,
|
||||
loading,
|
||||
error,
|
||||
autocompleteLink
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("groups")(CreateGroup));
|
||||
113
scm-ui/ui-webapp/src/groups/containers/DeleteGroup.js
Normal file
113
scm-ui/ui-webapp/src/groups/containers/DeleteGroup.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import type { Group } from "@scm-manager/ui-types";
|
||||
import {
|
||||
Subtitle,
|
||||
DeleteButton,
|
||||
confirmAlert,
|
||||
ErrorNotification
|
||||
} from "@scm-manager/ui-components";
|
||||
import {
|
||||
deleteGroup,
|
||||
getDeleteGroupFailure,
|
||||
isDeleteGroupPending
|
||||
} from "../modules/groups";
|
||||
import { connect } from "react-redux";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import type { History } from "history";
|
||||
|
||||
type Props = {
|
||||
loading: boolean,
|
||||
error: Error,
|
||||
group: Group,
|
||||
confirmDialog?: boolean,
|
||||
deleteGroup: (group: Group, callback?: () => void) => void,
|
||||
|
||||
// context props
|
||||
history: History,
|
||||
t: string => string
|
||||
};
|
||||
|
||||
export class DeleteGroup extends React.Component<Props> {
|
||||
static defaultProps = {
|
||||
confirmDialog: true
|
||||
};
|
||||
|
||||
deleteGroup = () => {
|
||||
this.props.deleteGroup(this.props.group, this.groupDeleted);
|
||||
};
|
||||
|
||||
groupDeleted = () => {
|
||||
this.props.history.push("/groups/");
|
||||
};
|
||||
|
||||
confirmDelete = () => {
|
||||
const { t } = this.props;
|
||||
confirmAlert({
|
||||
title: t("deleteGroup.confirmAlert.title"),
|
||||
message: t("deleteGroup.confirmAlert.message"),
|
||||
buttons: [
|
||||
{
|
||||
label: t("deleteGroup.confirmAlert.submit"),
|
||||
onClick: () => this.deleteGroup()
|
||||
},
|
||||
{
|
||||
label: t("deleteGroup.confirmAlert.cancel"),
|
||||
onClick: () => null
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
isDeletable = () => {
|
||||
return this.props.group._links.delete;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loading, error, confirmDialog, t } = this.props;
|
||||
const action = confirmDialog ? this.confirmDelete : this.deleteGroup;
|
||||
|
||||
if (!this.isDeletable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Subtitle subtitle={t("deleteGroup.subtitle")} />
|
||||
<ErrorNotification error={error} />
|
||||
<div className="columns">
|
||||
<div className="column">
|
||||
<DeleteButton
|
||||
label={t("deleteGroup.button")}
|
||||
action={action}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const loading = isDeleteGroupPending(state, ownProps.group.name);
|
||||
const error = getDeleteGroupFailure(state, ownProps.group.name);
|
||||
return {
|
||||
loading,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
deleteGroup: (group: Group, callback?: () => void) => {
|
||||
dispatch(deleteGroup(group, callback));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(withRouter(translate("groups")(DeleteGroup)));
|
||||
102
scm-ui/ui-webapp/src/groups/containers/EditGroup.js
Normal file
102
scm-ui/ui-webapp/src/groups/containers/EditGroup.js
Normal file
@@ -0,0 +1,102 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import GroupForm from "../components/GroupForm";
|
||||
import {
|
||||
modifyGroup,
|
||||
getModifyGroupFailure,
|
||||
isModifyGroupPending,
|
||||
modifyGroupReset
|
||||
} from "../modules/groups";
|
||||
import type { History } from "history";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import type { Group } from "@scm-manager/ui-types";
|
||||
import { ErrorNotification } from "@scm-manager/ui-components";
|
||||
import { getUserAutoCompleteLink } from "../../modules/indexResource";
|
||||
import DeleteGroup from "./DeleteGroup";
|
||||
|
||||
type Props = {
|
||||
group: Group,
|
||||
fetchGroup: (name: string) => void,
|
||||
modifyGroup: (group: Group, callback?: () => void) => void,
|
||||
modifyGroupReset: Group => void,
|
||||
autocompleteLink: string,
|
||||
history: History,
|
||||
loading?: boolean,
|
||||
error: Error
|
||||
};
|
||||
|
||||
class EditGroup extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
const { group, modifyGroupReset } = this.props;
|
||||
modifyGroupReset(group);
|
||||
}
|
||||
|
||||
groupModified = (group: Group) => () => {
|
||||
this.props.history.push(`/group/${group.name}`);
|
||||
};
|
||||
|
||||
modifyGroup = (group: Group) => {
|
||||
this.props.modifyGroup(group, this.groupModified(group));
|
||||
};
|
||||
|
||||
loadUserAutocompletion = (inputValue: string) => {
|
||||
const url = this.props.autocompleteLink + "?q=";
|
||||
return fetch(url + inputValue)
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
return json.map(element => {
|
||||
return {
|
||||
value: element,
|
||||
label: `${element.displayName} (${element.id})`
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loading, error, group } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<ErrorNotification error={error} />
|
||||
<GroupForm
|
||||
group={group}
|
||||
submitForm={group => {
|
||||
this.modifyGroup(group);
|
||||
}}
|
||||
loading={loading}
|
||||
loadUserSuggestions={this.loadUserAutocompletion}
|
||||
/>
|
||||
<hr />
|
||||
<DeleteGroup group={group} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const loading = isModifyGroupPending(state, ownProps.group.name);
|
||||
const error = getModifyGroupFailure(state, ownProps.group.name);
|
||||
const autocompleteLink = getUserAutoCompleteLink(state);
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
autocompleteLink
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
modifyGroup: (group: Group, callback?: () => void) => {
|
||||
dispatch(modifyGroup(group, callback));
|
||||
},
|
||||
modifyGroupReset: (group: Group) => {
|
||||
dispatch(modifyGroupReset(group));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(withRouter(EditGroup));
|
||||
161
scm-ui/ui-webapp/src/groups/containers/Groups.js
Normal file
161
scm-ui/ui-webapp/src/groups/containers/Groups.js
Normal file
@@ -0,0 +1,161 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { translate } from "react-i18next";
|
||||
import type { History } from "history";
|
||||
import type { Group, PagedCollection } from "@scm-manager/ui-types";
|
||||
import {
|
||||
fetchGroupsByPage,
|
||||
getGroupsFromState,
|
||||
isFetchGroupsPending,
|
||||
getFetchGroupsFailure,
|
||||
isPermittedToCreateGroups,
|
||||
selectListAsCollection
|
||||
} from "../modules/groups";
|
||||
import {
|
||||
Page,
|
||||
PageActions,
|
||||
OverviewPageActions,
|
||||
Notification,
|
||||
LinkPaginator,
|
||||
urls,
|
||||
CreateButton
|
||||
} from "@scm-manager/ui-components";
|
||||
import { GroupTable } from "./../components/table";
|
||||
import { getGroupsLink } from "../../modules/indexResource";
|
||||
|
||||
type Props = {
|
||||
groups: Group[],
|
||||
loading: boolean,
|
||||
error: Error,
|
||||
canAddGroups: boolean,
|
||||
list: PagedCollection,
|
||||
page: number,
|
||||
groupLink: string,
|
||||
|
||||
// context objects
|
||||
t: string => string,
|
||||
history: History,
|
||||
location: any,
|
||||
|
||||
// dispatch functions
|
||||
fetchGroupsByPage: (link: string, page: number, filter?: string) => void
|
||||
};
|
||||
|
||||
class Groups extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
const { fetchGroupsByPage, groupLink, page, location } = this.props;
|
||||
fetchGroupsByPage(
|
||||
groupLink,
|
||||
page,
|
||||
urls.getQueryStringFromLocation(location)
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate = (prevProps: Props) => {
|
||||
const {
|
||||
loading,
|
||||
list,
|
||||
page,
|
||||
groupLink,
|
||||
location,
|
||||
fetchGroupsByPage
|
||||
} = this.props;
|
||||
if (list && page && !loading) {
|
||||
const statePage: number = list.page + 1;
|
||||
if (page !== statePage || prevProps.location.search !== location.search) {
|
||||
fetchGroupsByPage(
|
||||
groupLink,
|
||||
page,
|
||||
urls.getQueryStringFromLocation(location)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { groups, loading, error, canAddGroups, t } = this.props;
|
||||
return (
|
||||
<Page
|
||||
title={t("groups.title")}
|
||||
subtitle={t("groups.subtitle")}
|
||||
loading={loading || !groups}
|
||||
error={error}
|
||||
>
|
||||
{this.renderGroupTable()}
|
||||
{this.renderCreateButton()}
|
||||
<PageActions>
|
||||
<OverviewPageActions
|
||||
showCreateButton={canAddGroups}
|
||||
link="groups"
|
||||
label={t("create-group-button.label")}
|
||||
/>
|
||||
</PageActions>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
renderGroupTable() {
|
||||
const { groups, list, page, location, t } = this.props;
|
||||
if (groups && groups.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<GroupTable groups={groups} />
|
||||
<LinkPaginator
|
||||
collection={list}
|
||||
page={page}
|
||||
filter={urls.getQueryStringFromLocation(location)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <Notification type="info">{t("groups.noGroups")}</Notification>;
|
||||
}
|
||||
|
||||
renderCreateButton() {
|
||||
const { canAddGroups, t } = this.props;
|
||||
if (canAddGroups) {
|
||||
return (
|
||||
<CreateButton
|
||||
label={t("create-group-button.label")}
|
||||
link="/groups/create"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const { match } = ownProps;
|
||||
const groups = getGroupsFromState(state);
|
||||
const loading = isFetchGroupsPending(state);
|
||||
const error = getFetchGroupsFailure(state);
|
||||
const page = urls.getPageFromMatch(match);
|
||||
const canAddGroups = isPermittedToCreateGroups(state);
|
||||
const list = selectListAsCollection(state);
|
||||
const groupLink = getGroupsLink(state);
|
||||
|
||||
return {
|
||||
groups,
|
||||
loading,
|
||||
error,
|
||||
canAddGroups,
|
||||
list,
|
||||
page,
|
||||
groupLink
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
fetchGroupsByPage: (link: string, page: number, filter?: string) => {
|
||||
dispatch(fetchGroupsByPage(link, page, filter));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("groups")(Groups));
|
||||
186
scm-ui/ui-webapp/src/groups/containers/SingleGroup.js
Normal file
186
scm-ui/ui-webapp/src/groups/containers/SingleGroup.js
Normal file
@@ -0,0 +1,186 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import {
|
||||
Page,
|
||||
ErrorPage,
|
||||
Loading,
|
||||
Navigation,
|
||||
SubNavigation,
|
||||
Section,
|
||||
NavLink
|
||||
} from "@scm-manager/ui-components";
|
||||
import { Route } from "react-router";
|
||||
import { Details } from "./../components/table";
|
||||
import {
|
||||
EditGroupNavLink,
|
||||
SetPermissionsNavLink
|
||||
} from "./../components/navLinks";
|
||||
import type { Group } from "@scm-manager/ui-types";
|
||||
import type { History } from "history";
|
||||
import {
|
||||
fetchGroupByName,
|
||||
getGroupByName,
|
||||
isFetchGroupPending,
|
||||
getFetchGroupFailure
|
||||
} from "../modules/groups";
|
||||
|
||||
import { translate } from "react-i18next";
|
||||
import EditGroup from "./EditGroup";
|
||||
import { getGroupsLink } from "../../modules/indexResource";
|
||||
import SetPermissions from "../../permissions/components/SetPermissions";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
|
||||
type Props = {
|
||||
name: string,
|
||||
group: Group,
|
||||
loading: boolean,
|
||||
error: Error,
|
||||
groupLink: string,
|
||||
|
||||
// dispatcher functions
|
||||
fetchGroupByName: (string, string) => void,
|
||||
|
||||
// context objects
|
||||
t: string => string,
|
||||
match: any,
|
||||
history: History
|
||||
};
|
||||
|
||||
class SingleGroup extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.fetchGroupByName(this.props.groupLink, this.props.name);
|
||||
}
|
||||
|
||||
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, group } = this.props;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title={t("singleGroup.errorTitle")}
|
||||
subtitle={t("singleGroup.errorSubtitle")}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!group || loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const url = this.matchedUrl();
|
||||
|
||||
const extensionProps = {
|
||||
group,
|
||||
url
|
||||
};
|
||||
|
||||
return (
|
||||
<Page title={group.name}>
|
||||
<div className="columns">
|
||||
<div className="column is-three-quarters">
|
||||
<Route
|
||||
path={url}
|
||||
exact
|
||||
component={() => <Details group={group} />}
|
||||
/>
|
||||
<Route
|
||||
path={`${url}/settings/general`}
|
||||
exact
|
||||
component={() => <EditGroup group={group} />}
|
||||
/>
|
||||
<Route
|
||||
path={`${url}/settings/permissions`}
|
||||
exact
|
||||
component={() => (
|
||||
<SetPermissions
|
||||
selectedPermissionsLink={group._links.permissions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ExtensionPoint
|
||||
name="group.route"
|
||||
props={extensionProps}
|
||||
renderAll={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="column">
|
||||
<Navigation>
|
||||
<Section label={t("singleGroup.menu.navigationLabel")}>
|
||||
<NavLink
|
||||
to={`${url}`}
|
||||
icon="fas fa-info-circle"
|
||||
label={t("singleGroup.menu.informationNavLink")}
|
||||
/>
|
||||
<ExtensionPoint
|
||||
name="group.navigation"
|
||||
props={extensionProps}
|
||||
renderAll={true}
|
||||
/>
|
||||
<SubNavigation
|
||||
to={`${url}/settings/general`}
|
||||
label={t("singleGroup.menu.settingsNavLink")}
|
||||
>
|
||||
<EditGroupNavLink
|
||||
group={group}
|
||||
editUrl={`${url}/settings/general`}
|
||||
/>
|
||||
<SetPermissionsNavLink
|
||||
group={group}
|
||||
permissionsUrl={`${url}/settings/permissions`}
|
||||
/>
|
||||
<ExtensionPoint
|
||||
name="group.setting"
|
||||
props={extensionProps}
|
||||
renderAll={true}
|
||||
/>
|
||||
</SubNavigation>
|
||||
</Section>
|
||||
</Navigation>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const name = ownProps.match.params.name;
|
||||
const group = getGroupByName(state, name);
|
||||
const loading = isFetchGroupPending(state, name);
|
||||
const error = getFetchGroupFailure(state, name);
|
||||
const groupLink = getGroupsLink(state);
|
||||
|
||||
return {
|
||||
name,
|
||||
group,
|
||||
loading,
|
||||
error,
|
||||
groupLink
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
fetchGroupByName: (link: string, name: string) => {
|
||||
dispatch(fetchGroupByName(link, name));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("groups")(SingleGroup));
|
||||
484
scm-ui/ui-webapp/src/groups/modules/groups.js
Normal file
484
scm-ui/ui-webapp/src/groups/modules/groups.js
Normal file
@@ -0,0 +1,484 @@
|
||||
// @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, Group } from "@scm-manager/ui-types";
|
||||
|
||||
export const FETCH_GROUPS = "scm/groups/FETCH_GROUPS";
|
||||
export const FETCH_GROUPS_PENDING = `${FETCH_GROUPS}_${types.PENDING_SUFFIX}`;
|
||||
export const FETCH_GROUPS_SUCCESS = `${FETCH_GROUPS}_${types.SUCCESS_SUFFIX}`;
|
||||
export const FETCH_GROUPS_FAILURE = `${FETCH_GROUPS}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
export const FETCH_GROUP = "scm/groups/FETCH_GROUP";
|
||||
export const FETCH_GROUP_PENDING = `${FETCH_GROUP}_${types.PENDING_SUFFIX}`;
|
||||
export const FETCH_GROUP_SUCCESS = `${FETCH_GROUP}_${types.SUCCESS_SUFFIX}`;
|
||||
export const FETCH_GROUP_FAILURE = `${FETCH_GROUP}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
export const CREATE_GROUP = "scm/groups/CREATE_GROUP";
|
||||
export const CREATE_GROUP_PENDING = `${CREATE_GROUP}_${types.PENDING_SUFFIX}`;
|
||||
export const CREATE_GROUP_SUCCESS = `${CREATE_GROUP}_${types.SUCCESS_SUFFIX}`;
|
||||
export const CREATE_GROUP_FAILURE = `${CREATE_GROUP}_${types.FAILURE_SUFFIX}`;
|
||||
export const CREATE_GROUP_RESET = `${CREATE_GROUP}_${types.RESET_SUFFIX}`;
|
||||
|
||||
export const MODIFY_GROUP = "scm/groups/MODIFY_GROUP";
|
||||
export const MODIFY_GROUP_PENDING = `${MODIFY_GROUP}_${types.PENDING_SUFFIX}`;
|
||||
export const MODIFY_GROUP_SUCCESS = `${MODIFY_GROUP}_${types.SUCCESS_SUFFIX}`;
|
||||
export const MODIFY_GROUP_FAILURE = `${MODIFY_GROUP}_${types.FAILURE_SUFFIX}`;
|
||||
export const MODIFY_GROUP_RESET = `${MODIFY_GROUP}_${types.RESET_SUFFIX}`;
|
||||
|
||||
export const DELETE_GROUP = "scm/groups/DELETE_GROUP";
|
||||
export const DELETE_GROUP_PENDING = `${DELETE_GROUP}_${types.PENDING_SUFFIX}`;
|
||||
export const DELETE_GROUP_SUCCESS = `${DELETE_GROUP}_${types.SUCCESS_SUFFIX}`;
|
||||
export const DELETE_GROUP_FAILURE = `${DELETE_GROUP}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
const CONTENT_TYPE_GROUP = "application/vnd.scmm-group+json;v=2";
|
||||
|
||||
// fetch groups
|
||||
export function fetchGroups(link: string) {
|
||||
return fetchGroupsByLink(link);
|
||||
}
|
||||
|
||||
export function fetchGroupsByPage(link: string, page: number, filter?: string) {
|
||||
// backend start counting by 0
|
||||
if (filter) {
|
||||
return fetchGroupsByLink(
|
||||
`${link}?page=${page - 1}&q=${decodeURIComponent(filter)}`
|
||||
);
|
||||
}
|
||||
return fetchGroupsByLink(`${link}?page=${page - 1}`);
|
||||
}
|
||||
|
||||
export function fetchGroupsByLink(link: string) {
|
||||
return function(dispatch: any) {
|
||||
dispatch(fetchGroupsPending());
|
||||
return apiClient
|
||||
.get(link)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
dispatch(fetchGroupsSuccess(data));
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(fetchGroupsFailure(link, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchGroupsPending(): Action {
|
||||
return {
|
||||
type: FETCH_GROUPS_PENDING
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchGroupsSuccess(groups: any): Action {
|
||||
return {
|
||||
type: FETCH_GROUPS_SUCCESS,
|
||||
payload: groups
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchGroupsFailure(url: string, error: Error): Action {
|
||||
return {
|
||||
type: FETCH_GROUPS_FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
url
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
//fetch group
|
||||
export function fetchGroupByLink(group: Group) {
|
||||
return fetchGroup(group._links.self.href, group.name);
|
||||
}
|
||||
|
||||
export function fetchGroupByName(link: string, name: string) {
|
||||
const groupUrl = link.endsWith("/") ? link + name : link + "/" + name;
|
||||
return fetchGroup(groupUrl, name);
|
||||
}
|
||||
|
||||
function fetchGroup(link: string, name: string) {
|
||||
return function(dispatch: any) {
|
||||
dispatch(fetchGroupPending(name));
|
||||
return apiClient
|
||||
.get(link)
|
||||
.then(response => {
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
dispatch(fetchGroupSuccess(data));
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(fetchGroupFailure(name, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchGroupPending(name: string): Action {
|
||||
return {
|
||||
type: FETCH_GROUP_PENDING,
|
||||
payload: name,
|
||||
itemId: name
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchGroupSuccess(group: any): Action {
|
||||
return {
|
||||
type: FETCH_GROUP_SUCCESS,
|
||||
payload: group,
|
||||
itemId: group.name
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchGroupFailure(name: string, error: Error): Action {
|
||||
return {
|
||||
type: FETCH_GROUP_FAILURE,
|
||||
payload: {
|
||||
name,
|
||||
error
|
||||
},
|
||||
itemId: name
|
||||
};
|
||||
}
|
||||
|
||||
//create group
|
||||
export function createGroup(link: string, group: Group, callback?: () => void) {
|
||||
return function(dispatch: Dispatch) {
|
||||
dispatch(createGroupPending());
|
||||
return apiClient
|
||||
.post(link, group, CONTENT_TYPE_GROUP)
|
||||
.then(() => {
|
||||
dispatch(createGroupSuccess());
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(createGroupFailure(error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createGroupPending() {
|
||||
return {
|
||||
type: CREATE_GROUP_PENDING
|
||||
};
|
||||
}
|
||||
|
||||
export function createGroupSuccess() {
|
||||
return {
|
||||
type: CREATE_GROUP_SUCCESS
|
||||
};
|
||||
}
|
||||
|
||||
export function createGroupFailure(error: Error) {
|
||||
return {
|
||||
type: CREATE_GROUP_FAILURE,
|
||||
payload: error
|
||||
};
|
||||
}
|
||||
|
||||
export function createGroupReset() {
|
||||
return {
|
||||
type: CREATE_GROUP_RESET
|
||||
};
|
||||
}
|
||||
|
||||
// modify group
|
||||
export function modifyGroup(group: Group, callback?: () => void) {
|
||||
return function(dispatch: Dispatch) {
|
||||
dispatch(modifyGroupPending(group));
|
||||
return apiClient
|
||||
.put(group._links.update.href, group, CONTENT_TYPE_GROUP)
|
||||
.then(() => {
|
||||
dispatch(modifyGroupSuccess(group));
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
dispatch(fetchGroupByLink(group));
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(modifyGroupFailure(group, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function modifyGroupPending(group: Group): Action {
|
||||
return {
|
||||
type: MODIFY_GROUP_PENDING,
|
||||
payload: group,
|
||||
itemId: group.name
|
||||
};
|
||||
}
|
||||
|
||||
export function modifyGroupSuccess(group: Group): Action {
|
||||
return {
|
||||
type: MODIFY_GROUP_SUCCESS,
|
||||
payload: group,
|
||||
itemId: group.name
|
||||
};
|
||||
}
|
||||
|
||||
export function modifyGroupFailure(group: Group, error: Error): Action {
|
||||
return {
|
||||
type: MODIFY_GROUP_FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
group
|
||||
},
|
||||
itemId: group.name
|
||||
};
|
||||
}
|
||||
|
||||
export function modifyGroupReset(group: Group): Action {
|
||||
return {
|
||||
type: MODIFY_GROUP_RESET,
|
||||
itemId: group.name
|
||||
};
|
||||
}
|
||||
|
||||
//delete group
|
||||
|
||||
export function deleteGroup(group: Group, callback?: () => void) {
|
||||
return function(dispatch: any) {
|
||||
dispatch(deleteGroupPending(group));
|
||||
return apiClient
|
||||
.delete(group._links.delete.href)
|
||||
.then(() => {
|
||||
dispatch(deleteGroupSuccess(group));
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(deleteGroupFailure(group, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteGroupPending(group: Group): Action {
|
||||
return {
|
||||
type: DELETE_GROUP_PENDING,
|
||||
payload: group,
|
||||
itemId: group.name
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteGroupSuccess(group: Group): Action {
|
||||
return {
|
||||
type: DELETE_GROUP_SUCCESS,
|
||||
payload: group,
|
||||
itemId: group.name
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteGroupFailure(group: Group, error: Error): Action {
|
||||
return {
|
||||
type: DELETE_GROUP_FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
group
|
||||
},
|
||||
itemId: group.name
|
||||
};
|
||||
}
|
||||
|
||||
//reducer
|
||||
function extractGroupsByNames(
|
||||
groups: Group[],
|
||||
groupNames: string[],
|
||||
oldGroupsByNames: Object
|
||||
) {
|
||||
const groupsByNames = {};
|
||||
|
||||
for (let group of groups) {
|
||||
groupsByNames[group.name] = group;
|
||||
}
|
||||
|
||||
for (let groupName in oldGroupsByNames) {
|
||||
groupsByNames[groupName] = oldGroupsByNames[groupName];
|
||||
}
|
||||
return groupsByNames;
|
||||
}
|
||||
|
||||
function deleteGroupInGroupsByNames(groups: {}, groupName: string) {
|
||||
let newGroups = {};
|
||||
for (let groupname in groups) {
|
||||
if (groupname !== groupName) newGroups[groupname] = groups[groupname];
|
||||
}
|
||||
return newGroups;
|
||||
}
|
||||
|
||||
function deleteGroupInEntries(groups: [], groupName: string) {
|
||||
let newGroups = [];
|
||||
for (let group of groups) {
|
||||
if (group !== groupName) newGroups.push(group);
|
||||
}
|
||||
return newGroups;
|
||||
}
|
||||
|
||||
const reducerByName = (state: any, groupname: string, newGroupState: any) => {
|
||||
const newGroupsByNames = {
|
||||
...state,
|
||||
[groupname]: newGroupState
|
||||
};
|
||||
|
||||
return newGroupsByNames;
|
||||
};
|
||||
|
||||
function listReducer(state: any = {}, action: any = {}) {
|
||||
switch (action.type) {
|
||||
case FETCH_GROUPS_SUCCESS:
|
||||
const groups = action.payload._embedded.groups;
|
||||
const groupNames = groups.map(group => group.name);
|
||||
return {
|
||||
...state,
|
||||
entries: groupNames,
|
||||
entry: {
|
||||
groupCreatePermission: !!action.payload._links.create,
|
||||
page: action.payload.page,
|
||||
pageTotal: action.payload.pageTotal,
|
||||
_links: action.payload._links
|
||||
}
|
||||
};
|
||||
// Delete single group actions
|
||||
case DELETE_GROUP_SUCCESS:
|
||||
const newGroupEntries = deleteGroupInEntries(
|
||||
state.entries,
|
||||
action.payload.name
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
entries: newGroupEntries
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function byNamesReducer(state: any = {}, action: any = {}) {
|
||||
switch (action.type) {
|
||||
// Fetch all groups actions
|
||||
case FETCH_GROUPS_SUCCESS:
|
||||
const groups = action.payload._embedded.groups;
|
||||
const groupNames = groups.map(group => group.name);
|
||||
const byNames = extractGroupsByNames(groups, groupNames, state.byNames);
|
||||
return {
|
||||
...byNames
|
||||
};
|
||||
case FETCH_GROUP_SUCCESS:
|
||||
return reducerByName(state, action.payload.name, action.payload);
|
||||
case DELETE_GROUP_SUCCESS:
|
||||
const newGroupByNames = deleteGroupInGroupsByNames(
|
||||
state,
|
||||
action.payload.name
|
||||
);
|
||||
return newGroupByNames;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
list: listReducer,
|
||||
byNames: byNamesReducer
|
||||
});
|
||||
|
||||
// selectors
|
||||
|
||||
const selectList = (state: Object) => {
|
||||
if (state.groups && state.groups.list) {
|
||||
return state.groups.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 isPermittedToCreateGroups = (state: Object): boolean => {
|
||||
const permission = selectListEntry(state).groupCreatePermission;
|
||||
if (permission) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export function getCreateGroupLink(state: Object) {
|
||||
if (state.groups.list.entry && state.groups.list.entry._links)
|
||||
return state.groups.list.entry._links.create.href;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getGroupsFromState(state: Object) {
|
||||
const groupNames = selectList(state).entries;
|
||||
if (!groupNames) {
|
||||
return null;
|
||||
}
|
||||
const groupEntries: Group[] = [];
|
||||
|
||||
for (let groupName of groupNames) {
|
||||
groupEntries.push(state.groups.byNames[groupName]);
|
||||
}
|
||||
|
||||
return groupEntries;
|
||||
}
|
||||
|
||||
export function isFetchGroupsPending(state: Object) {
|
||||
return isPending(state, FETCH_GROUPS);
|
||||
}
|
||||
|
||||
export function getFetchGroupsFailure(state: Object) {
|
||||
return getFailure(state, FETCH_GROUPS);
|
||||
}
|
||||
|
||||
export function isCreateGroupPending(state: Object) {
|
||||
return isPending(state, CREATE_GROUP);
|
||||
}
|
||||
|
||||
export function getCreateGroupFailure(state: Object) {
|
||||
return getFailure(state, CREATE_GROUP);
|
||||
}
|
||||
|
||||
export function isModifyGroupPending(state: Object, name: string) {
|
||||
return isPending(state, MODIFY_GROUP, name);
|
||||
}
|
||||
|
||||
export function getModifyGroupFailure(state: Object, name: string) {
|
||||
return getFailure(state, MODIFY_GROUP, name);
|
||||
}
|
||||
|
||||
export function getGroupByName(state: Object, name: string) {
|
||||
if (state.groups && state.groups.byNames) {
|
||||
return state.groups.byNames[name];
|
||||
}
|
||||
}
|
||||
|
||||
export function isFetchGroupPending(state: Object, name: string) {
|
||||
return isPending(state, FETCH_GROUP, name);
|
||||
}
|
||||
|
||||
export function getFetchGroupFailure(state: Object, name: string) {
|
||||
return getFailure(state, FETCH_GROUP, name);
|
||||
}
|
||||
|
||||
export function isDeleteGroupPending(state: Object, name: string) {
|
||||
return isPending(state, DELETE_GROUP, name);
|
||||
}
|
||||
|
||||
export function getDeleteGroupFailure(state: Object, name: string) {
|
||||
return getFailure(state, DELETE_GROUP, name);
|
||||
}
|
||||
681
scm-ui/ui-webapp/src/groups/modules/groups.test.js
Normal file
681
scm-ui/ui-webapp/src/groups/modules/groups.test.js
Normal file
@@ -0,0 +1,681 @@
|
||||
//@flow
|
||||
import configureMockStore from "redux-mock-store";
|
||||
import thunk from "redux-thunk";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import reducer, {
|
||||
fetchGroups,
|
||||
FETCH_GROUPS,
|
||||
FETCH_GROUPS_PENDING,
|
||||
FETCH_GROUPS_SUCCESS,
|
||||
FETCH_GROUPS_FAILURE,
|
||||
fetchGroupsSuccess,
|
||||
isPermittedToCreateGroups,
|
||||
getGroupsFromState,
|
||||
getFetchGroupsFailure,
|
||||
isFetchGroupsPending,
|
||||
selectListAsCollection,
|
||||
fetchGroupByLink,
|
||||
fetchGroupByName,
|
||||
FETCH_GROUP_PENDING,
|
||||
FETCH_GROUP_SUCCESS,
|
||||
FETCH_GROUP_FAILURE,
|
||||
fetchGroupSuccess,
|
||||
getFetchGroupFailure,
|
||||
FETCH_GROUP,
|
||||
isFetchGroupPending,
|
||||
getGroupByName,
|
||||
createGroup,
|
||||
CREATE_GROUP_SUCCESS,
|
||||
CREATE_GROUP_PENDING,
|
||||
CREATE_GROUP_FAILURE,
|
||||
isCreateGroupPending,
|
||||
CREATE_GROUP,
|
||||
getCreateGroupFailure,
|
||||
deleteGroup,
|
||||
DELETE_GROUP_PENDING,
|
||||
DELETE_GROUP_SUCCESS,
|
||||
DELETE_GROUP_FAILURE,
|
||||
DELETE_GROUP,
|
||||
deleteGroupSuccess,
|
||||
isDeleteGroupPending,
|
||||
getDeleteGroupFailure,
|
||||
modifyGroup,
|
||||
MODIFY_GROUP_PENDING,
|
||||
MODIFY_GROUP_SUCCESS,
|
||||
MODIFY_GROUP_FAILURE,
|
||||
getCreateGroupLink
|
||||
} from "./groups";
|
||||
const GROUPS_URL = "/api/v2/groups";
|
||||
const URL_HUMAN_GROUP = "http://localhost:8081/api/v2/groups/humanGroup";
|
||||
const URL = "/groups";
|
||||
|
||||
const error = new Error("You have an error!");
|
||||
|
||||
const humanGroup = {
|
||||
creationDate: "2018-07-31T08:39:07.860Z",
|
||||
description: "This is a group",
|
||||
name: "humanGroup",
|
||||
type: "xml",
|
||||
properties: {},
|
||||
members: ["userZaphod"],
|
||||
_links: {
|
||||
self: {
|
||||
href: URL_HUMAN_GROUP
|
||||
},
|
||||
delete: {
|
||||
href: URL_HUMAN_GROUP
|
||||
},
|
||||
update: {
|
||||
href: URL_HUMAN_GROUP
|
||||
}
|
||||
},
|
||||
_embedded: {
|
||||
members: [
|
||||
{
|
||||
name: "userZaphod",
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/api/v2/users/userZaphod"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const emptyGroup = {
|
||||
creationDate: "2018-07-31T08:39:07.860Z",
|
||||
description: "This is a group",
|
||||
name: "emptyGroup",
|
||||
type: "xml",
|
||||
properties: {},
|
||||
members: [],
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/api/v2/groups/emptyGroup"
|
||||
},
|
||||
delete: {
|
||||
href: "http://localhost:8081/api/v2/groups/emptyGroup"
|
||||
},
|
||||
update: {
|
||||
href: "http://localhost:8081/api/v2/groups/emptyGroup"
|
||||
}
|
||||
},
|
||||
_embedded: {
|
||||
members: []
|
||||
}
|
||||
};
|
||||
|
||||
const responseBody = {
|
||||
page: 0,
|
||||
pageTotal: 1,
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:3000/api/v2/groups/?page=0&pageSize=10"
|
||||
},
|
||||
first: {
|
||||
href: "http://localhost:3000/api/v2/groups/?page=0&pageSize=10"
|
||||
},
|
||||
last: {
|
||||
href: "http://localhost:3000/api/v2/groups/?page=0&pageSize=10"
|
||||
},
|
||||
create: {
|
||||
href: "http://localhost:3000/api/v2/groups/"
|
||||
}
|
||||
},
|
||||
_embedded: {
|
||||
groups: [humanGroup, emptyGroup]
|
||||
}
|
||||
};
|
||||
|
||||
const response = {
|
||||
headers: { "content-type": "application/json" },
|
||||
responseBody
|
||||
};
|
||||
|
||||
describe("groups fetch()", () => {
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should successfully fetch groups", () => {
|
||||
fetchMock.getOnce(GROUPS_URL, response);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_GROUPS_PENDING },
|
||||
{
|
||||
type: FETCH_GROUPS_SUCCESS,
|
||||
payload: response
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(fetchGroups(URL)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail getting groups on HTTP 500", () => {
|
||||
fetchMock.getOnce(GROUPS_URL, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchGroups(URL)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_GROUPS_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_GROUPS_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should sucessfully fetch single group by name", () => {
|
||||
fetchMock.getOnce(GROUPS_URL + "/humanGroup", humanGroup);
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchGroupByName(URL, "humanGroup")).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_GROUP_SUCCESS);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail fetching single group by name on HTTP 500", () => {
|
||||
fetchMock.getOnce(GROUPS_URL + "/humanGroup", {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchGroupByName(URL, "humanGroup")).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_GROUP_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should sucessfully fetch single group", () => {
|
||||
fetchMock.getOnce(URL_HUMAN_GROUP, humanGroup);
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchGroupByLink(humanGroup)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_GROUP_SUCCESS);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail fetching single group on HTTP 500", () => {
|
||||
fetchMock.getOnce(URL_HUMAN_GROUP, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchGroupByLink(humanGroup)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_GROUP_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully create group", () => {
|
||||
fetchMock.postOnce(GROUPS_URL, {
|
||||
status: 201
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(createGroup(URL, humanGroup)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should call the callback after creating group", () => {
|
||||
fetchMock.postOnce(GROUPS_URL, {
|
||||
status: 201
|
||||
});
|
||||
let called = false;
|
||||
|
||||
const callMe = () => {
|
||||
called = true;
|
||||
};
|
||||
const store = mockStore({});
|
||||
return store.dispatch(createGroup(URL, humanGroup, callMe)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS);
|
||||
expect(called).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail creating group on HTTP 500", () => {
|
||||
fetchMock.postOnce(GROUPS_URL, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(createGroup(URL, humanGroup)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(CREATE_GROUP_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
expect(actions[1].payload instanceof Error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully modify group", () => {
|
||||
fetchMock.putOnce(URL_HUMAN_GROUP, {
|
||||
status: 204
|
||||
});
|
||||
fetchMock.getOnce(URL_HUMAN_GROUP, humanGroup);
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(modifyGroup(humanGroup)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(MODIFY_GROUP_SUCCESS);
|
||||
expect(actions[2].type).toEqual(FETCH_GROUP_PENDING);
|
||||
expect(actions[1].payload).toEqual(humanGroup);
|
||||
});
|
||||
});
|
||||
|
||||
it("should call the callback after modifying group", () => {
|
||||
fetchMock.putOnce(URL_HUMAN_GROUP, {
|
||||
status: 204
|
||||
});
|
||||
fetchMock.getOnce(URL_HUMAN_GROUP, humanGroup);
|
||||
|
||||
let called = false;
|
||||
const callback = () => {
|
||||
called = true;
|
||||
};
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(modifyGroup(humanGroup, callback)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(MODIFY_GROUP_SUCCESS);
|
||||
expect(actions[2].type).toEqual(FETCH_GROUP_PENDING);
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail modifying group on HTTP 500", () => {
|
||||
fetchMock.putOnce(URL_HUMAN_GROUP, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(modifyGroup(humanGroup)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(MODIFY_GROUP_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete successfully group humanGroup", () => {
|
||||
fetchMock.deleteOnce(URL_HUMAN_GROUP, {
|
||||
status: 204
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(deleteGroup(humanGroup)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions.length).toBe(2);
|
||||
expect(actions[0].type).toEqual(DELETE_GROUP_PENDING);
|
||||
expect(actions[0].payload).toBe(humanGroup);
|
||||
expect(actions[1].type).toEqual(DELETE_GROUP_SUCCESS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should call the callback, after successful delete", () => {
|
||||
fetchMock.deleteOnce(URL_HUMAN_GROUP, {
|
||||
status: 204
|
||||
});
|
||||
|
||||
let called = false;
|
||||
const callMe = () => {
|
||||
called = true;
|
||||
};
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(deleteGroup(humanGroup, callMe)).then(() => {
|
||||
expect(called).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail to delete group humanGroup", () => {
|
||||
fetchMock.deleteOnce(URL_HUMAN_GROUP, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(deleteGroup(humanGroup)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(DELETE_GROUP_PENDING);
|
||||
expect(actions[0].payload).toBe(humanGroup);
|
||||
expect(actions[1].type).toEqual(DELETE_GROUP_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("groups reducer", () => {
|
||||
it("should update state correctly according to FETCH_GROUPS_SUCCESS action", () => {
|
||||
const newState = reducer({}, fetchGroupsSuccess(responseBody));
|
||||
|
||||
expect(newState.list).toEqual({
|
||||
entries: ["humanGroup", "emptyGroup"],
|
||||
entry: {
|
||||
groupCreatePermission: true,
|
||||
page: 0,
|
||||
pageTotal: 1,
|
||||
_links: responseBody._links
|
||||
}
|
||||
});
|
||||
|
||||
expect(newState.byNames).toEqual({
|
||||
humanGroup: humanGroup,
|
||||
emptyGroup: emptyGroup
|
||||
});
|
||||
|
||||
expect(newState.list.entry.groupCreatePermission).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should set groupCreatePermission to true if update link is present", () => {
|
||||
const newState = reducer({}, fetchGroupsSuccess(responseBody));
|
||||
|
||||
expect(newState.list.entry.groupCreatePermission).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not replace whole byNames map when fetching groups", () => {
|
||||
const oldState = {
|
||||
byNames: {
|
||||
emptyGroup: emptyGroup
|
||||
}
|
||||
};
|
||||
|
||||
const newState = reducer(oldState, fetchGroupsSuccess(responseBody));
|
||||
expect(newState.byNames["humanGroup"]).toBeDefined();
|
||||
expect(newState.byNames["emptyGroup"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should set groupCreatePermission to true if create link is present", () => {
|
||||
const newState = reducer({}, fetchGroupsSuccess(responseBody));
|
||||
|
||||
expect(newState.list.entry.groupCreatePermission).toBeTruthy();
|
||||
expect(newState.list.entries).toEqual(["humanGroup", "emptyGroup"]);
|
||||
expect(newState.byNames["emptyGroup"]).toBeTruthy();
|
||||
expect(newState.byNames["humanGroup"]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should update state according to FETCH_GROUP_SUCCESS action", () => {
|
||||
const newState = reducer({}, fetchGroupSuccess(emptyGroup));
|
||||
expect(newState.byNames["emptyGroup"]).toBe(emptyGroup);
|
||||
});
|
||||
|
||||
it("should affect groups state nor the state of other groups", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
list: {
|
||||
entries: ["humanGroup"]
|
||||
}
|
||||
},
|
||||
fetchGroupSuccess(emptyGroup)
|
||||
);
|
||||
expect(newState.byNames["emptyGroup"]).toBe(emptyGroup);
|
||||
expect(newState.list.entries).toEqual(["humanGroup"]);
|
||||
});
|
||||
|
||||
it("should remove group from state when delete succeeds", () => {
|
||||
const state = {
|
||||
list: {
|
||||
entries: ["humanGroup", "emptyGroup"]
|
||||
},
|
||||
byNames: {
|
||||
humanGroup: humanGroup,
|
||||
emptyGroup: emptyGroup
|
||||
}
|
||||
};
|
||||
|
||||
const newState = reducer(state, deleteGroupSuccess(emptyGroup));
|
||||
expect(newState.byNames["humanGroup"]).toBeDefined();
|
||||
expect(newState.byNames["emptyGroup"]).toBeFalsy();
|
||||
expect(newState.list.entries).toEqual(["humanGroup"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selector tests", () => {
|
||||
it("should return an empty object", () => {
|
||||
expect(selectListAsCollection({})).toEqual({});
|
||||
expect(selectListAsCollection({ groups: { a: "a" } })).toEqual({});
|
||||
});
|
||||
|
||||
it("should return a state slice collection", () => {
|
||||
const collection = {
|
||||
page: 3,
|
||||
totalPages: 42
|
||||
};
|
||||
|
||||
const state = {
|
||||
groups: {
|
||||
list: {
|
||||
entry: collection
|
||||
}
|
||||
}
|
||||
};
|
||||
expect(selectListAsCollection(state)).toBe(collection);
|
||||
});
|
||||
|
||||
it("should return false when groupCreatePermission is false", () => {
|
||||
expect(isPermittedToCreateGroups({})).toBe(false);
|
||||
expect(isPermittedToCreateGroups({ groups: { list: { entry: {} } } })).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
isPermittedToCreateGroups({
|
||||
groups: { list: { entry: { groupCreatePermission: false } } }
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when groupCreatePermission is true", () => {
|
||||
const state = {
|
||||
groups: {
|
||||
list: {
|
||||
entry: {
|
||||
groupCreatePermission: true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
expect(isPermittedToCreateGroups(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return create Group link", () => {
|
||||
const state = {
|
||||
groups: {
|
||||
list: {
|
||||
entry: {
|
||||
_links: {
|
||||
create: {
|
||||
href: "/create"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
expect(getCreateGroupLink(state)).toBe("/create");
|
||||
});
|
||||
|
||||
it("should get groups from state", () => {
|
||||
const state = {
|
||||
groups: {
|
||||
list: {
|
||||
entries: ["a", "b"]
|
||||
},
|
||||
byNames: {
|
||||
a: { name: "a" },
|
||||
b: { name: "b" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expect(getGroupsFromState(state)).toEqual([{ name: "a" }, { name: "b" }]);
|
||||
});
|
||||
|
||||
it("should return null when there are no groups in the state", () => {
|
||||
expect(getGroupsFromState({})).toBe(null);
|
||||
});
|
||||
|
||||
it("should return true, when fetch groups is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[FETCH_GROUPS]: true
|
||||
}
|
||||
};
|
||||
expect(isFetchGroupsPending(state)).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when fetch groups is not pending", () => {
|
||||
expect(isFetchGroupsPending({})).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when fetch groups did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[FETCH_GROUPS]: error
|
||||
}
|
||||
};
|
||||
expect(getFetchGroupsFailure(state)).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when fetch groups did not fail", () => {
|
||||
expect(getFetchGroupsFailure({})).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return group emptyGroup", () => {
|
||||
const state = {
|
||||
groups: {
|
||||
byNames: {
|
||||
emptyGroup: emptyGroup
|
||||
}
|
||||
}
|
||||
};
|
||||
expect(getGroupByName(state, "emptyGroup")).toEqual(emptyGroup);
|
||||
});
|
||||
|
||||
it("should return true, when fetch group humanGroup is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[FETCH_GROUP + "/humanGroup"]: true
|
||||
}
|
||||
};
|
||||
expect(isFetchGroupPending(state, "humanGroup")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when fetch group humanGroup is not pending", () => {
|
||||
expect(isFetchGroupPending({}, "humanGroup")).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when fetch group humanGroup did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[FETCH_GROUP + "/humanGroup"]: error
|
||||
}
|
||||
};
|
||||
expect(getFetchGroupFailure(state, "humanGroup")).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when fetch group humanGroup did not fail", () => {
|
||||
expect(getFetchGroupFailure({}, "humanGroup")).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return true if create group is pending", () => {
|
||||
expect(
|
||||
isCreateGroupPending({
|
||||
pending: {
|
||||
[CREATE_GROUP]: true
|
||||
}
|
||||
})
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return false if create group is not pending", () => {
|
||||
expect(isCreateGroupPending({})).toBe(false);
|
||||
});
|
||||
|
||||
it("should return error if creating group failed", () => {
|
||||
expect(
|
||||
getCreateGroupFailure({
|
||||
failure: {
|
||||
[CREATE_GROUP]: error
|
||||
}
|
||||
})
|
||||
).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined if creating group did not fail", () => {
|
||||
expect(getCreateGroupFailure({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return true, when delete group humanGroup is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[DELETE_GROUP + "/humanGroup"]: true
|
||||
}
|
||||
};
|
||||
expect(isDeleteGroupPending(state, "humanGroup")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when delete group humanGroup is not pending", () => {
|
||||
expect(isDeleteGroupPending({}, "humanGroup")).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when delete group humanGroup did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[DELETE_GROUP + "/humanGroup"]: error
|
||||
}
|
||||
};
|
||||
expect(getDeleteGroupFailure(state, "humanGroup")).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when delete group humanGroup did not fail", () => {
|
||||
expect(getDeleteGroupFailure({}, "humanGroup")).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return true, if createGroup is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[CREATE_GROUP]: true
|
||||
}
|
||||
};
|
||||
expect(isCreateGroupPending(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false, if createGroup is not pending", () => {
|
||||
expect(isCreateGroupPending({})).toBe(false);
|
||||
});
|
||||
|
||||
it("should return error of createGroup failed", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[CREATE_GROUP]: error
|
||||
}
|
||||
};
|
||||
expect(getCreateGroupFailure(state)).toEqual(error);
|
||||
});
|
||||
});
|
||||
43
scm-ui/ui-webapp/src/i18n.js
Normal file
43
scm-ui/ui-webapp/src/i18n.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import i18n from "i18next";
|
||||
import Backend from "i18next-fetch-backend";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { reactI18nextModule } from "react-i18next";
|
||||
import { urls } from "@scm-manager/ui-components";
|
||||
|
||||
const loadPath = urls.withContextPath("/locales/{{lng}}/{{ns}}.json");
|
||||
|
||||
// TODO load locales for moment
|
||||
|
||||
i18n
|
||||
.use(Backend)
|
||||
.use(LanguageDetector)
|
||||
.use(reactI18nextModule)
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
|
||||
// try to load only "en" and not "en_US"
|
||||
load: "languageOnly",
|
||||
|
||||
// have a common namespace used around the full app
|
||||
ns: ["commons"],
|
||||
defaultNS: "commons",
|
||||
|
||||
debug: false,
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false // not needed for react!!
|
||||
},
|
||||
|
||||
react: {
|
||||
wait: true
|
||||
},
|
||||
|
||||
backend: {
|
||||
loadPath: loadPath,
|
||||
init: {
|
||||
credentials: "same-origin"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
54
scm-ui/ui-webapp/src/index.js
Normal file
54
scm-ui/ui-webapp/src/index.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import Index from "./containers/Index";
|
||||
import registerServiceWorker from "./registerServiceWorker";
|
||||
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import i18n from "./i18n";
|
||||
|
||||
import { Provider } from "react-redux";
|
||||
import createHistory from "history/createBrowserHistory";
|
||||
|
||||
import type { BrowserHistory } from "history/createBrowserHistory";
|
||||
|
||||
import createReduxStore from "./createReduxStore";
|
||||
import { ConnectedRouter } from "react-router-redux";
|
||||
|
||||
import { urls } from "@scm-manager/ui-components";
|
||||
|
||||
import jss from "jss";
|
||||
import jssNested from "jss-nested";
|
||||
|
||||
import "./style/scm.scss";
|
||||
|
||||
// setup jss and install required plugins
|
||||
jss.setup(jssNested());
|
||||
|
||||
// Create a history of your choosing (we're using a browser history in this case)
|
||||
const history: BrowserHistory = createHistory({
|
||||
basename: urls.contextPath
|
||||
});
|
||||
|
||||
// Add the reducer to your store on the `router` key
|
||||
// Also apply our middleware for navigating
|
||||
const store = createReduxStore(history);
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) {
|
||||
throw new Error("could not find root element");
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{/* ConnectedRouter will use the store from Provider automatically */}
|
||||
<ConnectedRouter history={history}>
|
||||
<Index />
|
||||
</ConnectedRouter>
|
||||
</I18nextProvider>
|
||||
</Provider>,
|
||||
root
|
||||
);
|
||||
|
||||
registerServiceWorker();
|
||||
279
scm-ui/ui-webapp/src/modules/auth.js
Normal file
279
scm-ui/ui-webapp/src/modules/auth.js
Normal file
@@ -0,0 +1,279 @@
|
||||
// @flow
|
||||
import type { Me } from "@scm-manager/ui-types";
|
||||
import * as types from "./types";
|
||||
|
||||
import { apiClient, UnauthorizedError } from "@scm-manager/ui-components";
|
||||
import { isPending } from "./pending";
|
||||
import { getFailure } from "./failure";
|
||||
import {
|
||||
callFetchIndexResources,
|
||||
fetchIndexResources,
|
||||
fetchIndexResourcesPending,
|
||||
fetchIndexResourcesSuccess
|
||||
} from "./indexResource";
|
||||
|
||||
// Action
|
||||
|
||||
export const LOGIN = "scm/auth/LOGIN";
|
||||
export const LOGIN_PENDING = `${LOGIN}_${types.PENDING_SUFFIX}`;
|
||||
export const LOGIN_SUCCESS = `${LOGIN}_${types.SUCCESS_SUFFIX}`;
|
||||
export const LOGIN_FAILURE = `${LOGIN}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
export const FETCH_ME = "scm/auth/FETCH_ME";
|
||||
export const FETCH_ME_PENDING = `${FETCH_ME}_${types.PENDING_SUFFIX}`;
|
||||
export const FETCH_ME_SUCCESS = `${FETCH_ME}_${types.SUCCESS_SUFFIX}`;
|
||||
export const FETCH_ME_FAILURE = `${FETCH_ME}_${types.FAILURE_SUFFIX}`;
|
||||
export const FETCH_ME_UNAUTHORIZED = `${FETCH_ME}_UNAUTHORIZED`;
|
||||
|
||||
export const LOGOUT = "scm/auth/LOGOUT";
|
||||
export const LOGOUT_PENDING = `${LOGOUT}_${types.PENDING_SUFFIX}`;
|
||||
export const LOGOUT_SUCCESS = `${LOGOUT}_${types.SUCCESS_SUFFIX}`;
|
||||
export const LOGOUT_FAILURE = `${LOGOUT}_${types.FAILURE_SUFFIX}`;
|
||||
export const LOGOUT_REDIRECT = `${LOGOUT}_REDIRECT`;
|
||||
|
||||
// Reducer
|
||||
|
||||
const initialState = {};
|
||||
|
||||
export default function reducer(
|
||||
state: Object = initialState,
|
||||
action: Object = { type: "UNKNOWN" }
|
||||
) {
|
||||
switch (action.type) {
|
||||
case LOGIN_SUCCESS:
|
||||
case FETCH_ME_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
me: action.payload,
|
||||
authenticated: true
|
||||
};
|
||||
case FETCH_ME_UNAUTHORIZED:
|
||||
return {
|
||||
me: {},
|
||||
authenticated: false
|
||||
};
|
||||
case LOGOUT_SUCCESS:
|
||||
return initialState;
|
||||
|
||||
case LOGOUT_REDIRECT: {
|
||||
// we keep the current state until we are redirected to the new page
|
||||
return {
|
||||
...state,
|
||||
redirecting: true
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// Action Creators
|
||||
|
||||
export const loginPending = () => {
|
||||
return {
|
||||
type: LOGIN_PENDING
|
||||
};
|
||||
};
|
||||
|
||||
export const loginSuccess = (me: Me) => {
|
||||
return {
|
||||
type: LOGIN_SUCCESS,
|
||||
payload: me
|
||||
};
|
||||
};
|
||||
|
||||
export const loginFailure = (error: Error) => {
|
||||
return {
|
||||
type: LOGIN_FAILURE,
|
||||
payload: error
|
||||
};
|
||||
};
|
||||
|
||||
export const logoutPending = () => {
|
||||
return {
|
||||
type: LOGOUT_PENDING
|
||||
};
|
||||
};
|
||||
|
||||
export const logoutSuccess = () => {
|
||||
return {
|
||||
type: LOGOUT_SUCCESS
|
||||
};
|
||||
};
|
||||
|
||||
export const redirectAfterLogout = () => {
|
||||
return {
|
||||
type: LOGOUT_REDIRECT
|
||||
};
|
||||
};
|
||||
|
||||
export const logoutFailure = (error: Error) => {
|
||||
return {
|
||||
type: LOGOUT_FAILURE,
|
||||
payload: error
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchMePending = () => {
|
||||
return {
|
||||
type: FETCH_ME_PENDING
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchMeSuccess = (me: Me) => {
|
||||
return {
|
||||
type: FETCH_ME_SUCCESS,
|
||||
payload: me
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchMeUnauthenticated = () => {
|
||||
return {
|
||||
type: FETCH_ME_UNAUTHORIZED,
|
||||
resetPending: true
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchMeFailure = (error: Error) => {
|
||||
return {
|
||||
type: FETCH_ME_FAILURE,
|
||||
payload: error
|
||||
};
|
||||
};
|
||||
|
||||
// side effects
|
||||
|
||||
const callFetchMe = (link: string): Promise<Me> => {
|
||||
return apiClient.get(link).then(response => {
|
||||
return response.json();
|
||||
});
|
||||
};
|
||||
|
||||
export const login = (
|
||||
loginLink: string,
|
||||
username: string,
|
||||
password: string
|
||||
) => {
|
||||
const login_data = {
|
||||
cookie: true,
|
||||
grant_type: "password",
|
||||
username,
|
||||
password
|
||||
};
|
||||
return function(dispatch: any) {
|
||||
dispatch(loginPending());
|
||||
return apiClient
|
||||
.post(loginLink, login_data)
|
||||
.then(() => {
|
||||
dispatch(fetchIndexResourcesPending());
|
||||
return callFetchIndexResources();
|
||||
})
|
||||
.then(response => {
|
||||
dispatch(fetchIndexResourcesSuccess(response));
|
||||
const meLink = response._links.me.href;
|
||||
return callFetchMe(meLink);
|
||||
})
|
||||
.then(me => {
|
||||
dispatch(loginSuccess(me));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(loginFailure(err));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchMe = (link: string) => {
|
||||
return function(dispatch: any) {
|
||||
dispatch(fetchMePending());
|
||||
return callFetchMe(link)
|
||||
.then(me => {
|
||||
dispatch(fetchMeSuccess(me));
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
dispatch(fetchMeUnauthenticated());
|
||||
} else {
|
||||
dispatch(fetchMeFailure(error));
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const logout = (link: string) => {
|
||||
return function(dispatch: any) {
|
||||
dispatch(logoutPending());
|
||||
return apiClient
|
||||
.delete(link)
|
||||
.then(response => {
|
||||
return response.status === 200
|
||||
? response.json()
|
||||
: new Promise(function(resolve) {
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
.then(json => {
|
||||
let fetchIndex = true;
|
||||
if (json && json.logoutRedirect) {
|
||||
dispatch(redirectAfterLogout());
|
||||
window.location.assign(json.logoutRedirect);
|
||||
fetchIndex = false;
|
||||
} else {
|
||||
dispatch(logoutSuccess());
|
||||
}
|
||||
return fetchIndex;
|
||||
})
|
||||
.then((fetchIndex: boolean) => {
|
||||
if (fetchIndex) {
|
||||
dispatch(fetchIndexResources());
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(logoutFailure(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// selectors
|
||||
|
||||
const stateAuth = (state: Object): Object => {
|
||||
return state.auth || {};
|
||||
};
|
||||
|
||||
export const isAuthenticated = (state: Object) => {
|
||||
if (stateAuth(state).authenticated) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getMe = (state: Object): Me => {
|
||||
return stateAuth(state).me;
|
||||
};
|
||||
|
||||
export const isFetchMePending = (state: Object) => {
|
||||
return isPending(state, FETCH_ME);
|
||||
};
|
||||
|
||||
export const getFetchMeFailure = (state: Object) => {
|
||||
return getFailure(state, FETCH_ME);
|
||||
};
|
||||
|
||||
export const isLoginPending = (state: Object) => {
|
||||
return isPending(state, LOGIN);
|
||||
};
|
||||
|
||||
export const getLoginFailure = (state: Object) => {
|
||||
return getFailure(state, LOGIN);
|
||||
};
|
||||
|
||||
export const isLogoutPending = (state: Object) => {
|
||||
return isPending(state, LOGOUT);
|
||||
};
|
||||
|
||||
export const getLogoutFailure = (state: Object) => {
|
||||
return getFailure(state, LOGOUT);
|
||||
};
|
||||
|
||||
export const isRedirecting = (state: Object) => {
|
||||
return !!stateAuth(state).redirecting;
|
||||
};
|
||||
368
scm-ui/ui-webapp/src/modules/auth.test.js
Normal file
368
scm-ui/ui-webapp/src/modules/auth.test.js
Normal file
@@ -0,0 +1,368 @@
|
||||
import reducer, {
|
||||
fetchMeSuccess,
|
||||
logout,
|
||||
logoutSuccess,
|
||||
loginSuccess,
|
||||
fetchMeUnauthenticated,
|
||||
LOGIN_SUCCESS,
|
||||
login,
|
||||
LOGIN_FAILURE,
|
||||
LOGOUT_FAILURE,
|
||||
LOGOUT_SUCCESS,
|
||||
FETCH_ME_SUCCESS,
|
||||
fetchMe,
|
||||
FETCH_ME_FAILURE,
|
||||
FETCH_ME_UNAUTHORIZED,
|
||||
isAuthenticated,
|
||||
LOGIN_PENDING,
|
||||
FETCH_ME_PENDING,
|
||||
LOGOUT_PENDING,
|
||||
getMe,
|
||||
isFetchMePending,
|
||||
isLoginPending,
|
||||
isLogoutPending,
|
||||
getFetchMeFailure,
|
||||
LOGIN,
|
||||
FETCH_ME,
|
||||
LOGOUT,
|
||||
getLoginFailure,
|
||||
getLogoutFailure, isRedirecting, LOGOUT_REDIRECT, redirectAfterLogout,
|
||||
} from "./auth";
|
||||
|
||||
import configureMockStore from "redux-mock-store";
|
||||
import thunk from "redux-thunk";
|
||||
import fetchMock from "fetch-mock";
|
||||
import {
|
||||
FETCH_INDEXRESOURCES_PENDING,
|
||||
FETCH_INDEXRESOURCES_SUCCESS
|
||||
} from "./indexResource";
|
||||
|
||||
const me = {
|
||||
name: "tricia",
|
||||
displayName: "Tricia McMillian",
|
||||
mail: "trillian@heartofgold.universe"
|
||||
};
|
||||
|
||||
describe("auth reducer", () => {
|
||||
it("should set me and login on successful fetch of me", () => {
|
||||
const state = reducer(undefined, fetchMeSuccess(me));
|
||||
expect(state.me).toBe(me);
|
||||
expect(state.authenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("should set authenticated to false", () => {
|
||||
const initialState = {
|
||||
authenticated: true,
|
||||
me
|
||||
};
|
||||
const state = reducer(initialState, fetchMeUnauthenticated());
|
||||
expect(state.me.name).toBeUndefined();
|
||||
expect(state.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("should reset the state after logout", () => {
|
||||
const initialState = {
|
||||
authenticated: true,
|
||||
me
|
||||
};
|
||||
const state = reducer(initialState, logoutSuccess());
|
||||
expect(state.me).toBeUndefined();
|
||||
expect(state.authenticated).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should keep state and set redirecting to true", () => {
|
||||
const initialState = {
|
||||
authenticated: true,
|
||||
me
|
||||
};
|
||||
const state = reducer(initialState, redirectAfterLogout());
|
||||
expect(state.me).toBe(initialState.me);
|
||||
expect(state.authenticated).toBe(initialState.authenticated);
|
||||
expect(state.redirecting).toBe(true);
|
||||
});
|
||||
|
||||
it("should set state authenticated and me after login", () => {
|
||||
const state = reducer(undefined, loginSuccess(me));
|
||||
expect(state.me).toBe(me);
|
||||
expect(state.authenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth actions", () => {
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should dispatch login success and dispatch fetch me", () => {
|
||||
fetchMock.postOnce("/api/v2/auth/access_token", {
|
||||
body: {
|
||||
cookie: true,
|
||||
grant_type: "password",
|
||||
username: "tricia",
|
||||
password: "secret123"
|
||||
},
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
|
||||
fetchMock.getOnce("/api/v2/me", {
|
||||
body: me,
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
|
||||
const meLink = {
|
||||
me: {
|
||||
href: "/me"
|
||||
}
|
||||
};
|
||||
|
||||
fetchMock.getOnce("/api/v2/", {
|
||||
_links: meLink
|
||||
});
|
||||
|
||||
const expectedActions = [
|
||||
{ type: LOGIN_PENDING },
|
||||
{ type: FETCH_INDEXRESOURCES_PENDING },
|
||||
{ type: FETCH_INDEXRESOURCES_SUCCESS, payload: { _links: meLink } },
|
||||
{ type: LOGIN_SUCCESS, payload: me }
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store
|
||||
.dispatch(login("/auth/access_token", "tricia", "secret123"))
|
||||
.then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch login failure", () => {
|
||||
fetchMock.postOnce("/api/v2/auth/access_token", {
|
||||
status: 400
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store
|
||||
.dispatch(login("/auth/access_token", "tricia", "secret123"))
|
||||
.then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(LOGIN_PENDING);
|
||||
expect(actions[1].type).toEqual(LOGIN_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch fetch me success", () => {
|
||||
fetchMock.getOnce("/api/v2/me", {
|
||||
body: me,
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_ME_PENDING },
|
||||
{
|
||||
type: FETCH_ME_SUCCESS,
|
||||
payload: me
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(fetchMe("me")).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch fetch me failure", () => {
|
||||
fetchMock.getOnce("/api/v2/me", {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchMe("me")).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_ME_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_ME_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch fetch me unauthorized", () => {
|
||||
fetchMock.getOnce("/api/v2/me", 401);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_ME_PENDING },
|
||||
{ type: FETCH_ME_UNAUTHORIZED, resetPending: true }
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(fetchMe("me")).then(() => {
|
||||
// return of async actions
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch logout success", () => {
|
||||
fetchMock.deleteOnce("/api/v2/auth/access_token", {
|
||||
status: 204
|
||||
});
|
||||
|
||||
fetchMock.getOnce("/api/v2/me", {
|
||||
status: 401
|
||||
});
|
||||
|
||||
fetchMock.getOnce("/api/v2/", {
|
||||
_links: {
|
||||
login: {
|
||||
login: "/login"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const expectedActions = [
|
||||
{ type: LOGOUT_PENDING },
|
||||
{ type: LOGOUT_SUCCESS },
|
||||
{ type: FETCH_INDEXRESOURCES_PENDING }
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(logout("/auth/access_token")).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch logout success and redirect", () => {
|
||||
fetchMock.deleteOnce("/api/v2/auth/access_token", {
|
||||
status: 200,
|
||||
body: { logoutRedirect: "http://example.com/cas/logout" }
|
||||
});
|
||||
|
||||
fetchMock.getOnce("/api/v2/me", {
|
||||
status: 401
|
||||
});
|
||||
|
||||
fetchMock.getOnce("/api/v2/", {
|
||||
_links: {
|
||||
login: {
|
||||
login: "/login"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.location.assign = jest.fn();
|
||||
|
||||
const expectedActions = [
|
||||
{ type: LOGOUT_PENDING },
|
||||
{ type: LOGOUT_REDIRECT }
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(logout("/auth/access_token")).then(() => {
|
||||
expect(window.location.assign.mock.calls[0][0]).toBe(
|
||||
"http://example.com/cas/logout"
|
||||
);
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch logout failure", () => {
|
||||
fetchMock.deleteOnce("/api/v2/auth/access_token", {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(logout("/auth/access_token")).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(LOGOUT_PENDING);
|
||||
expect(actions[1].type).toEqual(LOGOUT_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth selectors", () => {
|
||||
const error = new Error("yo it failed");
|
||||
|
||||
it("should be false, if authenticated is undefined or false", () => {
|
||||
expect(isAuthenticated({})).toBe(false);
|
||||
expect(isAuthenticated({ auth: {} })).toBe(false);
|
||||
expect(isAuthenticated({ auth: { authenticated: false } })).toBe(false);
|
||||
});
|
||||
|
||||
it("should be true, if authenticated is true", () => {
|
||||
expect(isAuthenticated({ auth: { authenticated: true } })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return me", () => {
|
||||
expect(getMe({ auth: { me } })).toBe(me);
|
||||
});
|
||||
|
||||
it("should return undefined, if me is not set", () => {
|
||||
expect(getMe({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return true, if FETCH_ME is pending", () => {
|
||||
expect(isFetchMePending({ pending: { [FETCH_ME]: true } })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false, if FETCH_ME is not in pending state", () => {
|
||||
expect(isFetchMePending({ pending: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true, if LOGIN is pending", () => {
|
||||
expect(isLoginPending({ pending: { [LOGIN]: true } })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false, if LOGIN is not in pending state", () => {
|
||||
expect(isLoginPending({ pending: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true, if LOGOUT is pending", () => {
|
||||
expect(isLogoutPending({ pending: { [LOGOUT]: true } })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false, if LOGOUT is not in pending state", () => {
|
||||
expect(isLogoutPending({ pending: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("should return the error, if failure state is set for FETCH_ME", () => {
|
||||
expect(getFetchMeFailure({ failure: { [FETCH_ME]: error } })).toBe(error);
|
||||
});
|
||||
|
||||
it("should return unknown, if failure state is not set for FETCH_ME", () => {
|
||||
expect(getFetchMeFailure({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return the error, if failure state is set for LOGIN", () => {
|
||||
expect(getLoginFailure({ failure: { [LOGIN]: error } })).toBe(error);
|
||||
});
|
||||
|
||||
it("should return unknown, if failure state is not set for LOGIN", () => {
|
||||
expect(getLoginFailure({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return the error, if failure state is set for LOGOUT", () => {
|
||||
expect(getLogoutFailure({ failure: { [LOGOUT]: error } })).toBe(error);
|
||||
});
|
||||
|
||||
it("should return unknown, if failure state is not set for LOGOUT", () => {
|
||||
expect(getLogoutFailure({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return false, if redirecting is not set", () => {
|
||||
expect(isRedirecting({})).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false, if redirecting is false", () => {
|
||||
expect(isRedirecting({auth: { redirecting: false }})).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true, if redirecting is true", () => {
|
||||
expect(isRedirecting({auth: { redirecting: true }})).toBe(true);
|
||||
});
|
||||
});
|
||||
16
scm-ui/ui-webapp/src/modules/changePassword.js
Normal file
16
scm-ui/ui-webapp/src/modules/changePassword.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// @flow
|
||||
import { apiClient } from "@scm-manager/ui-components";
|
||||
|
||||
export const CONTENT_TYPE_PASSWORD_CHANGE =
|
||||
"application/vnd.scmm-passwordChange+json;v=2";
|
||||
export function changePassword(
|
||||
url: string,
|
||||
oldPassword: string,
|
||||
newPassword: string
|
||||
) {
|
||||
return apiClient
|
||||
.put(url, { oldPassword, newPassword }, CONTENT_TYPE_PASSWORD_CHANGE)
|
||||
.then(response => {
|
||||
return response;
|
||||
});
|
||||
}
|
||||
25
scm-ui/ui-webapp/src/modules/changePassword.test.js
Normal file
25
scm-ui/ui-webapp/src/modules/changePassword.test.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import fetchMock from "fetch-mock";
|
||||
import { changePassword, CONTENT_TYPE_PASSWORD_CHANGE } from "./changePassword";
|
||||
|
||||
describe("change password", () => {
|
||||
const CHANGE_PASSWORD_URL = "/me/password";
|
||||
const oldPassword = "old";
|
||||
const newPassword = "new";
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should update password", done => {
|
||||
fetchMock.put("/api/v2" + CHANGE_PASSWORD_URL, 204, {
|
||||
headers: { "content-type": CONTENT_TYPE_PASSWORD_CHANGE }
|
||||
});
|
||||
|
||||
changePassword(CHANGE_PASSWORD_URL, oldPassword, newPassword).then(
|
||||
content => {
|
||||
done();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
84
scm-ui/ui-webapp/src/modules/failure.js
Normal file
84
scm-ui/ui-webapp/src/modules/failure.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// @flow
|
||||
import type { Action } from "@scm-manager/ui-types";
|
||||
|
||||
const FAILURE_SUFFIX = "_FAILURE";
|
||||
const RESET_PATTERN = /^(.*)_(SUCCESS|RESET)$/;
|
||||
|
||||
function extractIdentifierFromFailure(action: Action) {
|
||||
const type = action.type;
|
||||
let identifier = type.substring(0, type.length - FAILURE_SUFFIX.length);
|
||||
if (action.itemId) {
|
||||
identifier += "/" + action.itemId;
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
function removeAllEntriesOfIdentifierFromState(
|
||||
state: Object,
|
||||
payload: any,
|
||||
identifier: string
|
||||
) {
|
||||
const newState = {};
|
||||
for (let failureType in state) {
|
||||
if (failureType !== identifier && !failureType.startsWith(identifier)) {
|
||||
newState[failureType] = state[failureType];
|
||||
}
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
|
||||
function removeFromState(state: Object, identifier: string) {
|
||||
const newState = {};
|
||||
for (let failureType in state) {
|
||||
if (failureType !== identifier) {
|
||||
newState[failureType] = state[failureType];
|
||||
}
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
|
||||
export default function reducer(
|
||||
state: Object = {},
|
||||
action: Action = { type: "UNKNOWN" }
|
||||
): Object {
|
||||
const type = action.type;
|
||||
if (type.endsWith(FAILURE_SUFFIX)) {
|
||||
const identifier = extractIdentifierFromFailure(action);
|
||||
let payload;
|
||||
if (action.payload instanceof Error) {
|
||||
payload = action.payload;
|
||||
} else if (action.payload) {
|
||||
payload = action.payload.error;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
[identifier]: payload
|
||||
};
|
||||
} else {
|
||||
const match = RESET_PATTERN.exec(type);
|
||||
if (match) {
|
||||
let identifier = match[1];
|
||||
if (action.itemId) {
|
||||
identifier += "/" + action.itemId;
|
||||
}
|
||||
if (action.payload)
|
||||
return removeAllEntriesOfIdentifierFromState(state, action.payload, identifier);
|
||||
else return removeFromState(state, identifier);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function getFailure(
|
||||
state: Object,
|
||||
actionType: string,
|
||||
itemId?: string | number
|
||||
) {
|
||||
if (state.failure) {
|
||||
let identifier = actionType;
|
||||
if (itemId) {
|
||||
identifier += "/" + itemId;
|
||||
}
|
||||
return state.failure[identifier];
|
||||
}
|
||||
}
|
||||
130
scm-ui/ui-webapp/src/modules/failure.test.js
Normal file
130
scm-ui/ui-webapp/src/modules/failure.test.js
Normal file
@@ -0,0 +1,130 @@
|
||||
// @flow
|
||||
import reducer, { getFailure } from "./failure";
|
||||
|
||||
const err = new Error("something failed");
|
||||
const otherErr = new Error("something else failed");
|
||||
|
||||
describe("failure reducer", () => {
|
||||
it("should set the error for FETCH_ITEMS", () => {
|
||||
const newState = reducer({}, { type: "FETCH_ITEMS_FAILURE", payload: err });
|
||||
expect(newState["FETCH_ITEMS"]).toBe(err);
|
||||
});
|
||||
|
||||
it("should do nothing for unknown action types", () => {
|
||||
const state = {};
|
||||
const newState = reducer(state, { type: "UNKNOWN" });
|
||||
expect(newState).toBe(state);
|
||||
});
|
||||
|
||||
it("should set the error for FETCH_ITEMS, if payload has multiple values", () => {
|
||||
const newState = reducer(
|
||||
{},
|
||||
{
|
||||
type: "FETCH_ITEMS_FAILURE",
|
||||
payload: { something: "something", error: err }
|
||||
}
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBe(err);
|
||||
});
|
||||
|
||||
it("should set the error for FETCH_ITEMS, but should not affect others", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_USERS: otherErr
|
||||
},
|
||||
{ type: "FETCH_ITEMS_FAILURE", payload: err }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBe(err);
|
||||
expect(newState["FETCH_USERS"]).toBe(otherErr);
|
||||
});
|
||||
|
||||
it("should reset FETCH_ITEMS after FETCH_ITEMS_SUCCESS", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_ITEMS: err
|
||||
},
|
||||
{ type: "FETCH_ITEMS_SUCCESS" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should reset FETCH_ITEMS after FETCH_ITEMS_RESET", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_ITEMS: err
|
||||
},
|
||||
{ type: "FETCH_ITEMS_RESET" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should reset FETCH_ITEMS after FETCH_ITEMS_RESET, but should not affect others", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_ITEMS: err,
|
||||
FETCH_USERS: err
|
||||
},
|
||||
{ type: "FETCH_ITEMS_RESET" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
expect(newState["FETCH_USERS"]).toBe(err);
|
||||
});
|
||||
|
||||
it("should set the error for a single item of FETCH_ITEM", () => {
|
||||
const newState = reducer(
|
||||
{},
|
||||
{ type: "FETCH_ITEM_FAILURE", payload: err, itemId: 42 }
|
||||
);
|
||||
expect(newState["FETCH_ITEM/42"]).toBe(err);
|
||||
});
|
||||
|
||||
it("should reset error for a single item of FETCH_ITEM", () => {
|
||||
const newState = reducer(
|
||||
{ "FETCH_ITEM/42": err },
|
||||
{ type: "FETCH_ITEM_SUCCESS", payload: err, itemId: 42 }
|
||||
);
|
||||
expect(newState["FETCH_ITEM/42"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("failure selector", () => {
|
||||
it("should return failure, if FETCH_ITEMS failure exists", () => {
|
||||
const failure = getFailure(
|
||||
{
|
||||
failure: {
|
||||
FETCH_ITEMS: err
|
||||
}
|
||||
},
|
||||
"FETCH_ITEMS"
|
||||
);
|
||||
expect(failure).toBe(err);
|
||||
});
|
||||
|
||||
it("should return undefined, if state has no failure", () => {
|
||||
const failure = getFailure({}, "FETCH_ITEMS");
|
||||
expect(failure).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined, if FETCH_ITEMS is not defined", () => {
|
||||
const failure = getFailure(
|
||||
{
|
||||
failure: {}
|
||||
},
|
||||
"FETCH_ITEMS"
|
||||
);
|
||||
expect(failure).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should return failure, if FETCH_ITEM 42 failure exists", () => {
|
||||
const failure = getFailure(
|
||||
{
|
||||
failure: {
|
||||
"FETCH_ITEM/42": err
|
||||
}
|
||||
},
|
||||
"FETCH_ITEM",
|
||||
42
|
||||
);
|
||||
expect(failure).toBe(err);
|
||||
});
|
||||
});
|
||||
201
scm-ui/ui-webapp/src/modules/indexResource.js
Normal file
201
scm-ui/ui-webapp/src/modules/indexResource.js
Normal file
@@ -0,0 +1,201 @@
|
||||
// @flow
|
||||
import * as types from "./types";
|
||||
|
||||
import { apiClient } from "@scm-manager/ui-components";
|
||||
import type { Action, IndexResources, Link } from "@scm-manager/ui-types";
|
||||
import { isPending } from "./pending";
|
||||
import { getFailure } from "./failure";
|
||||
|
||||
// Action
|
||||
|
||||
export const FETCH_INDEXRESOURCES = "scm/INDEXRESOURCES";
|
||||
export const FETCH_INDEXRESOURCES_PENDING = `${FETCH_INDEXRESOURCES}_${
|
||||
types.PENDING_SUFFIX
|
||||
}`;
|
||||
export const FETCH_INDEXRESOURCES_SUCCESS = `${FETCH_INDEXRESOURCES}_${
|
||||
types.SUCCESS_SUFFIX
|
||||
}`;
|
||||
export const FETCH_INDEXRESOURCES_FAILURE = `${FETCH_INDEXRESOURCES}_${
|
||||
types.FAILURE_SUFFIX
|
||||
}`;
|
||||
|
||||
const INDEX_RESOURCES_LINK = "/";
|
||||
|
||||
export const callFetchIndexResources = (): Promise<IndexResources> => {
|
||||
return apiClient.get(INDEX_RESOURCES_LINK).then(response => {
|
||||
return response.json();
|
||||
});
|
||||
};
|
||||
|
||||
export function fetchIndexResources() {
|
||||
return function(dispatch: any) {
|
||||
dispatch(fetchIndexResourcesPending());
|
||||
return callFetchIndexResources()
|
||||
.then(resources => {
|
||||
dispatch(fetchIndexResourcesSuccess(resources));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(fetchIndexResourcesFailure(err));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchIndexResourcesPending(): Action {
|
||||
return {
|
||||
type: FETCH_INDEXRESOURCES_PENDING
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchIndexResourcesSuccess(resources: IndexResources): Action {
|
||||
return {
|
||||
type: FETCH_INDEXRESOURCES_SUCCESS,
|
||||
payload: resources
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchIndexResourcesFailure(err: Error): Action {
|
||||
return {
|
||||
type: FETCH_INDEXRESOURCES_FAILURE,
|
||||
payload: err
|
||||
};
|
||||
}
|
||||
|
||||
// reducer
|
||||
export default function reducer(
|
||||
state: Object = {},
|
||||
action: Action = { type: "UNKNOWN" }
|
||||
): Object {
|
||||
if (!action.payload) {
|
||||
return state;
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case FETCH_INDEXRESOURCES_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
version: action.payload.version,
|
||||
links: action.payload._links
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// selectors
|
||||
|
||||
export function isFetchIndexResourcesPending(state: Object) {
|
||||
return isPending(state, FETCH_INDEXRESOURCES);
|
||||
}
|
||||
|
||||
export function getFetchIndexResourcesFailure(state: Object) {
|
||||
return getFailure(state, FETCH_INDEXRESOURCES);
|
||||
}
|
||||
|
||||
export function getLinks(state: Object) {
|
||||
return state.indexResources.links;
|
||||
}
|
||||
|
||||
export function getLink(state: Object, name: string) {
|
||||
if (state.indexResources.links && state.indexResources.links[name]) {
|
||||
return state.indexResources.links[name].href;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLinkCollection(state: Object, name: string): Link[] {
|
||||
if (state.indexResources.links && state.indexResources.links[name]) {
|
||||
return state.indexResources.links[name];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getAppVersion(state: Object) {
|
||||
return state.indexResources.version;
|
||||
}
|
||||
|
||||
export function getUiPluginsLink(state: Object) {
|
||||
return getLink(state, "uiPlugins");
|
||||
}
|
||||
|
||||
export function getAvailablePluginsLink(state: Object) {
|
||||
return getLink(state, "availablePlugins");
|
||||
}
|
||||
|
||||
export function getInstalledPluginsLink(state: Object) {
|
||||
return getLink(state, "installedPlugins");
|
||||
}
|
||||
|
||||
export function getPendingPluginsLink(state: Object) {
|
||||
return getLink(state, "pendingPlugins");
|
||||
}
|
||||
|
||||
export function getMeLink(state: Object) {
|
||||
return getLink(state, "me");
|
||||
}
|
||||
|
||||
export function getLogoutLink(state: Object) {
|
||||
return getLink(state, "logout");
|
||||
}
|
||||
|
||||
export function getLoginLink(state: Object) {
|
||||
return getLink(state, "login");
|
||||
}
|
||||
|
||||
export function getUsersLink(state: Object) {
|
||||
return getLink(state, "users");
|
||||
}
|
||||
|
||||
export function getRepositoryRolesLink(state: Object) {
|
||||
return getLink(state, "repositoryRoles");
|
||||
}
|
||||
|
||||
export function getRepositoryVerbsLink(state: Object) {
|
||||
return getLink(state, "repositoryVerbs");
|
||||
}
|
||||
|
||||
export function getGroupsLink(state: Object) {
|
||||
return getLink(state, "groups");
|
||||
}
|
||||
|
||||
export function getConfigLink(state: Object) {
|
||||
return getLink(state, "config");
|
||||
}
|
||||
|
||||
export function getRepositoriesLink(state: Object) {
|
||||
return getLink(state, "repositories");
|
||||
}
|
||||
|
||||
export function getHgConfigLink(state: Object) {
|
||||
return getLink(state, "hgConfig");
|
||||
}
|
||||
|
||||
export function getGitConfigLink(state: Object) {
|
||||
return getLink(state, "gitConfig");
|
||||
}
|
||||
|
||||
export function getSvnConfigLink(state: Object) {
|
||||
return getLink(state, "svnConfig");
|
||||
}
|
||||
|
||||
export function getLoginInfoLink(state: Object) {
|
||||
return getLink(state, "loginInfo");
|
||||
}
|
||||
|
||||
export function getUserAutoCompleteLink(state: Object): string {
|
||||
const link = getLinkCollection(state, "autocomplete").find(
|
||||
i => i.name === "users"
|
||||
);
|
||||
if (link) {
|
||||
return link.href;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function getGroupAutoCompleteLink(state: Object): string {
|
||||
const link = getLinkCollection(state, "autocomplete").find(
|
||||
i => i.name === "groups"
|
||||
);
|
||||
if (link) {
|
||||
return link.href;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
480
scm-ui/ui-webapp/src/modules/indexResource.test.js
Normal file
480
scm-ui/ui-webapp/src/modules/indexResource.test.js
Normal file
@@ -0,0 +1,480 @@
|
||||
import configureMockStore from "redux-mock-store";
|
||||
import thunk from "redux-thunk";
|
||||
import fetchMock from "fetch-mock";
|
||||
import reducer, {
|
||||
FETCH_INDEXRESOURCES_PENDING,
|
||||
FETCH_INDEXRESOURCES_SUCCESS,
|
||||
FETCH_INDEXRESOURCES_FAILURE,
|
||||
fetchIndexResources,
|
||||
fetchIndexResourcesSuccess,
|
||||
FETCH_INDEXRESOURCES,
|
||||
isFetchIndexResourcesPending,
|
||||
getFetchIndexResourcesFailure,
|
||||
getUiPluginsLink,
|
||||
getMeLink,
|
||||
getLogoutLink,
|
||||
getLoginLink,
|
||||
getUsersLink,
|
||||
getConfigLink,
|
||||
getRepositoriesLink,
|
||||
getHgConfigLink,
|
||||
getGitConfigLink,
|
||||
getSvnConfigLink,
|
||||
getLinks,
|
||||
getGroupsLink,
|
||||
getLinkCollection,
|
||||
getUserAutoCompleteLink,
|
||||
getGroupAutoCompleteLink
|
||||
} from "./indexResource";
|
||||
|
||||
const indexResourcesUnauthenticated = {
|
||||
version: "2.0.0-SNAPSHOT",
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/scm/api/v2/"
|
||||
},
|
||||
uiPlugins: {
|
||||
href: "http://localhost:8081/scm/api/v2/ui/plugins"
|
||||
},
|
||||
login: {
|
||||
href: "http://localhost:8081/scm/api/v2/auth/access_token"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const indexResourcesAuthenticated = {
|
||||
version: "2.0.0-SNAPSHOT",
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/scm/api/v2/"
|
||||
},
|
||||
uiPlugins: {
|
||||
href: "http://localhost:8081/scm/api/v2/ui/plugins"
|
||||
},
|
||||
me: {
|
||||
href: "http://localhost:8081/scm/api/v2/me/"
|
||||
},
|
||||
logout: {
|
||||
href: "http://localhost:8081/scm/api/v2/auth/access_token"
|
||||
},
|
||||
users: {
|
||||
href: "http://localhost:8081/scm/api/v2/users/"
|
||||
},
|
||||
groups: {
|
||||
href: "http://localhost:8081/scm/api/v2/groups/"
|
||||
},
|
||||
config: {
|
||||
href: "http://localhost:8081/scm/api/v2/config"
|
||||
},
|
||||
repositories: {
|
||||
href: "http://localhost:8081/scm/api/v2/repositories/"
|
||||
},
|
||||
hgConfig: {
|
||||
href: "http://localhost:8081/scm/api/v2/config/hg"
|
||||
},
|
||||
gitConfig: {
|
||||
href: "http://localhost:8081/scm/api/v2/config/git"
|
||||
},
|
||||
svnConfig: {
|
||||
href: "http://localhost:8081/scm/api/v2/config/svn"
|
||||
},
|
||||
autocomplete: [
|
||||
{
|
||||
href: "http://localhost:8081/scm/api/v2/autocomplete/users",
|
||||
name: "users"
|
||||
},
|
||||
{
|
||||
href: "http://localhost:8081/scm/api/v2/autocomplete/groups",
|
||||
name: "groups"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
describe("index resource", () => {
|
||||
describe("fetch index resource", () => {
|
||||
const index_url = "/api/v2/";
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should successfully fetch index resources when unauthenticated", () => {
|
||||
fetchMock.getOnce(index_url, indexResourcesUnauthenticated);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_INDEXRESOURCES_PENDING },
|
||||
{
|
||||
type: FETCH_INDEXRESOURCES_SUCCESS,
|
||||
payload: indexResourcesUnauthenticated
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchIndexResources()).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully fetch index resources when authenticated", () => {
|
||||
fetchMock.getOnce(index_url, indexResourcesAuthenticated);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_INDEXRESOURCES_PENDING },
|
||||
{
|
||||
type: FETCH_INDEXRESOURCES_SUCCESS,
|
||||
payload: indexResourcesAuthenticated
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchIndexResources()).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch FETCH_INDEX_RESOURCES_FAILURE if request fails", () => {
|
||||
fetchMock.getOnce(index_url, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchIndexResources()).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_INDEXRESOURCES_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_INDEXRESOURCES_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("index resources 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 index resources on FETCH_INDEXRESOURCES_SUCCESS", () => {
|
||||
const newState = reducer(
|
||||
{},
|
||||
fetchIndexResourcesSuccess(indexResourcesAuthenticated)
|
||||
);
|
||||
expect(newState.links).toBe(indexResourcesAuthenticated._links);
|
||||
});
|
||||
});
|
||||
|
||||
describe("index resources selectors", () => {
|
||||
const error = new Error("something goes wrong");
|
||||
|
||||
it("should return true, when fetch index resources is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[FETCH_INDEXRESOURCES]: true
|
||||
}
|
||||
};
|
||||
expect(isFetchIndexResourcesPending(state)).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when fetch index resources is not pending", () => {
|
||||
expect(isFetchIndexResourcesPending({})).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when fetch index resources did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[FETCH_INDEXRESOURCES]: error
|
||||
}
|
||||
};
|
||||
expect(getFetchIndexResourcesFailure(state)).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when fetch index resources did not fail", () => {
|
||||
expect(getFetchIndexResourcesFailure({})).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return all links", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesAuthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getLinks(state)).toBe(indexResourcesAuthenticated._links);
|
||||
});
|
||||
|
||||
// ui plugins link
|
||||
it("should return ui plugins link when authenticated and has permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesAuthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getUiPluginsLink(state)).toBe(
|
||||
"http://localhost:8081/scm/api/v2/ui/plugins"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return ui plugins links when unauthenticated and has permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesUnauthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getUiPluginsLink(state)).toBe(
|
||||
"http://localhost:8081/scm/api/v2/ui/plugins"
|
||||
);
|
||||
});
|
||||
|
||||
// me link
|
||||
it("should return me link when authenticated and has permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesAuthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getMeLink(state)).toBe("http://localhost:8081/scm/api/v2/me/");
|
||||
});
|
||||
|
||||
it("should return undefined for me link when unauthenticated or has not permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesUnauthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getMeLink(state)).toBe(undefined);
|
||||
});
|
||||
|
||||
// logout link
|
||||
it("should return logout link when authenticated and has permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesAuthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getLogoutLink(state)).toBe(
|
||||
"http://localhost:8081/scm/api/v2/auth/access_token"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return undefined for logout link when unauthenticated or has not permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesUnauthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getLogoutLink(state)).toBe(undefined);
|
||||
});
|
||||
|
||||
// login link
|
||||
it("should return login link when unauthenticated and has permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesUnauthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getLoginLink(state)).toBe(
|
||||
"http://localhost:8081/scm/api/v2/auth/access_token"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return undefined for login link when authenticated or has not permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesAuthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getLoginLink(state)).toBe(undefined);
|
||||
});
|
||||
|
||||
// users link
|
||||
it("should return users link when authenticated and has permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesAuthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getUsersLink(state)).toBe(
|
||||
"http://localhost:8081/scm/api/v2/users/"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return undefined for users link when unauthenticated or has not permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesUnauthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getUsersLink(state)).toBe(undefined);
|
||||
});
|
||||
|
||||
// groups link
|
||||
it("should return groups link when authenticated and has permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesAuthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getGroupsLink(state)).toBe(
|
||||
"http://localhost:8081/scm/api/v2/groups/"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return undefined for groups link when unauthenticated or has not permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesUnauthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getGroupsLink(state)).toBe(undefined);
|
||||
});
|
||||
|
||||
// config link
|
||||
it("should return config link when authenticated and has permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesAuthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getConfigLink(state)).toBe(
|
||||
"http://localhost:8081/scm/api/v2/config"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return undefined for config link when unauthenticated or has not permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesUnauthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getConfigLink(state)).toBe(undefined);
|
||||
});
|
||||
|
||||
// repositories link
|
||||
it("should return repositories link when authenticated and has permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesAuthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getRepositoriesLink(state)).toBe(
|
||||
"http://localhost:8081/scm/api/v2/repositories/"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return config for repositories link when unauthenticated or has not permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesUnauthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getRepositoriesLink(state)).toBe(undefined);
|
||||
});
|
||||
|
||||
// hgConfig link
|
||||
it("should return hgConfig link when authenticated and has permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesAuthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getHgConfigLink(state)).toBe(
|
||||
"http://localhost:8081/scm/api/v2/config/hg"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return config for hgConfig link when unauthenticated or has not permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesUnauthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getHgConfigLink(state)).toBe(undefined);
|
||||
});
|
||||
|
||||
// gitConfig link
|
||||
it("should return gitConfig link when authenticated and has permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesAuthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getGitConfigLink(state)).toBe(
|
||||
"http://localhost:8081/scm/api/v2/config/git"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return config for gitConfig link when unauthenticated or has not permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesUnauthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getGitConfigLink(state)).toBe(undefined);
|
||||
});
|
||||
|
||||
// svnConfig link
|
||||
it("should return svnConfig link when authenticated and has permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesAuthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getSvnConfigLink(state)).toBe(
|
||||
"http://localhost:8081/scm/api/v2/config/svn"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return config for svnConfig link when unauthenticated or has not permission to see it", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesUnauthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getSvnConfigLink(state)).toBe(undefined);
|
||||
});
|
||||
|
||||
// Autocomplete links
|
||||
it("should return link collection", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesAuthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getLinkCollection(state, "autocomplete")).toEqual(
|
||||
indexResourcesAuthenticated._links.autocomplete
|
||||
);
|
||||
});
|
||||
|
||||
it("should return user autocomplete link", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesAuthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getUserAutoCompleteLink(state)).toEqual(
|
||||
"http://localhost:8081/scm/api/v2/autocomplete/users"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return group autocomplete link", () => {
|
||||
const state = {
|
||||
indexResources: {
|
||||
links: indexResourcesAuthenticated._links
|
||||
}
|
||||
};
|
||||
expect(getGroupAutoCompleteLink(state)).toEqual(
|
||||
"http://localhost:8081/scm/api/v2/autocomplete/groups"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
88
scm-ui/ui-webapp/src/modules/pending.js
Normal file
88
scm-ui/ui-webapp/src/modules/pending.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// @flow
|
||||
import type { Action } from "@scm-manager/ui-types";
|
||||
import * as types from "./types";
|
||||
|
||||
const PENDING_SUFFIX = "_" + types.PENDING_SUFFIX;
|
||||
const RESET_ACTIONTYPES = [
|
||||
types.SUCCESS_SUFFIX,
|
||||
types.FAILURE_SUFFIX,
|
||||
types.RESET_SUFFIX
|
||||
];
|
||||
|
||||
function removeFromState(state: Object, identifier: string) {
|
||||
let newState = {};
|
||||
for (let childType in state) {
|
||||
if (childType !== identifier) {
|
||||
newState[childType] = state[childType];
|
||||
}
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
|
||||
function removeAllEntriesOfIdentifierFromState(
|
||||
state: Object,
|
||||
payload: any,
|
||||
identifier: string
|
||||
) {
|
||||
const newState = {};
|
||||
for (let childType in state) {
|
||||
if (childType !== identifier && !childType.startsWith(identifier)) {
|
||||
newState[childType] = state[childType];
|
||||
}
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
|
||||
function extractIdentifierFromPending(action: Action) {
|
||||
const type = action.type;
|
||||
let identifier = type.substring(0, type.length - PENDING_SUFFIX.length);
|
||||
if (action.itemId) {
|
||||
identifier += "/" + action.itemId;
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
export default function reducer(
|
||||
state: Object = {},
|
||||
action: Action = { type: "UNKNOWN" }
|
||||
): Object {
|
||||
const type = action.type;
|
||||
if (type.endsWith(PENDING_SUFFIX)) {
|
||||
const identifier = extractIdentifierFromPending(action);
|
||||
return {
|
||||
...state,
|
||||
[identifier]: true
|
||||
};
|
||||
} else {
|
||||
const index = type.lastIndexOf("_");
|
||||
if (index > 0) {
|
||||
const actionType = type.substring(index + 1);
|
||||
if (RESET_ACTIONTYPES.indexOf(actionType) >= 0 || action.resetPending) {
|
||||
let identifier = type.substring(0, index);
|
||||
if (action.itemId) {
|
||||
identifier += "/" + action.itemId;
|
||||
}
|
||||
if (action.payload)
|
||||
return removeAllEntriesOfIdentifierFromState(state, action.payload, identifier);
|
||||
else
|
||||
return removeFromState(state, identifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function isPending(
|
||||
state: Object,
|
||||
actionType: string,
|
||||
itemId?: string | number
|
||||
) {
|
||||
let type = actionType;
|
||||
if (itemId) {
|
||||
type += "/" + itemId;
|
||||
}
|
||||
if (state.pending && state.pending[type]) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
143
scm-ui/ui-webapp/src/modules/pending.test.js
Normal file
143
scm-ui/ui-webapp/src/modules/pending.test.js
Normal file
@@ -0,0 +1,143 @@
|
||||
import reducer, { isPending } from "./pending";
|
||||
|
||||
describe("pending reducer", () => {
|
||||
it("should set pending for FETCH_ITEMS to true", () => {
|
||||
const newState = reducer({}, { type: "FETCH_ITEMS_PENDING" });
|
||||
expect(newState["FETCH_ITEMS"]).toBe(true);
|
||||
});
|
||||
|
||||
it("should do nothing for unknown action types", () => {
|
||||
const state = {};
|
||||
const newState = reducer(state, { type: "UNKNOWN" });
|
||||
expect(newState).toBe(state);
|
||||
});
|
||||
|
||||
it("should set pending for FETCH_ITEMS to true, but should not affect others", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_USERS: true
|
||||
},
|
||||
{ type: "FETCH_ITEMS_PENDING" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBe(true);
|
||||
expect(newState["FETCH_USERS"]).toBe(true);
|
||||
});
|
||||
|
||||
it("should reset pending state for FETCH_ITEMS after FETCH_ITEMS_SUCCESS", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_ITEMS: true
|
||||
},
|
||||
{ type: "FETCH_ITEMS_SUCCESS" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should reset pending state for FETCH_ITEMS after FETCH_ITEMS_FAILURE", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_ITEMS: true
|
||||
},
|
||||
{ type: "FETCH_ITEMS_FAILURE" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should reset pending state for FETCH_ITEMS after FETCH_ITEMS_RESET", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_ITEMS: true
|
||||
},
|
||||
{ type: "FETCH_ITEMS_RESET" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should reset pending state for FETCH_ITEMS, if resetPending prop is available", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_ITEMS: true
|
||||
},
|
||||
{ type: "FETCH_ITEMS_SOMETHING", resetPending: true }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should reset pending state for FETCH_ITEMS after FETCH_ITEMS_SUCCESS, but should not affect others", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_USERS: true,
|
||||
FETCH_ITEMS: true
|
||||
},
|
||||
{ type: "FETCH_ITEMS_SUCCESS" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
expect(newState["FETCH_USERS"]).toBe(true);
|
||||
});
|
||||
|
||||
it("should set pending for a single item", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
"FETCH_USER/42": false
|
||||
},
|
||||
{ type: "FETCH_USER_PENDING", itemId: 21 }
|
||||
);
|
||||
expect(newState["FETCH_USER/21"]).toBe(true);
|
||||
expect(newState["FETCH_USER/42"]).toBe(false);
|
||||
});
|
||||
|
||||
it("should reset pending for a single item", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
"FETCH_USER/42": true
|
||||
},
|
||||
{ type: "FETCH_USER_SUCCESS", itemId: 42 }
|
||||
);
|
||||
expect(newState["FETCH_USER/42"]).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pending selectors", () => {
|
||||
it("should return true, while FETCH_ITEMS is pending", () => {
|
||||
const result = isPending(
|
||||
{
|
||||
pending: {
|
||||
FETCH_ITEMS: true
|
||||
}
|
||||
},
|
||||
"FETCH_ITEMS"
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false, if pending is not defined", () => {
|
||||
const result = isPending({}, "FETCH_ITEMS");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true, while FETCH_ITEM 42 is pending", () => {
|
||||
const result = isPending(
|
||||
{
|
||||
pending: {
|
||||
"FETCH_ITEM/42": true
|
||||
}
|
||||
},
|
||||
"FETCH_ITEM",
|
||||
42
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true, while FETCH_ITEM 42 is undefined", () => {
|
||||
const result = isPending(
|
||||
{
|
||||
pending: {
|
||||
"FETCH_ITEM/21": true
|
||||
}
|
||||
},
|
||||
"FETCH_ITEM",
|
||||
42
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
4
scm-ui/ui-webapp/src/modules/types.js
Normal file
4
scm-ui/ui-webapp/src/modules/types.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export const PENDING_SUFFIX = "PENDING";
|
||||
export const SUCCESS_SUFFIX = "SUCCESS";
|
||||
export const FAILURE_SUFFIX = "FAILURE";
|
||||
export const RESET_SUFFIX = "RESET";
|
||||
@@ -0,0 +1,47 @@
|
||||
// @flow
|
||||
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import { Checkbox } from "@scm-manager/ui-components";
|
||||
|
||||
type Props = {
|
||||
permission: string,
|
||||
checked: boolean,
|
||||
onChange: (value: boolean, name: string) => void,
|
||||
disabled: boolean,
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class PermissionCheckbox extends React.Component<Props> {
|
||||
render() {
|
||||
const { t, permission, checked, onChange, disabled } = this.props;
|
||||
const key = permission.split(":").join(".");
|
||||
return (
|
||||
<Checkbox
|
||||
name={permission}
|
||||
label={this.translateOrDefault(
|
||||
"permissions." + key + ".displayName",
|
||||
key
|
||||
)}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
helpText={this.translateOrDefault(
|
||||
"permissions." + key + ".description",
|
||||
t("permissions.unknown")
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
translateOrDefault = (key: string, defaultText: string) => {
|
||||
const translation = this.props.t(key);
|
||||
if (translation === key) {
|
||||
return defaultText;
|
||||
} else {
|
||||
return translation;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default translate("plugins")(PermissionCheckbox);
|
||||
211
scm-ui/ui-webapp/src/permissions/components/SetPermissions.js
Normal file
211
scm-ui/ui-webapp/src/permissions/components/SetPermissions.js
Normal file
@@ -0,0 +1,211 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { translate } from "react-i18next";
|
||||
import injectSheet from "react-jss";
|
||||
import classNames from "classnames";
|
||||
import type { Link } from "@scm-manager/ui-types";
|
||||
import {
|
||||
Notification,
|
||||
ErrorNotification,
|
||||
SubmitButton
|
||||
} from "@scm-manager/ui-components";
|
||||
import { getLink } from "../../modules/indexResource";
|
||||
import {
|
||||
loadPermissionsForEntity,
|
||||
setPermissions
|
||||
} from "./handlePermissions";
|
||||
import PermissionCheckbox from "./PermissionCheckbox";
|
||||
|
||||
type Props = {
|
||||
availablePermissionLink: string,
|
||||
selectedPermissionsLink: Link,
|
||||
|
||||
// context props
|
||||
classes: any,
|
||||
t: string => string
|
||||
};
|
||||
|
||||
type State = {
|
||||
permissions: { [string]: boolean },
|
||||
loading: boolean,
|
||||
error?: Error,
|
||||
permissionsChanged: boolean,
|
||||
permissionsSubmitted: boolean,
|
||||
overwritePermissionsLink?: Link
|
||||
};
|
||||
|
||||
const styles = {
|
||||
permissionsWrapper: {
|
||||
paddingBottom: "0",
|
||||
|
||||
"& .field .control": {
|
||||
width: "100%",
|
||||
wordWrap: "break-word"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class SetPermissions extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
permissions: {},
|
||||
loading: true,
|
||||
permissionsChanged: false,
|
||||
permissionsSubmitted: false,
|
||||
modifiable: false,
|
||||
overwritePermissionsLink: undefined
|
||||
};
|
||||
}
|
||||
|
||||
setLoadingState = () => {
|
||||
this.setState({
|
||||
loading: true
|
||||
});
|
||||
};
|
||||
|
||||
setErrorState = (error: Error) => {
|
||||
this.setState({
|
||||
error: error,
|
||||
loading: false
|
||||
});
|
||||
};
|
||||
|
||||
setSuccessfulState = () => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: undefined,
|
||||
permissionsSubmitted: true,
|
||||
permissionsChanged: false
|
||||
});
|
||||
};
|
||||
|
||||
componentDidMount(): void {
|
||||
loadPermissionsForEntity(
|
||||
this.props.availablePermissionLink,
|
||||
this.props.selectedPermissionsLink.href
|
||||
).then(response => {
|
||||
const { permissions, overwriteLink } = response;
|
||||
this.setState({
|
||||
permissions: permissions,
|
||||
loading: false,
|
||||
overwritePermissionsLink: overwriteLink
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
submit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (this.state.permissions) {
|
||||
const { permissions } = this.state;
|
||||
this.setLoadingState();
|
||||
const selectedPermissions = Object.entries(permissions)
|
||||
.filter(e => e[1])
|
||||
.map(e => e[0]);
|
||||
if (this.state.overwritePermissionsLink) {
|
||||
setPermissions(
|
||||
this.state.overwritePermissionsLink.href,
|
||||
selectedPermissions
|
||||
)
|
||||
.then(result => {
|
||||
this.setSuccessfulState();
|
||||
})
|
||||
.catch(err => {
|
||||
this.setErrorState(err);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { loading, permissionsSubmitted, error } = this.state;
|
||||
|
||||
let message = null;
|
||||
|
||||
if (permissionsSubmitted) {
|
||||
message = (
|
||||
<Notification
|
||||
type={"success"}
|
||||
children={t("setPermissions.setPermissionsSuccessful")}
|
||||
onClose={() => this.onClose()}
|
||||
/>
|
||||
);
|
||||
} else if (error) {
|
||||
message = <ErrorNotification error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={this.submit}>
|
||||
{message}
|
||||
{this.renderPermissions()}
|
||||
<SubmitButton
|
||||
disabled={!this.state.permissionsChanged}
|
||||
loading={loading}
|
||||
label={t("setPermissions.button")}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
renderPermissions = () => {
|
||||
const { classes } = this.props;
|
||||
const { overwritePermissionsLink, permissions } = this.state;
|
||||
const permissionArray = Object.keys(permissions);
|
||||
return (
|
||||
<div className="columns">
|
||||
<div className={classNames("column", "is-half", classes.permissionsWrapper)}>
|
||||
{permissionArray.slice(0, (permissionArray.length/2)+1).map(p => (
|
||||
<PermissionCheckbox
|
||||
key={p}
|
||||
permission={p}
|
||||
checked={permissions[p]}
|
||||
onChange={this.valueChanged}
|
||||
disabled={!overwritePermissionsLink}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={classNames("column", "is-half", classes.permissionsWrapper)}>
|
||||
{permissionArray.slice((permissionArray.length/2)+1, permissionArray.length).map(p => (
|
||||
<PermissionCheckbox
|
||||
key={p}
|
||||
permission={p}
|
||||
checked={permissions[p]}
|
||||
onChange={this.valueChanged}
|
||||
disabled={!overwritePermissionsLink}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
valueChanged = (value: boolean, name: string) => {
|
||||
this.setState(state => {
|
||||
const newPermissions = state.permissions;
|
||||
newPermissions[name] = value;
|
||||
return {
|
||||
permissions: newPermissions,
|
||||
permissionsChanged: true
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
onClose = () => {
|
||||
this.setState({
|
||||
permissionsSubmitted: false
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const availablePermissionLink = getLink(state, "permissions");
|
||||
return {
|
||||
availablePermissionLink
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(
|
||||
injectSheet(styles)(translate("permissions")(SetPermissions)));
|
||||
@@ -0,0 +1,33 @@
|
||||
//@flow
|
||||
import { apiClient } from "@scm-manager/ui-components";
|
||||
|
||||
export const CONTENT_TYPE_PERMISSIONS =
|
||||
"application/vnd.scmm-permissionCollection+json;v=2";
|
||||
|
||||
export function setPermissions(url: string, permissions: string[]) {
|
||||
return apiClient
|
||||
.put(url, { permissions: permissions }, CONTENT_TYPE_PERMISSIONS)
|
||||
.then(response => {
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
export function loadPermissionsForEntity(
|
||||
availableUrl: string,
|
||||
userUrl: string
|
||||
) {
|
||||
return Promise.all([
|
||||
apiClient.get(availableUrl).then(response => {
|
||||
return response.json();
|
||||
}),
|
||||
apiClient.get(userUrl).then(response => {
|
||||
return response.json();
|
||||
})
|
||||
]).then(values => {
|
||||
const [availablePermissions, checkedPermissions] = values;
|
||||
const permissions = {};
|
||||
availablePermissions.permissions.forEach(p => (permissions[p] = false));
|
||||
checkedPermissions.permissions.forEach(p => (permissions[p] = true));
|
||||
return { permissions, overwriteLink: checkedPermissions._links.overwrite };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//@flow
|
||||
import fetchMock from "fetch-mock";
|
||||
import { loadPermissionsForEntity } from "./handlePermissions";
|
||||
|
||||
describe("load permissions for entity", () => {
|
||||
const AVAILABLE_PERMISSIONS_URL = "/permissions";
|
||||
const USER_PERMISSIONS_URL = "/user/scmadmin/permissions";
|
||||
|
||||
const availablePermissions = `{
|
||||
"permissions": [
|
||||
"repository:read,pull:*",
|
||||
"repository:read,pull,push:*",
|
||||
"repository:*:*"
|
||||
]
|
||||
}`;
|
||||
const userPermissions = `{
|
||||
"permissions": [
|
||||
"repository:read,pull:*"
|
||||
],
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/api/v2/users/rene/permissions"
|
||||
},
|
||||
"overwrite": {
|
||||
"href": "/api/v2/users/rene/permissions"
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.getOnce(
|
||||
"/api/v2" + AVAILABLE_PERMISSIONS_URL,
|
||||
availablePermissions
|
||||
);
|
||||
fetchMock.getOnce("/api/v2" + USER_PERMISSIONS_URL, userPermissions);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should return permissions array", done => {
|
||||
loadPermissionsForEntity(
|
||||
AVAILABLE_PERMISSIONS_URL,
|
||||
USER_PERMISSIONS_URL
|
||||
).then(result => {
|
||||
const { permissions } = result;
|
||||
expect(Object.entries(permissions).length).toBe(3);
|
||||
expect(permissions["repository:read,pull:*"]).toBe(true);
|
||||
expect(permissions["repository:read,pull,push:*"]).toBe(false);
|
||||
expect(permissions["repository:*:*"]).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return overwrite link", done => {
|
||||
loadPermissionsForEntity(
|
||||
AVAILABLE_PERMISSIONS_URL,
|
||||
USER_PERMISSIONS_URL
|
||||
).then(result => {
|
||||
const { overwriteLink } = result;
|
||||
expect(overwriteLink.href).toBe("/api/v2/users/rene/permissions");
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
120
scm-ui/ui-webapp/src/registerServiceWorker.js
Normal file
120
scm-ui/ui-webapp/src/registerServiceWorker.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/* eslint-disable no-console */
|
||||
// In production, we register a service worker to serve assets from local cache.
|
||||
|
||||
// This lets the app load faster on subsequent visits in production, and gives
|
||||
// it offline capabilities. However, it also means that developers (and users)
|
||||
// will only see deployed updates on the "N+1" visit to a page, since previously
|
||||
// cached resources are updated in the background.
|
||||
|
||||
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
|
||||
// This link also includes instructions on opting out of this behavior.
|
||||
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === "localhost" ||
|
||||
// [::1] is the IPv6 localhost address.
|
||||
window.location.hostname === "[::1]" ||
|
||||
// 127.0.0.1/8 is considered localhost for IPv4.
|
||||
window.location.hostname.match(
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||
)
|
||||
);
|
||||
|
||||
export default function register() {
|
||||
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||
|
||||
if (isLocalhost) {
|
||||
// This is running on localhost. Lets check if a service worker still exists or not.
|
||||
checkValidServiceWorker(swUrl);
|
||||
|
||||
// Add some additional logging to localhost, pointing developers to the
|
||||
// service worker/PWA documentation.
|
||||
navigator.serviceWorker.ready.then(() => {
|
||||
console.log(
|
||||
"This web app is being served cache-first by a service " +
|
||||
"worker. To learn more, visit https://goo.gl/SC7cgQ"
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Is not local host. Just register service worker
|
||||
registerValidSW(swUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function registerValidSW(swUrl) {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then(registration => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === "installed") {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// At this point, the old content will have been purged and
|
||||
// the fresh content will have been added to the cache.
|
||||
// It's the perfect time to display a "New content is
|
||||
// available; please refresh." message in your web app.
|
||||
console.log("New content is available; please refresh.");
|
||||
} else {
|
||||
// At this point, everything has been precached.
|
||||
// It's the perfect time to display a
|
||||
// "Content is cached for offline use." message.
|
||||
console.log("Content is cached for offline use.");
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error during service worker registration:", error);
|
||||
});
|
||||
}
|
||||
|
||||
function checkValidServiceWorker(swUrl) {
|
||||
// Check if the service worker can be found. If it can't reload the page.
|
||||
fetch(swUrl)
|
||||
.then(response => {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
if (
|
||||
response.status === 404 ||
|
||||
response.headers.get("content-type").indexOf("javascript") === -1
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Service worker found. Proceed as normal.
|
||||
registerValidSW(swUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(
|
||||
"No internet connection found. App is running in offline mode."
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function unregister() {
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-enable no-console */
|
||||
@@ -0,0 +1,45 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { Repository, Branch } from "@scm-manager/ui-types";
|
||||
import { ButtonAddons, Button } from "@scm-manager/ui-components";
|
||||
import { translate } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
repository: Repository,
|
||||
branch: Branch,
|
||||
|
||||
// context props
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class BranchButtonGroup extends React.Component<Props> {
|
||||
render() {
|
||||
const { repository, branch, t } = this.props;
|
||||
|
||||
const changesetLink = `/repo/${repository.namespace}/${
|
||||
repository.name
|
||||
}/branch/${encodeURIComponent(branch.name)}/changesets/`;
|
||||
const sourcesLink = `/repo/${repository.namespace}/${
|
||||
repository.name
|
||||
}/sources/${encodeURIComponent(branch.name)}/`;
|
||||
|
||||
return (
|
||||
<ButtonAddons>
|
||||
<Button
|
||||
link={changesetLink}
|
||||
icon="exchange-alt"
|
||||
label={t("branch.commits")}
|
||||
reducedMobile={true}
|
||||
/>
|
||||
<Button
|
||||
link={sourcesLink}
|
||||
icon="code"
|
||||
label={t("branch.sources")}
|
||||
reducedMobile={true}
|
||||
/>
|
||||
</ButtonAddons>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("repos")(BranchButtonGroup);
|
||||
@@ -0,0 +1,33 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { Repository, Branch } from "@scm-manager/ui-types";
|
||||
import { translate } from "react-i18next";
|
||||
import BranchButtonGroup from "./BranchButtonGroup";
|
||||
import DefaultBranchTag from "./DefaultBranchTag";
|
||||
|
||||
type Props = {
|
||||
repository: Repository,
|
||||
branch: Branch,
|
||||
// context props
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class BranchDetail extends React.Component<Props> {
|
||||
render() {
|
||||
const { repository, branch, t } = this.props;
|
||||
|
||||
return (
|
||||
<div className="media">
|
||||
<div className="media-content subtitle">
|
||||
<strong>{t("branch.name")}</strong> {branch.name}{" "}
|
||||
<DefaultBranchTag defaultBranch={branch.defaultBranch} />
|
||||
</div>
|
||||
<div className="media-right">
|
||||
<BranchButtonGroup repository={repository} branch={branch} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("repos")(BranchDetail);
|
||||
125
scm-ui/ui-webapp/src/repos/branches/components/BranchForm.js
Normal file
125
scm-ui/ui-webapp/src/repos/branches/components/BranchForm.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import type { Repository, Branch, BranchRequest } from "@scm-manager/ui-types";
|
||||
import {
|
||||
Select,
|
||||
InputField,
|
||||
SubmitButton,
|
||||
validation as validator
|
||||
} from "@scm-manager/ui-components";
|
||||
import { orderBranches } from "../util/orderBranches";
|
||||
|
||||
type Props = {
|
||||
submitForm: BranchRequest => void,
|
||||
repository: Repository,
|
||||
branches: Branch[],
|
||||
loading?: boolean,
|
||||
transmittedName?: string,
|
||||
disabled?: boolean,
|
||||
t: string => string
|
||||
};
|
||||
|
||||
type State = {
|
||||
source?: string,
|
||||
name?: string,
|
||||
nameValidationError: boolean
|
||||
};
|
||||
|
||||
class BranchForm extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
nameValidationError: false,
|
||||
name: props.transmittedName
|
||||
};
|
||||
}
|
||||
|
||||
isFalsy(value) {
|
||||
return !value;
|
||||
}
|
||||
|
||||
isValid = () => {
|
||||
const { source, name } = this.state;
|
||||
return !(
|
||||
this.state.nameValidationError ||
|
||||
this.isFalsy(source) ||
|
||||
this.isFalsy(name)
|
||||
);
|
||||
};
|
||||
|
||||
submit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (this.isValid()) {
|
||||
this.props.submitForm({
|
||||
name: this.state.name,
|
||||
parent: this.state.source
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, branches, loading, transmittedName, disabled } = this.props;
|
||||
const { name } = this.state;
|
||||
orderBranches(branches);
|
||||
const options = branches.map(branch => ({
|
||||
label: branch.name,
|
||||
value: branch.name
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={this.submit}>
|
||||
<div className="columns">
|
||||
<div className="column">
|
||||
<Select
|
||||
name="source"
|
||||
label={t("branches.create.source")}
|
||||
options={options}
|
||||
onChange={this.handleSourceChange}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<InputField
|
||||
name="name"
|
||||
label={t("branches.create.name")}
|
||||
onChange={this.handleNameChange}
|
||||
value={name ? name : ""}
|
||||
validationError={this.state.nameValidationError}
|
||||
errorMessage={t("validation.branch.nameInvalid")}
|
||||
disabled={!!transmittedName || disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="columns">
|
||||
<div className="column">
|
||||
<SubmitButton
|
||||
disabled={disabled || !this.isValid()}
|
||||
loading={loading}
|
||||
label={t("branches.create.submit")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
handleSourceChange = (source: string) => {
|
||||
this.setState({
|
||||
...this.state,
|
||||
source
|
||||
});
|
||||
};
|
||||
|
||||
handleNameChange = (name: string) => {
|
||||
this.setState({
|
||||
nameValidationError: !validator.isNameValid(name),
|
||||
...this.state,
|
||||
name
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default translate("repos")(BranchForm);
|
||||
32
scm-ui/ui-webapp/src/repos/branches/components/BranchRow.js
Normal file
32
scm-ui/ui-webapp/src/repos/branches/components/BranchRow.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Branch } from "@scm-manager/ui-types";
|
||||
import DefaultBranchTag from "./DefaultBranchTag";
|
||||
|
||||
type Props = {
|
||||
baseUrl: string,
|
||||
branch: Branch
|
||||
};
|
||||
|
||||
class BranchRow extends React.Component<Props> {
|
||||
renderLink(to: string, label: string, defaultBranch?: boolean) {
|
||||
return (
|
||||
<Link to={to}>
|
||||
{label} <DefaultBranchTag defaultBranch={defaultBranch} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { baseUrl, branch } = this.props;
|
||||
const to = `${baseUrl}/${encodeURIComponent(branch.name)}/info`;
|
||||
return (
|
||||
<tr>
|
||||
<td>{this.renderLink(to, branch.name, branch.defaultBranch)}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default BranchRow;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user