mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-15 09:46:16 +01:00
implemented ui for pending plugin installation
This commit is contained in:
@@ -9,6 +9,7 @@ export type Plugin = {
|
||||
author: string,
|
||||
category: string,
|
||||
avatarUrl: string,
|
||||
pending: boolean,
|
||||
dependencies: string[],
|
||||
_links: Links
|
||||
};
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
68
scm-ui/src/admin/plugins/components/InstallPendingAction.js
Normal file
68
scm-ui/src/admin/plugins/components/InstallPendingAction.js
Normal 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);
|
||||
136
scm-ui/src/admin/plugins/components/InstallPendingModal.js
Normal file
136
scm-ui/src/admin/plugins/components/InstallPendingModal.js
Normal 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);
|
||||
30
scm-ui/src/admin/plugins/components/PluginBottomActions.js
Normal file
30
scm-ui/src/admin/plugins/components/PluginBottomActions.js
Normal 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);
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
32
scm-ui/src/admin/plugins/components/PluginTopActions.js
Normal file
32
scm-ui/src/admin/plugins/components/PluginTopActions.js
Normal 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);
|
||||
@@ -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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user