Merged in feature/plugin_center (pull request #284)

Feature/plugin center
This commit is contained in:
Eduard Heimbuch
2019-07-11 11:31:35 +00:00
36 changed files with 1482 additions and 195 deletions

View File

@@ -37,6 +37,8 @@ public class VndMediaType {
public static final String REPOSITORY_VERB_COLLECTION = PREFIX + "repositoryVerbCollection" + SUFFIX; public static final String REPOSITORY_VERB_COLLECTION = PREFIX + "repositoryVerbCollection" + SUFFIX;
public static final String REPOSITORY_TYPE_COLLECTION = PREFIX + "repositoryTypeCollection" + SUFFIX; public static final String REPOSITORY_TYPE_COLLECTION = PREFIX + "repositoryTypeCollection" + SUFFIX;
public static final String REPOSITORY_TYPE = PREFIX + "repositoryType" + SUFFIX; public static final String REPOSITORY_TYPE = PREFIX + "repositoryType" + SUFFIX;
public static final String PLUGIN = PREFIX + "plugin" + SUFFIX;
public static final String PLUGIN_COLLECTION = PREFIX + "pluginCollection" + SUFFIX;
public static final String UI_PLUGIN = PREFIX + "uiPlugin" + SUFFIX; public static final String UI_PLUGIN = PREFIX + "uiPlugin" + SUFFIX;
public static final String UI_PLUGIN_COLLECTION = PREFIX + "uiPluginCollection" + SUFFIX; public static final String UI_PLUGIN_COLLECTION = PREFIX + "uiPluginCollection" + SUFFIX;
@SuppressWarnings("squid:S2068") @SuppressWarnings("squid:S2068")

View File

@@ -0,0 +1,87 @@
//@flow
import * as React from "react";
import injectSheet from "react-jss";
import classNames from "classnames";
import { Link } from "react-router-dom";
const styles = {
inner: {
position: "relative",
pointerEvents: "none",
zIndex: 1
},
innerLink: {
pointerEvents: "all"
},
centerImage: {
marginTop: "0.8em",
marginLeft: "1em !important"
},
flexFullHeight: {
display: "flex",
flexDirection: "column",
alignSelf: "stretch"
},
content: {
display: "flex",
flexGrow: 1
},
footer: {
display: "flex",
marginTop: "auto",
paddingBottom: "1.5rem"
}
};
type Props = {
title: string,
description: string,
avatar: React.Node,
footerLeft: React.Node,
footerRight: React.Node,
link: string,
// context props
classes: any
};
class CardColumn extends React.Component<Props> {
createLink = () => {
const { link } = this.props;
if (link) {
return <Link className="overlay-column" to={link} />;
}
return null;
};
render() {
const { avatar, title, description, footerLeft, footerRight, classes } = this.props;
const link = this.createLink();
return (
<>
{link}
<article className={classNames("media", classes.inner)}>
<figure className={classNames(classes.centerImage, "media-left")}>
{avatar}
</figure>
<div className={classNames("media-content", "text-box", classes.flexFullHeight)}>
<div className={classes.content}>
<div className="content shorten-text">
<p className="is-marginless">
<strong>{title}</strong>
</p>
<p className="shorten-text">{description}</p>
</div>
</div>
<div className={classNames(classes.footer, "level")}>
<div className="level-left is-hidden-mobile">{footerLeft}</div>
<div className="level-right is-mobile">{footerRight}</div>
</div>
</div>
</article>
</>
);
}
}
export default injectSheet(styles)(CardColumn);

View File

@@ -0,0 +1,103 @@
//@flow
import * as React from "react";
import injectSheet from "react-jss";
import classNames from "classnames";
const styles = {
pointer: {
cursor: "pointer",
fontSize: "1.5rem"
},
repoGroup: {
marginBottom: "1em"
},
wrapper: {
padding: "0 0.75rem"
},
clearfix: {
clear: "both"
}
};
type Props = {
name: string,
elements: React.Node[],
// context props
classes: any
};
type State = {
collapsed: boolean
};
class CardColumnGroup extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
collapsed: false
};
}
toggleCollapse = () => {
this.setState(prevState => ({
collapsed: !prevState.collapsed
}));
};
isLastEntry = (array: React.Node[], index: number) => {
return index === array.length - 1;
};
isLengthOdd = (array: React.Node[]) => {
return array.length % 2 !== 0;
};
isFullSize = (array: React.Node[], index: number) => {
return this.isLastEntry(array, index) && this.isLengthOdd(array);
};
render() {
const { name, elements, classes } = this.props;
const { collapsed } = this.state;
const icon = collapsed ? "fa-angle-right" : "fa-angle-down";
let content = null;
if (!collapsed) {
content = elements.map((entry, index) => {
const fullColumnWidth = this.isFullSize(elements, index);
const sizeClass = fullColumnWidth ? "is-full" : "is-half";
return (
<div
className={classNames(
"box",
"box-link-shadow",
"column",
"is-clipped",
sizeClass
)}
key={index}
>
{entry}
</div>
);
});
}
return (
<div className={classes.repoGroup}>
<h2>
<span className={classes.pointer} onClick={this.toggleCollapse}>
<i className={classNames("fa", icon)} /> {name}
</span>
</h2>
<hr />
<div className={classNames("columns", "is-multiline", classes.wrapper)}>
{content}
</div>
<div className={classes.clearfix} />
</div>
);
}
}
export default injectSheet(styles)(CardColumnGroup);

