implemented ui for pending plugin installation

This commit is contained in:
Sebastian Sdorra
2019-08-21 14:54:01 +02:00
parent 9514a94492
commit 05967aca4a
14 changed files with 394 additions and 33 deletions

View File

@@ -9,6 +9,7 @@ export type Plugin = {
author: string,
category: string,
avatarUrl: string,
pending: boolean,
dependencies: string[],
_links: Links
};

View File

@@ -29,6 +29,7 @@
"installedNavLink": "Installiert",
"availableNavLink": "Verfügbar"
},
"installPending": "Austehende Plugins installieren",
"noPlugins": "Keine Plugins gefunden.",
"modal": {
"title": "{{name}} Plugin installieren",
@@ -42,7 +43,8 @@
"dependencies": "Abhängigkeiten",
"successNotification": "Das Plugin wurde erfolgreich installiert. Um Änderungen an der UI zu sehen, muss die Seite neu geladen werden:",
"reload": "jetzt new laden",
"restartNotification": "Der SCM-Manager Kontext sollte nur neu gestartet werden, wenn aktuell niemand damit arbeitet."
"restartNotification": "Der SCM-Manager Kontext sollte nur neu gestartet werden, wenn aktuell niemand damit arbeitet.",
"installPending": "Die folgenden Plugins werden installiert und anschließend wir der SCM-Manager Kontext neu gestartet."
}
},
"repositoryRole": {

View File

@@ -29,6 +29,7 @@
"installedNavLink": "Installed",
"availableNavLink": "Available"
},
"installPending": "Install pending plugins",
"noPlugins": "No plugins found.",
"modal": {
"title": "Install {{name}} Plugin",
@@ -42,7 +43,8 @@
"dependencies": "Dependencies",
"successNotification": "Successful installed plugin. You have to reload the page, to see ui changes:",
"reload": "reload now",
"restartNotification": "Restarting the scm-manager context, should only be done if no one else is currently working with it."
"restartNotification": "Restarting the scm-manager context, should only be done if no one else is currently working with it.",
"installPending": "The following plugins will be installed and after installation the scm-manager context will be restarted."
}
},
"repositoryRole": {

View File

@@ -0,0 +1,68 @@
// @flow
import React from "react";
import { Button, ButtonGroup, Modal } from "@scm-manager/ui-components";
import type { PluginCollection } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
import InstallPendingModal from "./InstallPendingModal";
type Props = {
collection: PluginCollection,
// context props
t: string => string
};
type State = {
showModal: boolean
};
class InstallPendingAction 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 { collection } = this.props;
if (showModal) {
return (
<InstallPendingModal
collection={collection}
onClose={this.closeModal}
/>
);
}
return null;
};
render() {
const { t } = this.props;
return (
<>
{this.renderModal()}
<Button
color="primary"
label={t("plugins.installPending")}
action={this.openModal}
/>
</>
);
}
}
export default translate("admin")(InstallPendingAction);

View File

@@ -0,0 +1,136 @@
// @flow
import React from "react";
import {
apiClient,
Button,
ButtonGroup,
ErrorNotification,
Modal,
Notification
} from "@scm-manager/ui-components";
import type { PluginCollection } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
type Props = {
onClose: () => void,
collection: PluginCollection,
// context props
t: string => string
};
type State = {
loading: boolean,
success: boolean,
error?: Error
};
class InstallPendingModal 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 (
<Notification type="success">
{t("plugins.modal.successNotification")}{" "}
<a onClick={e => window.location.reload()}>
{t("plugins.modal.reload")}
</a>
</Notification>
);
} else {
return (
<Notification type="warning">
{t("plugins.modal.restartNotification")}
</Notification>
);
}
};
installAndRestart = () => {
const { collection } = this.props;
this.setState({
loading: true
});
apiClient
.post(collection._links.installPending.href)
.then(() => {
this.setState({
success: true,
loading: false,
error: undefined
});
})
.catch(error => {
this.setState({
success: false,
loading: false,
error: error
});
});
};
renderBody = () => {
const { collection, t } = this.props;
return (
<>
<div className="media">
<div className="content">
<p>{t("plugins.modal.installPending")}</p>
<ul>
{collection._embedded.plugins
.filter(plugin => plugin.pending)
.map(plugin => (
<li key={plugin.name} className="has-text-weight-bold">{plugin.name}</li>
))}
</ul>
</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.installAndRestart")}
loading={loading}
action={this.installAndRestart}
disabled={error || success}
/>
<Button label={t("plugins.modal.abort")} action={onClose} />
</ButtonGroup>
);
};
render() {
const { onClose, t } = this.props;
return (
<Modal
title={t("plugins.modal.installAndRestart")}
closeFunction={onClose}
body={this.renderBody()}
footer={this.renderFooter()}
active={true}
/>
);
}
}
export default translate("admin")(InstallPendingModal);

