Introduce stale while revalidate pattern (#1555)

This Improves the frontend performance with stale while
revalidate pattern.

There are noticeable performance problems in the frontend that
needed addressing. While implementing the stale-while-revalidate
pattern to display cached responses while re-fetching up-to-date
data in the background, in the same vein we used the opportunity
to remove legacy code involving redux as much as possible,
cleaned up many components and converted them to functional
react components.

Co-authored-by: Sebastian Sdorra <sebastian.sdorra@cloudogu.com>
Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
This commit is contained in:
Konstantin Schaper
2021-02-24 08:17:40 +01:00
committed by GitHub
parent ad5c8102c0
commit 3a8d031ed5
243 changed files with 150259 additions and 80227 deletions

View File

@@ -21,40 +21,39 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import React, { FC, useEffect } from "react";
import PluginActionModal from "./PluginActionModal";
import { PendingPlugins } from "@scm-manager/ui-types";
import { apiClient } from "@scm-manager/ui-components";
import { WithTranslation, withTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import { useCancelPendingPlugins } from "@scm-manager/ui-api";
type Props = WithTranslation & {
type Props = {
onClose: () => void;
refresh: () => void;
pendingPlugins: PendingPlugins;
};
class CancelPendingActionModal extends React.Component<Props> {
render() {
const { onClose, pendingPlugins, t } = this.props;
const CancelPendingActionModal: FC<Props> = ({ onClose, pendingPlugins }) => {
const [t] = useTranslation("admin");
const { error, isLoading, update, isCancelled } = useCancelPendingPlugins();
return (
<PluginActionModal
description={t("plugins.modal.cancelPending")}
label={t("plugins.cancelPending")}
onClose={onClose}
pendingPlugins={pendingPlugins}
execute={this.cancelPending}
/>
);
}
useEffect(() => {
if (isCancelled) {
onClose();
}
}, [onClose, isCancelled]);
cancelPending = () => {
const { pendingPlugins, refresh, onClose } = this.props;
return apiClient
.post(pendingPlugins._links.cancel.href)
.then(refresh)
.then(onClose);
};
}
return (
<PluginActionModal
description={t("plugins.modal.cancelPending")}
label={t("plugins.cancelPending")}
onClose={onClose}
pendingPlugins={pendingPlugins}
success={isCancelled}
loading={isLoading}
error={error}
execute={() => update(pendingPlugins)}
/>
);
};
export default withTranslation("admin")(CancelPendingActionModal);
export default CancelPendingActionModal;

View File

@@ -1,78 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import { PendingPlugins } from "@scm-manager/ui-types";
import { Button } from "@scm-manager/ui-components";
import ExecutePendingModal from "./ExecutePendingModal";
type Props = WithTranslation & {
pendingPlugins: PendingPlugins;
};
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 withTranslation("admin")(ExecutePendingAction);

View File

@@ -21,39 +21,36 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { PendingPlugins } from "@scm-manager/ui-types";
import { apiClient, Notification } from "@scm-manager/ui-components";
import waitForRestart from "./waitForRestart";
import { Notification } from "@scm-manager/ui-components";
import PluginActionModal from "./PluginActionModal";
import { useExecutePendingPlugins } from "@scm-manager/ui-api";
type Props = WithTranslation & {
type Props = {
onClose: () => void;
pendingPlugins: PendingPlugins;
};
class ExecutePendingActionModal extends React.Component<Props> {
render() {
const { onClose, pendingPlugins, t } = this.props;
const ExecutePendingActionModal: FC<Props> = ({ onClose, pendingPlugins }) => {
const [t] = useTranslation("admin");
const { update, isLoading: loading, error, isExecuted } = useExecutePendingPlugins();
return (
<PluginActionModal
description={t("plugins.modal.executePending")}
label={t("plugins.modal.executeAndRestart")}
onClose={onClose}
pendingPlugins={pendingPlugins}
execute={this.executeAndRestart}
>
<Notification type="warning">{t("plugins.modal.restartNotification")}</Notification>
</PluginActionModal>
);
}
return (
<PluginActionModal
description={t("plugins.modal.executePending")}
label={t("plugins.modal.executeAndRestart")}
onClose={onClose}
pendingPlugins={pendingPlugins}
error={error}
loading={loading}
success={isExecuted}
execute={() => update(pendingPlugins)}
>
<Notification type="warning">{t("plugins.modal.restartNotification")}</Notification>
</PluginActionModal>
);
};
executeAndRestart = () => {
const { pendingPlugins } = this.props;
return apiClient.post(pendingPlugins._links.execute.href).then(waitForRestart);
};
}
export default withTranslation("admin")(ExecutePendingActionModal);
export default ExecutePendingActionModal;

View File

@@ -1,135 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { apiClient, Button, ButtonGroup, ErrorNotification, Modal, Notification } from "@scm-manager/ui-components";
import { PendingPlugins } from "@scm-manager/ui-types";
import { WithTranslation, withTranslation } from "react-i18next";
import waitForRestart from "./waitForRestart";
import SuccessNotification from "./SuccessNotification";
import PendingPluginsQueue from "./PendingPluginsQueue";
type Props = WithTranslation & {
onClose: () => void;
pendingPlugins: PendingPlugins;
};
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
});
});
};
renderBody = () => {
const { pendingPlugins, t } = this.props;
return (
<>
<div className="media">
<div className="content">
<p>{t("plugins.modal.executePending")}</p>
<PendingPluginsQueue pendingPlugins={pendingPlugins} />
</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 withTranslation("admin")(ExecutePendingModal);

View File

@@ -29,35 +29,20 @@ import SuccessNotification from "./SuccessNotification";
type Props = WithTranslation & {
onClose: () => void;
actionType: string;
pendingPlugins?: PendingPlugins;
installedPlugins?: PluginCollection;
refresh: () => void;
execute: () => Promise<any>;
execute: () => void;
description: string;
label: string;
loading: boolean;
error?: Error | null;
success: boolean;
children?: React.Node;
};
type State = {
loading: boolean;
success: boolean;
error?: Error;
};
class PluginActionModal extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loading: false,
success: false
};
}
class PluginActionModal extends React.Component<Props> {
renderNotifications = () => {
const { children } = this.props;
const { error, success } = this.state;
const { children, error, success } = this.props;
if (error) {
return <ErrorNotification error={error} />;
} else if (success) {
@@ -67,28 +52,6 @@ class PluginActionModal extends React.Component<Props, State> {
}
};
executeAction = () => {
this.setState({
loading: true
});
this.props
.execute()
.then(() => {
this.setState({
success: true,
loading: false
});
})
.catch(error => {
this.setState({
success: false,
loading: false,
error: error
});
});
};
renderModalContent = () => {
return (
<>
@@ -189,16 +152,15 @@ class PluginActionModal extends React.Component<Props, State> {
};
renderFooter = () => {
const { onClose, t } = this.props;
const { loading, error, success } = this.state;
const { onClose, t, loading, error, success } = this.props;
return (
<ButtonGroup>
<Button
color="warning"
label={this.props.label}
loading={loading}
action={this.executeAction}
disabled={error || success}
action={this.props.execute}
disabled={!!error || success}
/>
<Button label={t("plugins.modal.abort")} action={onClose} />
</ButtonGroup>

View File

@@ -21,29 +21,17 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { Plugin } from "@scm-manager/ui-types";
import { Link, Plugin } from "@scm-manager/ui-types";
import { CardColumn, Icon } from "@scm-manager/ui-components";
import PluginAvatar from "./PluginAvatar";
import PluginModal from "./PluginModal";
import { PluginAction, PluginModalContent } from "../containers/PluginsOverview";
export const PluginAction = {
INSTALL: "install",
UPDATE: "update",
UNINSTALL: "uninstall"
};
type Props = WithTranslation & {
type Props = {
plugin: Plugin;
refresh: () => void;
};
type State = {
showInstallModal: boolean;
showUpdateModal: boolean;
showUninstallModal: boolean;
openModal: (content: PluginModalContent) => void;
};
const ActionbarWrapper = styled.div`
@@ -65,136 +53,48 @@ const IconWrapper = styled.span`
}
`;
class PluginEntry extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
const PluginEntry: FC<Props> = ({ plugin, openModal }) => {
const [t] = useTranslation("admin");
const isInstallable = plugin._links.install && (plugin._links.install as Link).href;
const isUpdatable = plugin._links.update && (plugin._links.update as Link).href;
const isUninstallable = plugin._links.uninstall && (plugin._links.uninstall as Link).href;
this.state = {
showInstallModal: false,
showUpdateModal: false,
showUninstallModal: false
};
}
const pendingSpinner = () => (
<Icon className="fa-spin fa-lg" name="spinner" color={plugin.markedForUninstall ? "danger" : "info"} />
);
const actionBar = () => (
<ActionbarWrapper className="is-flex">
{isInstallable && (
<IconWrapper className="level-item" onClick={() => openModal({ plugin, action: PluginAction.INSTALL })}>
<Icon title={t("plugins.modal.install")} name="download" color="info" />
</IconWrapper>
)}
{isUninstallable && (
<IconWrapper className="level-item" onClick={() => openModal({ plugin, action: PluginAction.UNINSTALL })}>
<Icon title={t("plugins.modal.uninstall")} name="trash" color="info" />
</IconWrapper>
)}
{isUpdatable && (
<IconWrapper className="level-item" onClick={() => openModal({ plugin, action: PluginAction.UPDATE })}>
<Icon title={t("plugins.modal.update")} name="sync-alt" color="info" />
</IconWrapper>
)}
</ActionbarWrapper>
);
createAvatar = (plugin: Plugin) => {
return <PluginAvatar plugin={plugin} />;
};
return (
<>
<CardColumn
action={isInstallable ? () => openModal({ plugin, action: PluginAction.INSTALL }) : undefined}
avatar={<PluginAvatar plugin={plugin} />}
title={plugin.displayName ? <strong>{plugin.displayName}</strong> : <strong>{plugin.name}</strong>}
description={plugin.description}
contentRight={plugin.pending || plugin.markedForUninstall ? pendingSpinner() : actionBar()}
footerLeft={<small>{plugin.version}</small>}
footerRight={<small className="level-item is-block shorten-text">{plugin.author}</small>}
/>
</>
);
};
toggleModal = (showModal: string) => {
const oldValue = this.state[showModal];
this.setState({
[showModal]: !oldValue
});
};
createFooterLeft = (plugin: Plugin) => {
return <small>{plugin.version}</small>;
};
createFooterRight = (plugin: Plugin) => {
return <small className="level-item is-block shorten-text">{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 { t } = this.props;
return (
<ActionbarWrapper className="is-flex">
{this.isInstallable() && (
<IconWrapper className="level-item" onClick={() => this.toggleModal("showInstallModal")}>
<Icon title={t("plugins.modal.install")} name="download" color="info" />
</IconWrapper>
)}
{this.isUninstallable() && (
<IconWrapper className="level-item" onClick={() => this.toggleModal("showUninstallModal")}>
<Icon title={t("plugins.modal.uninstall")} name="trash" color="info" />
</IconWrapper>
)}
{this.isUpdatable() && (
<IconWrapper className="level-item" onClick={() => this.toggleModal("showUpdateModal")}>
<Icon title={t("plugins.modal.update")} name="sync-alt" color="info" />
</IconWrapper>
)}
</ActionbarWrapper>
);
};
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 } = this.props;
return <Icon className="fa-spin fa-lg" name="spinner" color={plugin.markedForUninstall ? "danger" : "info"} />;
};
render() {
const { plugin } = this.props;
const avatar = this.createAvatar(plugin);
const actionbar = this.createActionbar();
const footerLeft = this.createFooterLeft(plugin);
const footerRight = this.createFooterRight(plugin);
const modal = this.renderModal();
return (
<>
<CardColumn
action={this.isInstallable() ? () => this.toggleModal("showInstallModal") : null}
avatar={avatar}
title={plugin.displayName ? <strong>{plugin.displayName}</strong> : <strong>{plugin.name}</strong>}
description={plugin.description}
contentRight={plugin.pending || plugin.markedForUninstall ? this.createPendingSpinner() : actionbar}
footerLeft={footerLeft}
footerRight={footerRight}
/>
{modal}
</>
);
}
}
export default withTranslation("admin")(PluginEntry);
export default PluginEntry;

View File

@@ -21,24 +21,22 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import React, { FC } from "react";
import { CardColumnGroup } from "@scm-manager/ui-components";
import { PluginGroup } from "@scm-manager/ui-types";
import PluginEntry from "./PluginEntry";
import { PluginModalContent } from "../containers/PluginsOverview";
type Props = {
group: PluginGroup;
refresh: () => void;
openModal: (content: PluginModalContent) => 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} />;
}
}
const PluginGroupEntry: FC<Props> = ({ openModal, group }) => {
const entries = group.plugins.map(plugin => {
return <PluginEntry plugin={plugin} openModal={openModal} key={plugin.name} />;
});
return <CardColumnGroup name={group.name} elements={entries} />;
};
export default PluginGroupEntry;

View File

@@ -21,29 +21,26 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import React, { FC } from "react";
import { Plugin } from "@scm-manager/ui-types";
import PluginGroupEntry from "../components/PluginGroupEntry";
import groupByCategory from "./groupByCategory";
import { PluginModalContent } from "../containers/PluginsOverview";
type Props = {
plugins: Plugin[];
refresh: () => void;
openModal: (content: PluginModalContent) => 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>
);
}
}
const PluginList: FC<Props> = ({ plugins, openModal }) => {
const groups = groupByCategory(plugins);
return (
<div className="content is-plugin-page">
{groups.map(group => {
return <PluginGroupEntry group={group} openModal={openModal} key={group.name} />;
})}
</div>
);
};
export default PluginList;

View File

@@ -21,38 +21,22 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import styled from "styled-components";
import { Plugin } from "@scm-manager/ui-types";
import {
apiClient,
Button,
ButtonGroup,
Checkbox,
ErrorNotification,
Modal,
Notification
} from "@scm-manager/ui-components";
import waitForRestart from "./waitForRestart";
import { Button, ButtonGroup, Checkbox, ErrorNotification, Modal, Notification } from "@scm-manager/ui-components";
import SuccessNotification from "./SuccessNotification";
import { PluginAction } from "./PluginEntry";
import { useInstallPlugin, useUninstallPlugin, useUpdatePlugins } from "@scm-manager/ui-api";
import { PluginAction } from "../containers/PluginsOverview";
type Props = WithTranslation & {
type Props = {
plugin: Plugin;
pluginAction: string;
refresh: () => void;
onClose: () => void;
};
type State = {
success: boolean;
restart: boolean;
loading: boolean;
error?: Error;
};
const ListParent = styled.div`
margin-right: 0;
min-width: ${props => (props.pluginAction === PluginAction.INSTALL ? "5.5em" : "10em")};
@@ -63,88 +47,43 @@ const ListChild = styled.div`
flex-grow: 4;
`;
class PluginModal extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loading: false,
restart: false,
success: false
};
}
const PluginModal: FC<Props> = ({ onClose, pluginAction, plugin }) => {
const [t] = useTranslation("admin");
const [shouldRestart, setShouldRestart] = useState<boolean>(false);
const { isLoading: isInstalling, error: installError, install, isInstalled } = useInstallPlugin();
const { isLoading: isUninstalling, error: uninstallError, uninstall, isUninstalled } = useUninstallPlugin();
const { isLoading: isUpdating, error: updateError, update, isUpdated } = useUpdatePlugins();
const error = installError || uninstallError || updateError;
const loading = isInstalling || isUninstalling || isUpdating;
const isDone = isInstalled || isUninstalled || isUpdated;
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();
});
useEffect(() => {
if (isDone && !shouldRestart) {
onClose();
}
};
}, [isDone]);
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
});
const handlePluginAction = (e: Event) => {
e.preventDefault();
apiClient
.post(this.createPluginActionLink())
.then(this.onSuccess)
.catch(error => {
this.setState({
loading: false,
success: false,
error: error
});
});
switch (pluginAction) {
case PluginAction.INSTALL:
install(plugin, { restart: shouldRestart });
break;
case PluginAction.UNINSTALL:
uninstall(plugin, { restart: shouldRestart });
break;
case PluginAction.UPDATE:
update(plugin, { restart: shouldRestart });
break;
default:
throw new Error(`Unkown plugin action ${pluginAction}`);
}
};
footer = () => {
const { pluginAction, onClose, t } = this.props;
const { loading, error, restart, success } = this.state;
const footer = () => {
let color = pluginAction === PluginAction.UNINSTALL ? "warning" : "primary";
let label = `plugins.modal.${pluginAction}`;
if (restart) {
if (shouldRestart) {
color = "warning";
label = `plugins.modal.${pluginAction}AndRestart`;
}
@@ -153,18 +92,16 @@ class PluginModal extends React.Component<Props, State> {
<Button
label={t(label)}
color={color}
action={this.handlePluginAction}
action={handlePluginAction}
loading={loading}
disabled={!!error || success}
disabled={!!error || isDone}
/>
<Button label={t("plugins.modal.abort")} action={onClose} />
</ButtonGroup>
);
};
renderDependencies() {
const { plugin, t } = this.props;
const renderDependencies = () => {
let dependencies = null;
if (plugin.dependencies && plugin.dependencies.length > 0) {
dependencies = (
@@ -181,11 +118,9 @@ class PluginModal extends React.Component<Props, State> {
);
}
return dependencies;
}
renderOptionalDependencies() {
const { plugin, t } = this.props;
};
const renderOptionalDependencies = () => {
let optionalDependencies = null;
if (plugin.optionalDependencies && plugin.optionalDependencies.length > 0) {
optionalDependencies = (
@@ -202,24 +137,22 @@ class PluginModal extends React.Component<Props, State> {
);
}
return optionalDependencies;
}
};
renderNotifications = () => {
const { t, pluginAction } = this.props;
const { restart, error, success } = this.state;
const renderNotifications = () => {
if (error) {
return (
<div className="media">
<ErrorNotification error={error} />
</div>
);
} else if (success) {
} else if (isDone && shouldRestart) {
return (
<div className="media">
<SuccessNotification pluginAction={pluginAction} />
</div>
);
} else if (restart) {
} else if (shouldRestart) {
return (
<div className="media">
<Notification type="warning">{t("plugins.modal.restartNotification")}</Notification>
@@ -229,22 +162,13 @@ class PluginModal extends React.Component<Props, State> {
return null;
};
handleRestartChange = (value: boolean) => {
this.setState({
restart: value
});
};
createRestartSectionContent = () => {
const { restart } = this.state;
const { plugin, pluginAction, t } = this.props;
const createRestartSectionContent = () => {
if (plugin._links[pluginAction + "WithRestart"]) {
return (
<Checkbox
checked={restart}
checked={shouldRestart}
label={t("plugins.modal.restart")}
onChange={this.handleRestartChange}
onChange={setShouldRestart}
disabled={false}
/>
);
@@ -253,71 +177,67 @@ class PluginModal extends React.Component<Props, State> {
}
};
render() {
const { plugin, pluginAction, onClose, t } = this.props;
const body = (
<>
<div className="media">
<div className="media-content">
<p>{plugin.description}</p>
</div>
const body = (
<>
<div className="media">
<div className="media-content">
<p>{plugin.description}</p>
</div>
<div className="media">
<div className="media-content">
</div>
<div className="media">
<div className="media-content">
<div className="field is-horizontal">
<ListParent className={classNames("field-label", "is-inline-flex")} pluginAction={pluginAction}>
{t("plugins.modal.author")}:
</ListParent>
<ListChild className={classNames("field-body", "is-inline-flex")}>{plugin.author}</ListChild>
</div>
{pluginAction === PluginAction.INSTALL && (
<div className="field is-horizontal">
<ListParent className={classNames("field-label", "is-inline-flex")} pluginAction={pluginAction}>
{t("plugins.modal.author")}:
{t("plugins.modal.version")}:
</ListParent>
<ListChild className={classNames("field-body", "is-inline-flex")}>{plugin.author}</ListChild>
<ListChild className={classNames("field-body", "is-inline-flex")}>{plugin.version}</ListChild>
</div>
{pluginAction === PluginAction.INSTALL && (
<div className="field is-horizontal">
<ListParent className={classNames("field-label", "is-inline-flex")} pluginAction={pluginAction}>
{t("plugins.modal.version")}:
</ListParent>
<ListChild className={classNames("field-body", "is-inline-flex")}>{plugin.version}</ListChild>
</div>
)}
{(pluginAction === PluginAction.UPDATE || pluginAction === PluginAction.UNINSTALL) && (
<div className="field is-horizontal">
<ListParent className={classNames("field-label", "is-inline-flex")}>
{t("plugins.modal.currentVersion")}:
</ListParent>
<ListChild className={classNames("field-body", "is-inline-flex")}>{plugin.version}</ListChild>
</div>
)}
{pluginAction === PluginAction.UPDATE && (
<div className="field is-horizontal">
<ListParent className={classNames("field-label", "is-inline-flex")}>
{t("plugins.modal.newVersion")}:
</ListParent>
<ListChild className={classNames("field-body", "is-inline-flex")}>{plugin.newVersion}</ListChild>
</div>
)}
{this.renderDependencies()}
{this.renderOptionalDependencies()}
</div>
)}
{(pluginAction === PluginAction.UPDATE || pluginAction === PluginAction.UNINSTALL) && (
<div className="field is-horizontal">
<ListParent className={classNames("field-label", "is-inline-flex")}>
{t("plugins.modal.currentVersion")}:
</ListParent>
<ListChild className={classNames("field-body", "is-inline-flex")}>{plugin.version}</ListChild>
</div>
)}
{pluginAction === PluginAction.UPDATE && (
<div className="field is-horizontal">
<ListParent className={classNames("field-label", "is-inline-flex")}>
{t("plugins.modal.newVersion")}:
</ListParent>
<ListChild className={classNames("field-body", "is-inline-flex")}>{plugin.newVersion}</ListChild>
</div>
)}
{renderDependencies()}
{renderOptionalDependencies()}
</div>
<div className="media">
<div className="media-content">{this.createRestartSectionContent()}</div>
</div>
{this.renderNotifications()}
</>
);
</div>
<div className="media">
<div className="media-content">{createRestartSectionContent()}</div>
</div>
{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}
/>
);
}
}
return (
<Modal
title={t(`plugins.modal.title.${pluginAction}`, {
name: plugin.displayName ? plugin.displayName : plugin.name
})}
closeFunction={onClose}
body={body}
footer={footer()}
active={true}
/>
);
};
export default withTranslation("admin")(PluginModal);
export default PluginModal;

View File

@@ -24,7 +24,7 @@
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import { Notification } from "@scm-manager/ui-components";
import { PluginAction } from "./PluginEntry";
import { PluginAction } from "../containers/PluginsOverview";
type Props = WithTranslation & {
pluginAction?: string;
@@ -48,7 +48,7 @@ class InstallSuccessNotification extends React.Component<Props> {
return (
<Notification type="success">
{this.createMessageForPluginAction()}{" "}
<a onClick={e => window.location.reload(true)}>{t("plugins.modal.reload")}</a>
<a onClick={_ => window.location.reload(true)}>{t("plugins.modal.reload")}</a>
</Notification>
);
}

View File

@@ -21,40 +21,38 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { PluginCollection } from "@scm-manager/ui-types";
import { apiClient } from "@scm-manager/ui-components";
import PluginActionModal from "./PluginActionModal";
import { useUpdatePlugins } from "@scm-manager/ui-api";
type Props = WithTranslation & {
type Props = {
onClose: () => void;
refresh: () => void;
installedPlugins: PluginCollection;
};
class UpdateAllActionModal extends React.Component<Props> {
render() {
const { onClose, installedPlugins, t } = this.props;
const UpdateAllActionModal: FC<Props> = ({ installedPlugins, onClose }) => {
const [t] = useTranslation("admin");
const { update, isLoading, error, isUpdated } = useUpdatePlugins();
return (
<PluginActionModal
description={t("plugins.modal.updateAll")}
label={t("plugins.updateAll")}
onClose={onClose}
installedPlugins={installedPlugins}
execute={this.updateAll}
/>
);
}
useEffect(() => {
if (isUpdated) {
onClose();
}
}, [onClose, isUpdated]);
updateAll = () => {
const { installedPlugins, refresh, onClose } = this.props;
return apiClient
.post(installedPlugins._links.update.href)
.then(refresh)
.then(onClose);
};
}
export default withTranslation("admin")(UpdateAllActionModal);
return (
<PluginActionModal
description={t("plugins.modal.updateAll")}
label={t("plugins.updateAll")}
onClose={onClose}
installedPlugins={installedPlugins}
error={error}
loading={isLoading}
success={isUpdated}
execute={() => update(installedPlugins)}
/>
);
};
export default UpdateAllActionModal;

View File

@@ -1,53 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { apiClient } from "@scm-manager/ui-components";
const waitForRestart = () => {
const endTime = Number(new Date()) + 60000;
let started = false;
const executor = (resolve, reject) => {
// we need some initial delay
if (!started) {
started = true;
setTimeout(executor, 1000, resolve, reject);
} else {
apiClient
.get("")
.then(resolve)
.catch(() => {
if (Number(new Date()) < endTime) {
setTimeout(executor, 500, resolve, reject);
} else {
reject(new Error("timeout reached"));
}
});
}
};
return new Promise<void>(executor);
};
export default waitForRestart;

View File

@@ -22,10 +22,9 @@
* SOFTWARE.
*/
import * as React from "react";
import { connect } from "react-redux";
import { WithTranslation, withTranslation } from "react-i18next";
import { compose } from "redux";
import { PendingPlugins, Plugin, PluginCollection } from "@scm-manager/ui-types";
import { FC, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plugin } from "@scm-manager/ui-types";
import {
Button,
ButtonGroup,
@@ -35,89 +34,54 @@ import {
Subtitle,
Title
} from "@scm-manager/ui-components";
import {
fetchPendingPlugins,
fetchPluginsByLink,
getFetchPluginsFailure,
getPendingPlugins,
getPluginCollection,
isFetchPluginsPending
} from "../modules/plugins";
import PluginsList from "../components/PluginList";
import {
getPendingPluginsLink,
mustGetAvailablePluginsLink,
mustGetInstalledPluginsLink
} from "../../../modules/indexResource";
import PluginTopActions from "../components/PluginTopActions";
import PluginBottomActions from "../components/PluginBottomActions";
import ExecutePendingActionModal from "../components/ExecutePendingActionModal";
import CancelPendingActionModal from "../components/CancelPendingActionModal";
import UpdateAllActionModal from "../components/UpdateAllActionModal";
import ShowPendingModal from "../components/ShowPendingModal";
import { useAvailablePlugins, useInstalledPlugins, usePendingPlugins } from "@scm-manager/ui-api";
import PluginModal from "../components/PluginModal";
type Props = WithTranslation & {
loading: boolean;
error: Error;
collection: PluginCollection;
baseUrl: string;
export enum PluginAction {
INSTALL = "install",
UPDATE = "update",
UNINSTALL = "uninstall"
}
export type PluginModalContent = {
plugin: Plugin;
action: PluginAction;
}
type Props = {
installed: boolean;
availablePluginsLink: string;
installedPluginsLink: string;
pendingPluginsLink: string;
pendingPlugins: PendingPlugins;
// dispatched functions
fetchPluginsByLink: (link: string) => void;
fetchPendingPlugins: (link: string) => void;
};
type State = {
showPendingModal: boolean;
showExecutePendingModal: boolean;
showUpdateAllModal: boolean;
showCancelModal: boolean;
};
const PluginsOverview: FC<Props> = ({ installed }) => {
const [t] = useTranslation("admin");
const {
data: availablePlugins,
isLoading: isLoadingAvailablePlugins,
error: availablePluginsError
} = useAvailablePlugins({ enabled: !installed });
const {
data: installedPlugins,
isLoading: isLoadingInstalledPlugins,
error: installedPluginsError
} = useInstalledPlugins({ enabled: installed });
const { data: pendingPlugins, isLoading: isLoadingPendingPlugins, error: pendingPluginsError } = usePendingPlugins();
const [showPendingModal, setShowPendingModal] = useState(false);
const [showExecutePendingModal, setShowExecutePendingModal] = useState(false);
const [showUpdateAllModal, setShowUpdateAllModal] = useState(false);
const [showCancelModal, setShowCancelModal] = useState(false);
const [pluginModalContent, setPluginModalContent] = useState<PluginModalContent | null>(null);
const collection = installed ? installedPlugins : availablePlugins;
const error = (installed ? installedPluginsError : availablePluginsError) || pendingPluginsError;
const loading = (installed ? isLoadingInstalledPlugins : isLoadingAvailablePlugins) || isLoadingPendingPlugins;
class PluginsOverview extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
showPendingModal: false,
showExecutePendingModal: false,
showUpdateAllModal: false,
showCancelModal: false
};
}
componentDidMount() {
this.fetchPlugins();
}
componentDidUpdate(prevProps: Props) {
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.ReactNode) => {
const { installed, t } = this.props;
const renderHeader = (actions: React.ReactNode) => {
return (
<div className="columns">
<div className="column">
@@ -129,15 +93,14 @@ class PluginsOverview extends React.Component<Props, State> {
);
};
renderFooter = (actions: React.ReactNode) => {
const renderFooter = (actions: React.ReactNode) => {
if (actions) {
return <PluginBottomActions>{actions}</PluginBottomActions>;
}
return null;
};
createActions = () => {
const { pendingPlugins, collection, t } = this.props;
const createActions = () => {
const buttons = [];
if (pendingPlugins && pendingPlugins._links) {
@@ -149,11 +112,7 @@ class PluginsOverview extends React.Component<Props, State> {
key={"executePending"}
icon={"arrow-circle-right"}
label={t("plugins.executePending")}
action={() =>
this.setState({
showExecutePendingModal: true
})
}
action={() => setShowExecutePendingModal(true)}
/>
);
}
@@ -167,11 +126,7 @@ class PluginsOverview extends React.Component<Props, State> {
key={"showPending"}
icon={"info"}
label={t("plugins.showPending")}
action={() =>
this.setState({
showPendingModal: true
})
}
action={() => setShowPendingModal(true)}
/>
);
}
@@ -183,11 +138,7 @@ class PluginsOverview extends React.Component<Props, State> {
key={"cancelPending"}
icon={"times"}
label={t("plugins.cancelPending")}
action={() =>
this.setState({
showCancelModal: true
})
}
action={() => setShowCancelModal(true)}
/>
);
}
@@ -200,12 +151,8 @@ class PluginsOverview extends React.Component<Props, State> {
reducedMobile={true}
key={"updateAll"}
icon={"sync-alt"}
label={this.computeUpdateAllSize()}
action={() =>
this.setState({
showUpdateAllModal: true
})
}
label={computeUpdateAllSize()}
action={() => setShowUpdateAllModal(true)}
/>
);
}
@@ -216,133 +163,74 @@ class PluginsOverview extends React.Component<Props, State> {
return null;
};
computeUpdateAllSize = () => {
const { collection, t } = this.props;
const outdatedPlugins = collection._embedded.plugins.filter((p: Plugin) => p._links.update).length;
const computeUpdateAllSize = () => {
const outdatedPlugins = collection?._embedded.plugins.filter((p: Plugin) => p._links.update).length;
return t("plugins.outdatedPlugins", {
count: outdatedPlugins
});
};
render() {
const { loading, error, collection } = this.props;
if (error) {
return <ErrorNotification error={error} />;
const renderPluginsList = () => {
if (collection?._embedded && collection._embedded.plugins.length > 0) {
return <PluginsList plugins={collection._embedded.plugins} openModal={setPluginModalContent} />;
}
return <Notification type="info">{t("plugins.noPlugins")}</Notification>;
};
if (!collection || loading) {
return <Loading />;
const renderModals = () => {
if (showPendingModal && pendingPlugins) {
return <ShowPendingModal onClose={() => setShowPendingModal(false)} pendingPlugins={pendingPlugins} />;
}
const actions = this.createActions();
return (
<>
{this.renderHeader(actions)}
<hr className="header-with-actions" />
{this.renderPluginsList()}
{this.renderFooter(actions)}
{this.renderModals()}
</>
);
}
renderModals = () => {
const { collection, pendingPlugins } = this.props;
const { showPendingModal, showExecutePendingModal, showCancelModal, showUpdateAllModal } = this.state;
if (showPendingModal) {
if (showExecutePendingModal && pendingPlugins) {
return (
<ShowPendingModal
onClose={() =>
this.setState({
showPendingModal: false
})
}
pendingPlugins={pendingPlugins}
/>
<ExecutePendingActionModal onClose={() => setShowExecutePendingModal(false)} pendingPlugins={pendingPlugins} />
);
}
if (showExecutePendingModal) {
return (
<ExecutePendingActionModal
onClose={() =>
this.setState({
showExecutePendingModal: false
})
}
pendingPlugins={pendingPlugins}
/>
);
}
if (showCancelModal) {
if (showCancelModal && pendingPlugins) {
return (
<CancelPendingActionModal
onClose={() =>
this.setState({
showCancelModal: false
})
}
refresh={this.fetchPlugins}
onClose={() => setShowCancelModal(false)}
pendingPlugins={pendingPlugins}
/>
);
}
if (showUpdateAllModal) {
if (showUpdateAllModal && collection) {
return (
<UpdateAllActionModal
onClose={() =>
this.setState({
showUpdateAllModal: false
})
}
refresh={this.fetchPlugins}
onClose={() => setShowUpdateAllModal(false)}
installedPlugins={collection}
/>
);
}
if (pluginModalContent) {
const { action, plugin } = pluginModalContent;
return <PluginModal
plugin={plugin}
pluginAction={action}
onClose={() => setPluginModalContent(null)}
/>
}
return null;
};
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>;
if (error) {
return <ErrorNotification error={error} />;
}
}
const mapStateToProps = (state: any) => {
const collection = getPluginCollection(state);
const loading = isFetchPluginsPending(state);
const error = getFetchPluginsFailure(state);
const availablePluginsLink = mustGetAvailablePluginsLink(state);
const installedPluginsLink = mustGetInstalledPluginsLink(state);
const pendingPluginsLink = getPendingPluginsLink(state);
const pendingPlugins = getPendingPlugins(state);
if (!collection || loading) {
return <Loading />;
}
return {
collection,
loading,
error,
availablePluginsLink,
installedPluginsLink,
pendingPluginsLink,
pendingPlugins
};
const actions = createActions();
return (
<>
{renderHeader(actions)}
<hr className="header-with-actions" />
{renderPluginsList()}
{renderFooter(actions)}
{renderModals()}
</>
);
};
const mapDispatchToProps = (dispatch: any) => {
return {
fetchPluginsByLink: (link: string) => {
dispatch(fetchPluginsByLink(link));
},
fetchPendingPlugins: (link: string) => {
dispatch(fetchPendingPlugins(link));
}
};
};
export default compose(withTranslation("admin"), connect(mapStateToProps, mapDispatchToProps))(PluginsOverview);
export default PluginsOverview;

View File

@@ -1,363 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
FETCH_PLUGIN,
FETCH_PLUGIN_FAILURE,
FETCH_PLUGIN_PENDING,
FETCH_PLUGIN_SUCCESS,
FETCH_PLUGINS,
FETCH_PLUGINS_FAILURE,
FETCH_PLUGINS_PENDING,
FETCH_PLUGINS_SUCCESS,
fetchPluginByLink,
fetchPluginByName,
fetchPluginsByLink,
fetchPluginsSuccess,
fetchPluginSuccess,
getFetchPluginFailure,
getFetchPluginsFailure,
getPlugin,
getPluginCollection,
isFetchPluginPending,
isFetchPluginsPending
} from "./plugins";
import { Plugin, PluginCollection } from "@scm-manager/ui-types";
const groupManagerPlugin: Plugin = {
name: "scm-groupmanager-plugin",
bundles: ["/scm/groupmanager-plugin.bundle.js"],
type: "Administration",
version: "2.0.0-SNAPSHOT",
author: "Sebastian Sdorra",
description: "Notify a remote webserver whenever a plugin is pushed to.",
_links: {
self: {
href: "http://localhost:8081/api/v2/ui/plugins/scm-groupmanager-plugin"
}
}
};
const scriptPlugin: Plugin = {
name: "scm-script-plugin",
bundles: ["/scm/script-plugin.bundle.js"],
type: "Miscellaneous",
version: "2.0.0-SNAPSHOT",
author: "Sebastian Sdorra",
description: "Script support for scm-manager.",
_links: {
self: {
href: "http://localhost:8081/api/v2/ui/plugins/scm-script-plugin"
}
}
};
const branchwpPlugin: Plugin = {
name: "scm-branchwp-plugin",
bundles: ["/scm/branchwp-plugin.bundle.js"],
type: "Miscellaneous",
version: "2.0.0-SNAPSHOT",
author: "Sebastian Sdorra",
description: "This plugin adds branch write protection for plugins.",
_links: {
self: {
href: "http://localhost:8081/api/v2/ui/plugins/scm-branchwp-plugin"
}
}
};
const pluginCollectionWithNames: PluginCollection = {
_links: {
self: {
href: "http://localhost:8081/api/v2/ui/plugins"
}
},
_embedded: {
plugins: [groupManagerPlugin.name, scriptPlugin.name, branchwpPlugin.name]
}
};
const pluginCollection: PluginCollection = {
_links: {
self: {
href: "http://localhost:8081/api/v2/ui/plugins"
}
},
_embedded: {
plugins: [groupManagerPlugin, scriptPlugin, branchwpPlugin]
}
};
describe("plugins fetch", () => {
const URL = "ui/plugins";
const PLUGINS_URL = "/api/v2/ui/plugins";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should successfully fetch plugins from link", () => {
fetchMock.getOnce(PLUGINS_URL, pluginCollection);
const expectedActions = [
{
type: FETCH_PLUGINS_PENDING
},
{
type: FETCH_PLUGINS_SUCCESS,
payload: pluginCollection
}
];
const store = mockStore({});
return store.dispatch(fetchPluginsByLink(URL)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_PLUGINS_FAILURE if request fails", () => {
fetchMock.getOnce(PLUGINS_URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchPluginsByLink(URL)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_PLUGINS_PENDING);
expect(actions[1].type).toEqual(FETCH_PLUGINS_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should successfully fetch scm-groupmanager-plugin by name", () => {
fetchMock.getOnce(PLUGINS_URL + "/scm-groupmanager-plugin", groupManagerPlugin);
const expectedActions = [
{
type: FETCH_PLUGIN_PENDING,
payload: {
name: "scm-groupmanager-plugin"
},
itemId: "scm-groupmanager-plugin"
},
{
type: FETCH_PLUGIN_SUCCESS,
payload: groupManagerPlugin,
itemId: "scm-groupmanager-plugin"
}
];
const store = mockStore({});
return store.dispatch(fetchPluginByName(URL, "scm-groupmanager-plugin")).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_PLUGIN_FAILURE, if the request for scm-groupmanager-plugin by name fails", () => {
fetchMock.getOnce(PLUGINS_URL + "/scm-groupmanager-plugin", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchPluginByName(URL, "scm-groupmanager-plugin")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_PLUGIN_PENDING);
expect(actions[1].type).toEqual(FETCH_PLUGIN_FAILURE);
expect(actions[1].payload.name).toBe("scm-groupmanager-plugin");
expect(actions[1].payload.error).toBeDefined();
expect(actions[1].itemId).toBe("scm-groupmanager-plugin");
});
});
it("should successfully fetch scm-groupmanager-plugin", () => {
fetchMock.getOnce("http://localhost:8081/api/v2/ui/plugins/scm-groupmanager-plugin", groupManagerPlugin);
const expectedActions = [
{
type: FETCH_PLUGIN_PENDING,
payload: {
name: "scm-groupmanager-plugin"
},
itemId: "scm-groupmanager-plugin"
},
{
type: FETCH_PLUGIN_SUCCESS,
payload: groupManagerPlugin,
itemId: "scm-groupmanager-plugin"
}
];
const store = mockStore({});
return store.dispatch(fetchPluginByLink(groupManagerPlugin)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_PLUGIN_FAILURE, it the request for scm-groupmanager-plugin fails", () => {
fetchMock.getOnce("http://localhost:8081/api/v2/ui/plugins/scm-groupmanager-plugin", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchPluginByLink(groupManagerPlugin)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_PLUGIN_PENDING);
expect(actions[1].type).toEqual(FETCH_PLUGIN_FAILURE);
expect(actions[1].payload.name).toBe("scm-groupmanager-plugin");
expect(actions[1].payload.error).toBeDefined();
expect(actions[1].itemId).toBe("scm-groupmanager-plugin");
});
});
});
describe("plugins reducer", () => {
it("should return empty object, if state and action is undefined", () => {
expect(reducer()).toEqual({});
});
it("should return the same state, if the action is undefined", () => {
const state = {
x: true
};
expect(reducer(state)).toBe(state);
});
it("should return the same state, if the action is unknown to the reducer", () => {
const state = {
x: true
};
expect(
reducer(state, {
type: "EL_SPECIALE"
})
).toBe(state);
});
it("should store the plugins by it's type and name on FETCH_PLUGINS_SUCCESS", () => {
const newState = reducer({}, fetchPluginsSuccess(pluginCollection));
expect(newState.list._embedded.plugins).toEqual([
"scm-groupmanager-plugin",
"scm-script-plugin",
"scm-branchwp-plugin"
]);
expect(newState.byNames["scm-groupmanager-plugin"]).toBe(groupManagerPlugin);
expect(newState.byNames["scm-script-plugin"]).toBe(scriptPlugin);
expect(newState.byNames["scm-branchwp-plugin"]).toBe(branchwpPlugin);
});
it("should store the plugin at byNames", () => {
const newState = reducer({}, fetchPluginSuccess(groupManagerPlugin));
expect(newState.byNames["scm-groupmanager-plugin"]).toBe(groupManagerPlugin);
});
});
describe("plugins selectors", () => {
const error = new Error("something went wrong");
it("should return the plugins collection", () => {
const state = {
plugins: {
list: pluginCollectionWithNames,
byNames: {
"scm-groupmanager-plugin": groupManagerPlugin,
"scm-script-plugin": scriptPlugin,
"scm-branchwp-plugin": branchwpPlugin
}
}
};
const collection = getPluginCollection(state);
expect(collection).toEqual(pluginCollection);
});
it("should return true, when fetch plugins is pending", () => {
const state = {
pending: {
[FETCH_PLUGINS]: true
}
};
expect(isFetchPluginsPending(state)).toEqual(true);
});
it("should return false, when fetch plugins is not pending", () => {
expect(isFetchPluginsPending({})).toEqual(false);
});
it("should return error when fetch plugins did fail", () => {
const state = {
failure: {
[FETCH_PLUGINS]: error
}
};
expect(getFetchPluginsFailure(state)).toEqual(error);
});
it("should return undefined when fetch plugins did not fail", () => {
expect(getFetchPluginsFailure({})).toBe(undefined);
});
it("should return the plugin collection", () => {
const state = {
plugins: {
byNames: {
"scm-groupmanager-plugin": groupManagerPlugin
}
}
};
const plugin = getPlugin(state, "scm-groupmanager-plugin");
expect(plugin).toEqual(groupManagerPlugin);
});
it("should return true, when fetch plugin is pending", () => {
const state = {
pending: {
[FETCH_PLUGIN + "/scm-groupmanager-plugin"]: true
}
};
expect(isFetchPluginPending(state, "scm-groupmanager-plugin")).toEqual(true);
});
it("should return false, when fetch plugin is not pending", () => {
expect(isFetchPluginPending({}, "scm-groupmanager-plugin")).toEqual(false);
});
it("should return error when fetch plugin did fail", () => {
const state = {
failure: {
[FETCH_PLUGIN + "/scm-groupmanager-plugin"]: error
}
};
expect(getFetchPluginFailure(state, "scm-groupmanager-plugin")).toEqual(error);
});
it("should return undefined when fetch plugin did not fail", () => {
expect(getFetchPluginFailure({}, "scm-groupmanager-plugin")).toBe(undefined);
});
});

View File

@@ -1,277 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import * as types from "../../../modules/types";
import { isPending } from "../../../modules/pending";
import { getFailure } from "../../../modules/failure";
import { 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 (const 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);
}