View File

@@ -25,7 +25,7 @@ class LinkPaginator extends React.Component<Props> {
renderFirstButton() { renderFirstButton() {
return ( return (
<Button <Button
className={"pagination-link"} className="pagination-link"
label={"1"} label={"1"}
disabled={false} disabled={false}
link={this.addFilterToLink("1")} link={this.addFilterToLink("1")}
@@ -69,7 +69,7 @@ class LinkPaginator extends React.Component<Props> {
const { collection } = this.props; const { collection } = this.props;
return ( return (
<Button <Button
className={"pagination-link"} className="pagination-link"
label={`${collection.pageTotal}`} label={`${collection.pageTotal}`}
disabled={false} disabled={false}
link={this.addFilterToLink(`${collection.pageTotal}`)} link={this.addFilterToLink(`${collection.pageTotal}`)}

View File

@@ -17,7 +17,7 @@ class StatePaginator extends React.Component<Props> {
renderFirstButton() { renderFirstButton() {
return ( return (
<Button <Button
className={"pagination-link"} className="pagination-link"
label={"1"} label={"1"}
disabled={false} disabled={false}
action={() => this.updateCurrentPage(1)} action={() => this.updateCurrentPage(1)}
@@ -35,7 +35,7 @@ class StatePaginator extends React.Component<Props> {
return ( return (
<Button <Button
className={"pagination-previous"} className="pagination-previous"
label={label ? label : previousPage.toString()} label={label ? label : previousPage.toString()}
disabled={!this.hasLink("prev")} disabled={!this.hasLink("prev")}
action={() => this.updateCurrentPage(previousPage)} action={() => this.updateCurrentPage(previousPage)}
@@ -53,7 +53,7 @@ class StatePaginator extends React.Component<Props> {
const nextPage = page + 1; const nextPage = page + 1;
return ( return (
<Button <Button
className={"pagination-next"} className="pagination-next"
label={label ? label : nextPage.toString()} label={label ? label : nextPage.toString()}
disabled={!this.hasLink("next")} disabled={!this.hasLink("next")}
action={() => this.updateCurrentPage(nextPage)} action={() => this.updateCurrentPage(nextPage)}
@@ -65,7 +65,7 @@ class StatePaginator extends React.Component<Props> {
const { collection } = this.props; const { collection } = this.props;
return ( return (
<Button <Button
className={"pagination-link"} className="pagination-link"
label={`${collection.pageTotal}`} label={`${collection.pageTotal}`}
disabled={false} disabled={false}
action={() => this.updateCurrentPage(collection.pageTotal)} action={() => this.updateCurrentPage(collection.pageTotal)}

View File

@@ -34,6 +34,8 @@ export { default as MarkdownView } from "./MarkdownView";
export { default as SyntaxHighlighter } from "./SyntaxHighlighter"; export { default as SyntaxHighlighter } from "./SyntaxHighlighter";
export { default as ErrorBoundary } from "./ErrorBoundary"; export { default as ErrorBoundary } from "./ErrorBoundary";
export { default as OverviewPageActions } from "./OverviewPageActions.js"; export { default as OverviewPageActions } from "./OverviewPageActions.js";
export { default as CardColumnGroup } from "./CardColumnGroup";
export { default as CardColumn } from "./CardColumn";
export { apiClient } from "./apiclient.js"; export { apiClient } from "./apiclient.js";
export * from "./errors"; export * from "./errors";

View File

@@ -0,0 +1,22 @@
//@flow
import type { Collection, Links } from "./hal";
export type Plugin = {
name: string,
type: string,
version: string,
author: string,
description?: string,
_links: Links
};
export type PluginCollection = Collection & {
_embedded: {
plugins: Plugin[] | string[]
}
};
export type PluginGroup = {
name: string,
plugins: Plugin[]
};

View File

@@ -25,6 +25,8 @@ export type { SubRepository, File } from "./Sources";
export type { SelectValue, AutocompleteObject } from "./Autocomplete"; export type { SelectValue, AutocompleteObject } from "./Autocomplete";
export type { Plugin, PluginCollection, PluginGroup } from "./Plugin";
export type { RepositoryRole } from "./RepositoryRole"; export type { RepositoryRole } from "./RepositoryRole";
export type { NamespaceStrategies } from "./NamespaceStrategies"; export type { NamespaceStrategies } from "./NamespaceStrategies";

View File

@@ -10,6 +10,17 @@
"currentAppVersion": "Aktuelle Software-Versionsnummer" "currentAppVersion": "Aktuelle Software-Versionsnummer"
} }
}, },
"plugins": {
"title": "Plugins",
"installedSubtitle": "Installierte Plugins",
"availableSubtitle": "Verfügbare Plugins",
"menu": {
"pluginsNavLink": "Plugins",
"installedNavLink": "Installiert",
"availableNavLink": "Verfügbar"
},
"noPlugins": "Keine Plugins gefunden."
},
"repositoryRole": { "repositoryRole": {
"navLink": "Berechtigungsrollen", "navLink": "Berechtigungsrollen",
"title": "Berechtigungsrollen", "title": "Berechtigungsrollen",

View File

@@ -10,6 +10,17 @@
"currentAppVersion": "Current Application Version" "currentAppVersion": "Current Application Version"
} }
}, },
"plugins": {
"title": "Plugins",
"installedSubtitle": "Installed Plugins",
"availableSubtitle": "Available Plugins",
"menu": {
"pluginsNavLink": "Plugins",
"installedNavLink": "Installed",
"availableNavLink": "Available"
},
"noPlugins": "No plugins found."
},
"repositoryRole": { "repositoryRole": {
"navLink": "Permission Roles", "navLink": "Permission Roles",
"title": "Permission Roles", "title": "Permission Roles",

View File

@@ -10,6 +10,7 @@ import type { Links } from "@scm-manager/ui-types";
import { Page, Navigation, NavLink, Section, SubNavigation } from "@scm-manager/ui-components"; import { Page, Navigation, NavLink, Section, SubNavigation } from "@scm-manager/ui-components";
import { getLinks } from "../../modules/indexResource"; import { getLinks } from "../../modules/indexResource";
import AdminDetails from "./AdminDetails"; import AdminDetails from "./AdminDetails";
import PluginsOverview from "../plugins/containers/PluginsOverview";
import GlobalConfig from "./GlobalConfig"; import GlobalConfig from "./GlobalConfig";
import RepositoryRoles from "../roles/containers/RepositoryRoles"; import RepositoryRoles from "../roles/containers/RepositoryRoles";
import SingleRepositoryRole from "../roles/containers/SingleRepositoryRole"; import SingleRepositoryRole from "../roles/containers/SingleRepositoryRole";
@@ -62,6 +63,35 @@ class Admin extends React.Component<Props> {
<Redirect exact from={url} to={`${url}/info`} /> <Redirect exact from={url} to={`${url}/info`} />
<Route path={`${url}/info`} exact component={AdminDetails} /> <Route path={`${url}/info`} exact component={AdminDetails} />
<Route path={`${url}/settings/general`} exact component={GlobalConfig} /> <Route path={`${url}/settings/general`} exact component={GlobalConfig} />
<Redirect exact from={`${url}/plugins`} to={`${url}/plugins/installed/`} />
<Route
path={`${url}/plugins/installed`}
exact
render={() => (
<PluginsOverview baseUrl={`${url}/plugins/installed`} installed={true} />
)}
/>
<Route
path={`${url}/plugins/installed/:page`}
exact
render={() => (
<PluginsOverview baseUrl={`${url}/plugins/installed`} installed={true} />
)}
/>
<Route
path={`${url}/plugins/available`}
exact
render={() => (
<PluginsOverview baseUrl={`${url}/plugins/available`} installed={false} />
)}
/>
<Route
path={`${url}/plugins/available/:page`}
exact
render={() => (
<PluginsOverview baseUrl={`${url}/plugins/available`} installed={false} />
)}
/>
<Route <Route
path={`${url}/role/:role`} path={`${url}/role/:role`}
render={() => ( render={() => (
@@ -106,6 +136,24 @@ class Admin extends React.Component<Props> {
icon="fas fa-info-circle" icon="fas fa-info-circle"
label={t("admin.menu.informationNavLink")} label={t("admin.menu.informationNavLink")}
/> />
{
links.plugins &&
<SubNavigation
to={`${url}/plugins/`}
icon="fas fa-puzzle-piece"
label={t("plugins.menu.pluginsNavLink")}
>
<NavLink
to={`${url}/plugins/installed/`}
label={t("plugins.menu.installedNavLink")}
/>
{/* Activate this again after available plugins page is created */}
{/*<NavLink*/}
{/* to={`${url}/plugins/available/`}*/}
{/* label={t("plugins.menu.availableNavLink")}*/}
{/*/>*/}
</SubNavigation>
}
<NavLink <NavLink
to={`${url}/roles/`} to={`${url}/roles/`}
icon="fas fa-user-shield" icon="fas fa-user-shield"

View File

@@ -1,10 +1,9 @@
// @flow // @flow
import React from "react"; import React from "react";
import { connect } from "react-redux";
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import {Loading, Subtitle} from "@scm-manager/ui-components"; import { Loading, Title, Subtitle } from "@scm-manager/ui-components";
import {getAppVersion} from "../../modules/indexResource"; import { getAppVersion } from "../../modules/indexResource";
import {connect} from "react-redux";
import Title from "@scm-manager/ui-components/src/layout/Title";
type Props = { type Props = {
loading: boolean, loading: boolean,
@@ -24,10 +23,12 @@ class AdminDetails extends React.Component<Props> {
return <Loading />; return <Loading />;
} }
return <> return (
<Title title={t("admin.information.currentAppVersion")}/> <>
<Subtitle subtitle={this.props.version}/> <Title title={t("admin.information.currentAppVersion")} />
</>; <Subtitle subtitle={this.props.version} />
</>
);
} }
} }

View File

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

View File

@@ -0,0 +1,44 @@
//@flow
import React from "react";
import type { Plugin } from "@scm-manager/ui-types";
import { CardColumn } from "@scm-manager/ui-components";
import PluginAvatar from "./PluginAvatar";
type Props = {
plugin: Plugin
};
class PluginEntry extends React.Component<Props> {
createAvatar = (plugin: Plugin) => {
return <PluginAvatar plugin={plugin} />;
};
createFooterLeft = (plugin: Plugin) => {
return <small className="level-item">{plugin.author}</small>;
};
createFooterRight = (plugin: Plugin) => {
return <p className="level-item">{plugin.version}</p>;
};
render() {
const { plugin } = this.props;
const avatar = this.createAvatar(plugin);
const footerLeft = this.createFooterLeft(plugin);
const footerRight = this.createFooterRight(plugin);
// TODO: Add link to plugin page below
return (
<CardColumn
link="#"
avatar={avatar}
title={plugin.name}
description={plugin.description}
footerLeft={footerLeft}
footerRight={footerRight}
/>
);
}
}
export default PluginEntry;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,108 @@
// @flow
import React from "react";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import { compose } from "redux";
import type { PluginCollection } from "@scm-manager/ui-types";
import {
Loading,
Title,
Subtitle,
Notification,
ErrorNotification
} from "@scm-manager/ui-components";
import {
fetchPluginsByLink,
getFetchPluginsFailure,
getPluginCollection,
isFetchPluginsPending
} from "../modules/plugins";
import PluginsList from "../components/PluginsList";
import { getPluginsLink } from "../../../modules/indexResource";
type Props = {
loading: boolean,
error: Error,
collection: PluginCollection,
baseUrl: string,
installed: boolean,
pluginsLink: string,
// context objects
t: string => string,
// dispatched functions
fetchPluginsByLink: (link: string) => void
};
class PluginsOverview extends React.Component<Props> {
componentDidMount() {
const { fetchPluginsByLink, pluginsLink } = this.props;
fetchPluginsByLink(pluginsLink);
}
render() {
const { loading, error, collection, installed, t } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (!collection || loading) {
return <Loading />;
}
return (
<>
<Title title={t("plugins.title")} />
<Subtitle
subtitle={
installed
? t("plugins.installedSubtitle")
: t("plugins.availableSubtitle")
}
/>
{this.renderPluginsList()}
</>
);
}
renderPluginsList() {
const { collection, t } = this.props;
if (collection._embedded && collection._embedded.plugins.length > 0) {
return <PluginsList plugins={collection._embedded.plugins} />;
}
return <Notification type="info">{t("plugins.noPlugins")}</Notification>;
}
}
const mapStateToProps = state => {
const collection = getPluginCollection(state);
const loading = isFetchPluginsPending(state);
const error = getFetchPluginsFailure(state);
const pluginsLink = getPluginsLink(state);
return {
collection,
loading,
error,
pluginsLink
};
};
const mapDispatchToProps = dispatch => {
return {
fetchPluginsByLink: (link: string) => {
dispatch(fetchPluginsByLink(link));
}
};
};
export default compose(
translate("admin"),
connect(
mapStateToProps,
mapDispatchToProps
)
)(PluginsOverview);

View File

@@ -0,0 +1,191 @@
// @flow
import * as types from "../../../modules/types";
import { isPending } from "../../../modules/pending";
import { getFailure } from "../../../modules/failure";
import type { Action, Plugin, PluginCollection } from "@scm-manager/ui-types";
import { apiClient } from "@scm-manager/ui-components";
export const FETCH_PLUGINS = "scm/plugins/FETCH_PLUGINS";
export const FETCH_PLUGINS_PENDING = `${FETCH_PLUGINS}_${types.PENDING_SUFFIX}`;
export const FETCH_PLUGINS_SUCCESS = `${FETCH_PLUGINS}_${types.SUCCESS_SUFFIX}`;
export const FETCH_PLUGINS_FAILURE = `${FETCH_PLUGINS}_${types.FAILURE_SUFFIX}`;
export const FETCH_PLUGIN = "scm/plugins/FETCH_PLUGIN";
export const FETCH_PLUGIN_PENDING = `${FETCH_PLUGIN}_${types.PENDING_SUFFIX}`;
export const FETCH_PLUGIN_SUCCESS = `${FETCH_PLUGIN}_${types.SUCCESS_SUFFIX}`;
export const FETCH_PLUGIN_FAILURE = `${FETCH_PLUGIN}_${types.FAILURE_SUFFIX}`;
// 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
};
}
// reducer
function normalizeByName(pluginCollection: PluginCollection) {
const names = [];
const byNames = {};
for (const plugin of pluginCollection._embedded.plugins) {
names.push(plugin.name);
byNames[plugin.name] = plugin;
}
return {
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(action.payload);
case FETCH_PLUGIN_SUCCESS:
return reducerByNames(state, action.payload);
default:
return state;
}
}
// selectors
export function getPluginCollection(state: Object) {
if (state.plugins && state.plugins.list && state.plugins.byNames) {
const plugins = [];
for (let pluginName of state.plugins.list._embedded.plugins) {
plugins.push(state.plugins.byNames[pluginName]);
}
return {
...state.plugins.list,
_embedded: {
plugins
}
};
}
}
export function isFetchPluginsPending(state: Object) {
return isPending(state, FETCH_PLUGINS);
}
export function getFetchPluginsFailure(state: Object) {
return getFailure(state, FETCH_PLUGINS);
}
export function getPlugin(state: Object, name: string) {
if (state.plugins && state.plugins.byNames) {
return state.plugins.byNames[name];
}
}
export function isFetchPluginPending(state: Object, name: string) {
return isPending(state, FETCH_PLUGIN, name);
}
export function getFetchPluginFailure(state: Object, name: string) {
return getFailure(state, FETCH_PLUGIN, name);
}

View File

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

View File

@@ -18,6 +18,7 @@ import config from "./admin/modules/config";
import roles from "./admin/roles/modules/roles"; import roles from "./admin/roles/modules/roles";
import namespaceStrategies from "./admin/modules/namespaceStrategies"; import namespaceStrategies from "./admin/modules/namespaceStrategies";
import indexResources from "./modules/indexResource"; import indexResources from "./modules/indexResource";
import plugins from "./admin/plugins/modules/plugins";
import type { BrowserHistory } from "history/createBrowserHistory"; import type { BrowserHistory } from "history/createBrowserHistory";
import branches from "./repos/branches/modules/branches"; import branches from "./repos/branches/modules/branches";
@@ -42,7 +43,8 @@ function createReduxStore(history: BrowserHistory) {
config, config,
roles, roles,
sources, sources,
namespaceStrategies namespaceStrategies,
plugins
}); });
return createStore( return createStore(

View File

@@ -116,6 +116,10 @@ export function getUiPluginsLink(state: Object) {
return getLink(state, "uiPlugins"); return getLink(state, "uiPlugins");
} }
export function getPluginsLink(state: Object) {
return getLink(state, "plugins");
}
export function getMeLink(state: Object) { export function getMeLink(state: Object) {
return getLink(state, "me"); return getLink(state, "me");
} }

View File

@@ -1,33 +1,12 @@
//@flow //@flow
import React from "react"; import React from "react";
import { Link } from "react-router-dom";
import injectSheet from "react-jss";
import type { Repository } from "@scm-manager/ui-types"; import type { Repository } from "@scm-manager/ui-types";
import { DateFromNow } from "@scm-manager/ui-components"; import { CardColumn, DateFromNow } from "@scm-manager/ui-components";
import RepositoryEntryLink from "./RepositoryEntryLink"; import RepositoryEntryLink from "./RepositoryEntryLink";
import classNames from "classnames";
import RepositoryAvatar from "./RepositoryAvatar"; import RepositoryAvatar from "./RepositoryAvatar";
const styles = {
inner: {
position: "relative",
pointerEvents: "none",
zIndex: 1
},
innerLink: {
pointerEvents: "all"
},
centerImage: {
marginTop: "0.8em",
marginLeft: "1em !important"
}
};
type Props = { type Props = {
repository: Repository, repository: Repository
fullColumnWidth?: boolean,
// context props
classes: any
}; };
class RepositoryEntry extends React.Component<Props> { class RepositoryEntry extends React.Component<Props> {
@@ -83,53 +62,41 @@ class RepositoryEntry extends React.Component<Props> {
return null; return null;
}; };
render() { createFooterLeft = (repository: Repository, repositoryLink: string) => {
const { repository, classes, fullColumnWidth } = this.props;
const repositoryLink = this.createLink(repository);
const halfColumn = fullColumnWidth ? "is-full" : "is-half";
const overlayLinkClass = fullColumnWidth
? "overlay-full-column"
: "overlay-half-column";
return ( return (
<div <>
className={classNames(
"box",
"box-link-shadow",
"column",
"is-clipped",
halfColumn
)}
>
<Link className={classNames(overlayLinkClass)} to={repositoryLink} />
<article className={classNames("media", classes.inner)}>
<figure className={classNames(classes.centerImage, "media-left")}>
<RepositoryAvatar repository={repository} />
</figure>
<div className={classNames("media-content", "text-box")}>
<div className="content">
<p className="is-marginless">
<strong>{repository.name}</strong>
</p>
<p className={"shorten-text"}>{repository.description}</p>
</div>
<nav className="level is-mobile">
<div className="level-left">
{this.renderBranchesLink(repository, repositoryLink)} {this.renderBranchesLink(repository, repositoryLink)}
{this.renderChangesetsLink(repository, repositoryLink)} {this.renderChangesetsLink(repository, repositoryLink)}
{this.renderSourcesLink(repository, repositoryLink)} {this.renderSourcesLink(repository, repositoryLink)}
{this.renderModifyLink(repository, repositoryLink)} {this.renderModifyLink(repository, repositoryLink)}
</div> </>
<div className="level-right is-hidden-mobile"> );
};
createFooterRight = (repository: Repository) => {
return (
<small className="level-item"> <small className="level-item">
<DateFromNow date={repository.creationDate} /> <DateFromNow date={repository.creationDate} />
</small> </small>
</div> );
</nav> };
</div>
</article> render() {
</div> const { repository } = this.props;
const repositoryLink = this.createLink(repository);
const footerLeft = this.createFooterLeft(repository, repositoryLink);
const footerRight = this.createFooterRight(repository);
return (
<CardColumn
avatar={<RepositoryAvatar repository={repository} />}
title={repository.name}
description={repository.description}
link={repositoryLink}
footerLeft={footerLeft}
footerRight={footerRight}
/>
); );
} }
} }
export default injectSheet(styles)(RepositoryEntry); export default RepositoryEntry;

View File

@@ -1,92 +1,21 @@
//@flow //@flow
import React from "react"; import React from "react";
import type { RepositoryGroup, Repository } from "@scm-manager/ui-types"; import { CardColumnGroup } from "@scm-manager/ui-components";
import injectSheet from "react-jss"; import type { RepositoryGroup } from "@scm-manager/ui-types";
import classNames from "classnames";
import RepositoryEntry from "./RepositoryEntry"; import RepositoryEntry from "./RepositoryEntry";
const styles = {
pointer: {
cursor: "pointer",
fontSize: "1.5rem"
},
repoGroup: {
marginBottom: "1em"
},
wrapper: {
padding: "0 0.75rem"
},
clearfix: {
clear: "both"
}
};
type Props = { type Props = {
group: RepositoryGroup, group: RepositoryGroup
// context props
classes: any
}; };
type State = { class RepositoryGroupEntry extends React.Component<Props> {
collapsed: boolean
};
class RepositoryGroupEntry extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
collapsed: false
};
}
toggleCollapse = () => {
this.setState(prevState => ({
collapsed: !prevState.collapsed
}));
};
isLastEntry = (array: Repository[], index: number) => {
return index === array.length - 1;
};
isLengthOdd = (array: Repository[]) => {
return array.length % 2 !== 0;
};
isFullSize = (array: Repository[], index: number) => {
return this.isLastEntry(array, index) && this.isLengthOdd(array);
};
render() { render() {
const { group, classes } = this.props; const { group } = this.props;
const { collapsed } = this.state; const entries = group.repositories.map((repository, index) => {
return <RepositoryEntry repository={repository} key={index} />;
const icon = collapsed ? "fa-angle-right" : "fa-angle-down";
let content = null;
if (!collapsed) {
content = group.repositories.map((repository, index) => {
const fullColumnWidth = this.isFullSize(group.repositories, index);
return (
<RepositoryEntry repository={repository} fullColumnWidth={fullColumnWidth} key={index} />
);
}); });
} return <CardColumnGroup name={group.name} elements={entries} />;
return (
<div className={classes.repoGroup}>
<h2>
<span className={classes.pointer} onClick={this.toggleCollapse}>
<i className={classNames("fa", icon)} /> {group.name}
</span>
</h2>
<hr />
<div className={classNames("columns", "is-multiline", classes.wrapper)}>
{content}
</div>
<div className={classes.clearfix} />
</div>
);
} }
} }
export default injectSheet(styles)(RepositoryGroupEntry); export default RepositoryGroupEntry;

View File

@@ -159,27 +159,31 @@ ul.is-separated {
// multiline Columns // multiline Columns
.columns.is-multiline { .columns.is-multiline {
.column {
height: 120px;
.overlay-column {
position: absolute;
height: calc(120px - 1.5rem);
}
}
.column.is-half { .column.is-half {
width: calc(50% - 0.75rem); width: calc(50% - 0.75rem);
max-height: 120px;
&:nth-child(odd) { &:nth-child(odd) {
margin-right: 1.5rem; margin-right: 1.5rem;
} }
.overlay-half-column { .overlay-column {
position: absolute;
height: calc(120px - 1.5rem);
width: calc(50% - 3rem); width: calc(50% - 3rem);
} }
} }
.column.is-full {
.overlay-full-column { .column.is-full .overlay-column {
position: absolute;
height: calc(120px - 0.5rem);
width: calc(100% - 1.5rem); width: calc(100% - 1.5rem);
} }
}
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.column.is-half { .column.is-half {
width: 100%; width: 100%;
@@ -188,9 +192,25 @@ ul.is-separated {
margin-right: 0; margin-right: 0;
} }
.overlay-half-column { .overlay-column {
position: absolute; width: calc(100% - 1.5rem);
height: calc(120px - 0.5rem); }
}
}
}
.content.is-plugin-page {
.columns.is-multiline {
.column.is-half .overlay-column {
width: calc(37.5% - 1.5rem);
}
.column.is-full .overlay-column {
width: calc(75% - 1.5rem);
}
@media screen and (max-width: 768px) {
.column.is-half .overlay-column,
.column.is-full .overlay-column {
width: calc(100% - 1.5rem); width: calc(100% - 1.5rem);
} }
} }

View File

@@ -8,6 +8,7 @@ import org.apache.shiro.SecurityUtils;
import sonia.scm.SCMContextProvider; import sonia.scm.SCMContextProvider;
import sonia.scm.config.ConfigurationPermissions; import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.group.GroupPermissions; import sonia.scm.group.GroupPermissions;
import sonia.scm.plugin.PluginPermissions;
import sonia.scm.repository.RepositoryRolePermissions; import sonia.scm.repository.RepositoryRolePermissions;
import sonia.scm.security.PermissionPermissions; import sonia.scm.security.PermissionPermissions;
import sonia.scm.user.UserPermissions; import sonia.scm.user.UserPermissions;
@@ -34,11 +35,15 @@ public class IndexDtoGenerator extends HalAppenderMapper {
List<Link> autoCompleteLinks = Lists.newArrayList(); List<Link> autoCompleteLinks = Lists.newArrayList();
builder.self(resourceLinks.index().self()); builder.self(resourceLinks.index().self());
builder.single(link("uiPlugins", resourceLinks.uiPluginCollection().self())); builder.single(link("uiPlugins", resourceLinks.uiPluginCollection().self()));
if (SecurityUtils.getSubject().isAuthenticated()) { if (SecurityUtils.getSubject().isAuthenticated()) {
builder.single( builder.single(
link("me", resourceLinks.me().self()), link("me", resourceLinks.me().self()),
link("logout", resourceLinks.authentication().logout()) link("logout", resourceLinks.authentication().logout())
); );
if (PluginPermissions.read().isPermitted()) {
builder.single(link("plugins", resourceLinks.pluginCollection().self()));
}
if (UserPermissions.list().isPermitted()) { if (UserPermissions.list().isPermitted()) {
builder.single(link("users", resourceLinks.userCollection().self())); builder.single(link("users", resourceLinks.userCollection().self()));
} }

View File

@@ -0,0 +1,24 @@
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
public class PluginDto extends HalRepresentation {
private String name;
private String type;
private String version;
private String author;
private String description;
public PluginDto(Links links) {
add(links);
}
}

View File

@@ -0,0 +1,45 @@
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.Links;
import sonia.scm.plugin.PluginWrapper;
import java.util.Collection;
import java.util.List;
import static de.otto.edison.hal.Embedded.embeddedBuilder;
import static de.otto.edison.hal.Links.linkingTo;
import static java.util.stream.Collectors.toList;
public class PluginDtoCollectionMapper {
private final ResourceLinks resourceLinks;
private final PluginDtoMapper mapper;
@Inject
public PluginDtoCollectionMapper(ResourceLinks resourceLinks, PluginDtoMapper mapper) {
this.resourceLinks = resourceLinks;
this.mapper = mapper;
}
public HalRepresentation map(Collection<PluginWrapper> plugins) {
List<PluginDto> dtos = plugins.stream().map(mapper::map).collect(toList());
return new HalRepresentation(createLinks(), embedDtos(dtos));
}
private Links createLinks() {
String baseUrl = resourceLinks.pluginCollection().self();
Links.Builder linksBuilder = linkingTo()
.with(Links.linkingTo().self(baseUrl).build());
return linksBuilder.build();
}
private Embedded embedDtos(List<PluginDto> dtos) {
return embeddedBuilder()
.with("plugins", dtos)
.build();
}
}

View File

@@ -0,0 +1,32 @@
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Links;
import sonia.scm.plugin.PluginWrapper;
import javax.inject.Inject;
import static de.otto.edison.hal.Links.linkingTo;
public class PluginDtoMapper {
private final ResourceLinks resourceLinks;
@Inject
public PluginDtoMapper(ResourceLinks resourceLinks) {
this.resourceLinks = resourceLinks;
}
public PluginDto map(PluginWrapper plugin) {
Links.Builder linksBuilder = linkingTo()
.self(resourceLinks.plugin()
.self(plugin.getPlugin().getInformation().getId(false)));
PluginDto pluginDto = new PluginDto(linksBuilder.build());
pluginDto.setName(plugin.getPlugin().getInformation().getName());
pluginDto.setType(plugin.getPlugin().getInformation().getCategory() != null ? plugin.getPlugin().getInformation().getCategory() : "Miscellaneous");
pluginDto.setVersion(plugin.getPlugin().getInformation().getVersion());
pluginDto.setAuthor(plugin.getPlugin().getInformation().getAuthor());
pluginDto.setDescription(plugin.getPlugin().getInformation().getDescription());
return pluginDto;
}
}

View File

@@ -0,0 +1,87 @@
package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode;
import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import com.webcohesion.enunciate.metadata.rs.TypeHint;
import sonia.scm.plugin.Plugin;
import sonia.scm.plugin.PluginLoader;
import sonia.scm.plugin.PluginPermissions;
import sonia.scm.plugin.PluginWrapper;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static sonia.scm.ContextEntry.ContextBuilder.entity;
import static sonia.scm.NotFoundException.notFound;
public class PluginResource {
private final PluginLoader pluginLoader;
private final PluginDtoCollectionMapper collectionMapper;
private final PluginDtoMapper mapper;
@Inject
public PluginResource(PluginLoader pluginLoader, PluginDtoCollectionMapper collectionMapper, PluginDtoMapper mapper) {
this.pluginLoader = pluginLoader;
this.collectionMapper = collectionMapper;
this.mapper = mapper;
}
/**
* Returns a collection of installed plugins.
*
* @return collection of installed plugins.
*/
@GET
@Path("")
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(CollectionDto.class)
@Produces(VndMediaType.PLUGIN_COLLECTION)
public Response getInstalledPlugins() {
PluginPermissions.read().check();
List<PluginWrapper> plugins = new ArrayList<>(pluginLoader.getInstalledPlugins());
return Response.ok(collectionMapper.map(plugins)).build();
}
/**
* Returns the installed plugin with the given id.
*
* @param id id of plugin
*
* @return installed plugin with specified id
*/
@GET
@Path("{id}")
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 404, condition = "not found"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(PluginDto.class)
@Produces(VndMediaType.PLUGIN)
public Response getInstalledPlugin(@PathParam("id") String id) {
PluginPermissions.read().check();
Optional<PluginDto> pluginDto = pluginLoader.getInstalledPlugins()
.stream()
.filter(plugin -> id.equals(plugin.getPlugin().getInformation().getId(false)))
.map(mapper::map)
.findFirst();
if (pluginDto.isPresent()) {
return Response.ok(pluginDto.get()).build();
} else {
throw notFound(entity(Plugin.class, id));
}
}
}

View File

@@ -0,0 +1,21 @@
package sonia.scm.api.v2.resources;
import javax.inject.Inject;
import javax.inject.Provider;
import javax.ws.rs.Path;
@Path("v2/")
public class PluginRootResource {
private Provider<PluginResource> pluginResourceProvider;
@Inject
public PluginRootResource(Provider<PluginResource> pluginResourceProvider) {
this.pluginResourceProvider = pluginResourceProvider;
}
@Path("plugins")
public PluginResource plugins() {
return pluginResourceProvider.get();
}
}

View File

@@ -651,6 +651,38 @@ class ResourceLinks {
} }
} }
public PluginLinks plugin() {
return new PluginLinks(scmPathInfoStore.get());
}
static class PluginLinks {
private final LinkBuilder pluginLinkBuilder;
PluginLinks(ScmPathInfo pathInfo) {
pluginLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, PluginResource.class);
}
String self(String id) {
return pluginLinkBuilder.method("plugins").parameters().method("getInstalledPlugin").parameters(id).href();
}
}
public PluginCollectionLinks pluginCollection() {
return new PluginCollectionLinks(scmPathInfoStore.get());
}
static class PluginCollectionLinks {
private final LinkBuilder pluginCollectionLinkBuilder;
PluginCollectionLinks(ScmPathInfo pathInfo) {
pluginCollectionLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, PluginResource.class);
}
String self() {
return pluginCollectionLinkBuilder.method("plugins").parameters().method("getInstalledPlugins").parameters().href();
}
}
public AuthenticationLinks authentication() { public AuthenticationLinks authentication() {
return new AuthenticationLinks(scmPathInfoStore.get()); return new AuthenticationLinks(scmPathInfoStore.get());
} }

View File

@@ -67,7 +67,13 @@
<value>configuration:read,write:*</value> <value>configuration:read,write:*</value>
</permission> </permission>
<permission> <permission>
<value>repositoryRole:write</value> <value>repositoryRole:read,write</value>
</permission>
<permission>
<value>plugin:read</value>
</permission>
<permission>
<value>plugin:read,write</value>
</permission> </permission>
</permissions> </permissions>

View File

@@ -66,6 +66,16 @@
"description": "Kann benutzerdefinierte Rollen und deren Berechtigungen erstellen, ändern und löschen" "description": "Kann benutzerdefinierte Rollen und deren Berechtigungen erstellen, ändern und löschen"
} }
}, },
"plugin": {
"read": {
"displayName": "Alle Plugins lesen",
"description": "Darf alle installierten und verfügbaren Plugins lesen"
},
"read,write": {
"displayName": "Alle Plugins lesen und verwalten",
"description": "Darf alle installierten und verfügbaren Plugins lesen und verwalten"
}
},
"unknown": "Unbekannte Berechtigung" "unknown": "Unbekannte Berechtigung"
}, },
"verbs": { "verbs": {

View File

@@ -66,6 +66,16 @@
"description": "May create, modify and delete custom repository roles and their permissions" "description": "May create, modify and delete custom repository roles and their permissions"
} }
}, },
"plugin": {
"read": {
"displayName": "Read all plugins",
"description": "May see all installed and available plugins"
},
"read,write": {
"displayName": "Read and manage all plugins",
"description": "May see and manage all installed and available plugins"
}
},
"unknown": "Unknown permission" "unknown": "Unknown permission"
}, },
"verbs": { "verbs": {