View File

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

View File

@@ -5,6 +5,7 @@ import type { Plugin } from "@scm-manager/ui-types";
import { CardColumn } from "@scm-manager/ui-components";
import PluginAvatar from "./PluginAvatar";
import PluginModal from "./PluginModal";
import classNames from "classnames";
type Props = {
plugin: Plugin,
@@ -43,34 +44,48 @@ class PluginEntry extends React.Component<Props, State> {
}));
};
createContentRight = (plugin: Plugin) => {
const { classes } = this.props;
if (plugin._links && plugin._links.install && plugin._links.install.href) {
return (
<div className={classes.link} onClick={this.toggleModal}>
<i className="fas fa-download fa-2x has-text-info" />
</div>
);
}
};
createFooterLeft = (plugin: Plugin) => {
createFooterRight = (plugin: Plugin) => {
return <small className="level-item">{plugin.author}</small>;
};
createFooterRight = (plugin: Plugin) => {
return <p className="level-item">{plugin.version}</p>;
createFooterLeft = (plugin: Plugin) => {
const { classes } = this.props;
if (plugin.pending) {
return (
<span className="level-item">
<i className="fas fa-spinner fa-spin has-text-info" />
</span>
);
} else if (
plugin._links &&
plugin._links.install &&
plugin._links.install.href
) {
return (
<span
className={classNames(classes.link, "level-item")}
onClick={this.toggleModal}
>
<i className="fas fa-download has-text-info" />
</span>
);
}
};
render() {
const { plugin } = this.props;
const { showModal } = this.state;
const avatar = this.createAvatar(plugin);
const contentRight = this.createContentRight(plugin);
const footerLeft = this.createFooterLeft(plugin);
const footerRight = this.createFooterRight(plugin);
const modal = showModal ? <PluginModal plugin={plugin} onSubmit={this.toggleModal} onClose={this.toggleModal} /> : null;
const modal = showModal ? (
<PluginModal
plugin={plugin}
onSubmit={this.toggleModal}
onClose={this.toggleModal}
/>
) : null;
// TODO: Add link to plugin page below
return (
@@ -80,7 +95,6 @@ class PluginEntry extends React.Component<Props, State> {
avatar={avatar}
title={plugin.displayName ? plugin.displayName : plugin.name}
description={plugin.description}
contentRight={contentRight}
footerLeft={footerLeft}
footerRight={footerRight}
/>

View File

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

View File

@@ -1,5 +1,5 @@
// @flow
import React from "react";
import * as React from "react";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import { compose } from "redux";
@@ -9,7 +9,8 @@ import {
Title,
Subtitle,
Notification,
ErrorNotification
ErrorNotification,
Button
} from "@scm-manager/ui-components";
import {
fetchPluginsByLink,
@@ -17,11 +18,14 @@ import {
getPluginCollection,
isFetchPluginsPending
} from "../modules/plugins";
import PluginsList from "../components/PluginsList";
import PluginsList from "../components/PluginList";
import {
getAvailablePluginsLink,
getInstalledPluginsLink
} from "../../../modules/indexResource";
import PluginTopActions from "../components/PluginTopActions";
import PluginBottomActions from "../components/PluginBottomActions";
import InstallPendingAction from '../components/InstallPendingAction';
type Props = {
loading: boolean,
@@ -64,8 +68,44 @@ class PluginsOverview extends React.Component<Props> {
}
}
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 { collection } = this.props;
if (collection._links.installPending) {
return (
<InstallPendingAction collection={collection} />
);
}
return null;
};
render() {
const { loading, error, collection, installed, t } = this.props;
const { loading, error, collection } = this.props;
if (error) {
return <ErrorNotification error={error} />;
@@ -75,17 +115,13 @@ class PluginsOverview extends React.Component<Props> {
return <Loading />;
}
const actions = this.createActions();
return (
<>
<Title title={t("plugins.title")} />
<Subtitle
subtitle={
installed
? t("plugins.installedSubtitle")
: t("plugins.availableSubtitle")
}
/>
{this.renderHeader(actions)}
<hr className="header-with-actions" />
{this.renderPluginsList()}
{this.renderFooter(actions)}
</>
);
}

View File

@@ -95,4 +95,16 @@ public class AvailablePluginResource {
pluginManager.install(name, restartAfterInstallation);
return Response.ok().build();
}
@POST
@Path("/install-pending")
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 500, condition = "internal server error")
})
public Response installPending() {
PluginPermissions.manage().check();
pluginManager.installPendingAndRestart();
return Response.ok().build();
}
}

View File

@@ -3,9 +3,11 @@ package sonia.scm.api.v2.resources;
import com.google.inject.Inject;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Link;
import de.otto.edison.hal.Links;
import sonia.scm.plugin.AvailablePlugin;
import sonia.scm.plugin.InstalledPlugin;
import sonia.scm.plugin.PluginPermissions;
import java.util.List;
@@ -31,7 +33,7 @@ public class PluginDtoCollectionMapper {
public HalRepresentation mapAvailable(List<AvailablePlugin> plugins) {
List<PluginDto> dtos = plugins.stream().map(mapper::mapAvailable).collect(toList());
return new HalRepresentation(createAvailablePluginsLinks(), embedDtos(dtos));
return new HalRepresentation(createAvailablePluginsLinks(plugins), embedDtos(dtos));
}
private Links createInstalledPluginsLinks() {
@@ -42,14 +44,25 @@ public class PluginDtoCollectionMapper {
return linksBuilder.build();
}
private Links createAvailablePluginsLinks() {
private Links createAvailablePluginsLinks(List<AvailablePlugin> plugins) {
String baseUrl = resourceLinks.availablePluginCollection().self();
Links.Builder linksBuilder = linkingTo()
.with(Links.linkingTo().self(baseUrl).build());
if (PluginPermissions.manage().isPermitted()) {
if (containsPending(plugins)) {
linksBuilder.single(Link.link("installPending", resourceLinks.availablePluginCollection().installPending()));
}
}
return linksBuilder.build();
}
private boolean containsPending(List<AvailablePlugin> plugins) {
return plugins.stream().anyMatch(AvailablePlugin::isPending);
}
private Embedded embedDtos(List<PluginDto> dtos) {
return embeddedBuilder()
.with("plugins", dtos)

View File

@@ -715,6 +715,10 @@ class ResourceLinks {
availablePluginCollectionLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, AvailablePluginResource.class);
}
String installPending() {
return availablePluginCollectionLinkBuilder.method("availablePlugins").parameters().method("installPending").parameters().href();
}
String self() {
return availablePluginCollectionLinkBuilder.method("availablePlugins").parameters().method("getAvailablePlugins").parameters().href();
}

View File

@@ -139,6 +139,17 @@ class AvailablePluginResourceTest {
verify(pluginManager).install("pluginName", false);
assertThat(HttpServletResponse.SC_OK).isEqualTo(response.getStatus());
}
@Test
void installPendingPlugin() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.post("/v2/plugins/available/install-pending");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
verify(pluginManager).installPendingAndRestart();
assertThat(HttpServletResponse.SC_OK).isEqualTo(response.getStatus());
}
}
private AvailablePlugin createPlugin() {