Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#7585: refactor of the Print plugin as a plugins container #7596

Merged
merged 10 commits into from
Dec 14, 2021
9 changes: 9 additions & 0 deletions web/client/actions/print.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const PRINT_CAPABILITIES_LOADED = 'PRINT_CAPABILITIES_LOADED';
export const PRINT_CAPABILITIES_ERROR = 'PRINT_CAPABILITIES_ERROR';

export const SET_PRINT_PARAMETER = 'SET_PRINT_PARAMETER';
export const ADD_PRINT_PARAMETER = 'ADD_PRINT_PARAMETER';
export const CONFIGURE_PRINT_MAP = 'CONFIGURE_PRINT_MAP';
export const CHANGE_PRINT_ZOOM_LEVEL = 'CHANGE_PRINT_ZOOM_LEVEL';
export const CHANGE_MAP_PRINT_PREVIEW = 'CHANGE_MAP_PRINT_PREVIEW';
Expand Down Expand Up @@ -105,6 +106,14 @@ export function setPrintParameter(name, value) {
};
}

export function addPrintParameter(name, value) {
return {
type: ADD_PRINT_PARAMETER,
name,
value
};
}

export function configurePrintMap(center, zoom, scaleZoom, scale, layers, projection, currentLocale) {
return {
type: CONFIGURE_PRINT_MAP,
Expand Down
2 changes: 1 addition & 1 deletion web/client/components/print/PrintPreview.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class PrintPreview extends React.Component {

render() {
return (
<div>
<div id="mapstore-print-preview-panel">
<div style={this.props.style}>
<Document file={this.props.url}
onLoadSuccess={this.onDocumentComplete}>
Expand Down
233 changes: 153 additions & 80 deletions web/client/plugins/Print.jsx

Large diffs are not rendered by default.

189 changes: 189 additions & 0 deletions web/client/plugins/__tests__/Print-test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import React, {useEffect} from "react";
import ReactDOM from "react-dom";
import expect from "expect";

import Print from "../Print";
import { getLazyPluginForTest } from './pluginsTestUtils';
import {Null} from "../print/Null";

function getByXPath(xpath) {
return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
}

const initialState = {
controls: {
print: {
enabled: true
}
},
print: {
spec: {},
capabilities: {
layouts: [],
dpis: [],
scales: []
}
}
};

const CustomComponent = ({actions}) => {
useEffect(() => {
actions.addParameter("custom", "");
}, []);
return <div>print.mycustomplugin</div>;
};

function expectDefaultItems() {
expect(document.getElementById("mapstore-print-panel")).toExist();
expect(getByXPath("//*[text()='print.title']")).toExist();
expect(getByXPath("//*[text()='print.description']")).toExist();
expect(document.getElementById("print_preview")).toExist();
expect(getByXPath("//*[text()='print.sheetsize']")).toExist();
expect(getByXPath("//*[text()='print.alternatives.legend']")).toExist();
expect(getByXPath("//*[text()='print.alternatives.2pages']")).toExist();
expect(getByXPath("//*[text()='print.alternatives.landscape']")).toExist();
expect(getByXPath("//*[text()='print.alternatives.portrait']")).toExist();
expect(getByXPath("//*[text()='print.legend.font']")).toExist();
expect(getByXPath("//*[text()='print.legend.forceLabels']")).toExist();
expect(getByXPath("//*[text()='print.legend.iconsSize']")).toExist();
expect(getByXPath("//*[text()='print.legend.dpi']")).toExist();
expect(getByXPath("//*[text()='print.resolution']")).toExist();
expect(getByXPath("//*[text()='print.submit']")).toExist();
expect(document.getElementById("mapstore-print-preview-panel")).toNotExist();
}

function getPrintPlugin({items = [], layers = [], preview = false} = {}) {
return getLazyPluginForTest({
plugin: Print,
storeState: {
...initialState,
browser: "good",
layers,
print: {
pdfUrl: preview ? "http://fakepreview" : undefined,
...initialState.print,
map: {
center: {x: 0, y: 0},
layers
}
}
},
items
});
}

describe('Print Plugin', () => {
beforeEach((done) => {
document.body.innerHTML = '<div id="container"></div>';
setTimeout(done);
});

afterEach((done) => {
ReactDOM.unmountComponentAtNode(document.getElementById("container"));
document.body.innerHTML = '';
setTimeout(done);
});

it('default configuration', (done) => {
getPrintPlugin().then(({ Plugin }) => {
try {
ReactDOM.render(<Plugin />, document.getElementById("container"));
expectDefaultItems();
done();
} catch (ex) {
done(ex);
}
});
});

it('default configuration with preview enabled', (done) => {
getPrintPlugin({
preview: true
}).then(({ Plugin }) => {
try {
ReactDOM.render(<Plugin />, document.getElementById("container"));
expect(document.getElementById("mapstore-print-preview-panel")).toExist();
done();
} catch (ex) {
done(ex);
}
});
});

it('default configuration with not allowed layers', (done) => {
getPrintPlugin({
layers: [{visibility: true, type: "bing"}]
}).then(({ Plugin }) => {
try {
ReactDOM.render(<Plugin />, document.getElementById("container"));
expectDefaultItems();
expect(getByXPath("//*[text()='print.defaultBackground']")).toExist();
done();
} catch (ex) {
done(ex);
}
});
});

it('custom plugin', (done) => {
getPrintPlugin({items: [{
plugin: CustomComponent,
target: "left-panel"
}]}).then(({ Plugin }) => {
try {
ReactDOM.render(<Plugin />, document.getElementById("container"));
expectDefaultItems();
expect(getByXPath("//*[text()='print.mycustomplugin']")).toExist();
done();
} catch (ex) {
done(ex);
}
});
});

it('custom plugin sets initial state', (done) => {
getPrintPlugin({items: [{
plugin: CustomComponent,
target: "left-panel"
}]}).then(({ Plugin, store }) => {
try {
ReactDOM.render(<Plugin />, document.getElementById("container"));
expect(store.getState()?.print?.spec?.params?.custom).toNotBe(undefined);
done();
} catch (ex) {
done(ex);
}
});
});

it('custom plugin replaces existing', (done) => {
getPrintPlugin({items: [{
plugin: CustomComponent,
target: "name"
}]}).then(({ Plugin }) => {
try {
ReactDOM.render(<Plugin />, document.getElementById("container"));
expect(getByXPath("//*[text()='print.title']")).toNotExist();
expect(getByXPath("//*[text()='print.mycustomplugin']")).toExist();
done();
} catch (ex) {
done(ex);
}
});
});

it('custom plugin removes existing', (done) => {
getPrintPlugin({items: [{
plugin: Null,
target: "name"
}]}).then(({ Plugin }) => {
try {
ReactDOM.render(<Plugin />, document.getElementById("container"));
expect(getByXPath("//*[text()='print.title']")).toNotExist();
done();
} catch (ex) {
done(ex);
}
});
});
});
91 changes: 83 additions & 8 deletions web/client/plugins/__tests__/pluginsTestUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import PropTypes from "prop-types";
import endsWith from 'lodash/endsWith';
import castArray from 'lodash/castArray';
import flatten from 'lodash/flatten';
Expand All @@ -23,6 +24,8 @@ import annotations from '../../reducers/annotations';
import context from '../../reducers/context';
import security from '../../reducers/security';

import { getPlugins } from "../../utils/PluginsUtils";

// StandardStore add by default current reducers
const rootReducers = {
map,
Expand All @@ -41,13 +44,45 @@ const createRegisterActionsMiddleware = (actions) => {
};
};

function getImplementation(pluginDef) {
return Object.keys(pluginDef).reduce((previous, key) => {
if (endsWith(key, 'Plugin')) {
return pluginDef[key];
}
return previous;
}, null);
}

class PluginsContext extends React.Component {
static propTypes = {
plugins: PropTypes.object
};
static defaultProps = {
plugins: {}
};
static childContextTypes = {
plugins: PropTypes.object
};
getChildContext() {
return {
plugins: this.props.plugins
};
}
render() {
return <>{this.props.children}</>;
}
}

/**
* Helper to get a plugin configured for testing.
*
* @param {object} pluginDef plugin definition as loaded from require / import
* @param {object} storeState optional initial state for redux store (overrides default store built using plugin's reducers)
* @param {object} [plugins] optional plugins definition list ({MyPlugin: <definition>, ...}), used to filter available containers
* @params {function|function[]} [testEpics] optional epics to intercept actions for test
* @param {function|function[]} [containersReducers] optional additional reducers, that will be enabled, in addition to those defined by the plugin itself
* @param {object} [additionalPlugins] optional list of plugin definitions that will be added to the Plugin context
* @param {object[]} [items] optional list of plugin items (subplugins)
* @returns {object} an object with the following properties:
* - Plugin: plugin propertly connected to a mocked store
* - store: the mocked store
Expand All @@ -58,13 +93,8 @@ const createRegisterActionsMiddleware = (actions) => {
* import MyPlugin from './MyPlugin';
* const { Plugin, store, actions, containers } = getPluginForTest(MyPlugin, {}, {ContainerPlugin: {}});
*/
export const getPluginForTest = (pluginDef, storeState, plugins, testEpics = [], containersReducers, actions = [] ) => {
const PluginImpl = Object.keys(pluginDef).reduce((previous, key) => {
if (endsWith(key, 'Plugin')) {
return pluginDef[key];
}
return previous;
}, null);
export const getPluginForTest = (pluginDef, storeState, plugins, testEpics = [], containersReducers, actions = [], additionalPlugins = {}, items = [] ) => {
const PluginImpl = getImplementation(pluginDef);
const containers = Object.keys(PluginImpl)
.filter(prop => !plugins || Object.keys(plugins).indexOf(prop + 'Plugin') !== -1)
.reduce((previous, key) => {
Expand All @@ -87,12 +117,57 @@ export const getPluginForTest = (pluginDef, storeState, plugins, testEpics = [],
const epicMiddleware = createEpicMiddleware(rootEpic);

const store = applyMiddleware(thunkMiddleware, epicMiddleware, createRegisterActionsMiddleware(actions))(createStore)(reducer, storeState);
const pluginProps = items?.length ? {items} : undefined;
return {
PluginImpl,
Plugin: (props) => <Provider store={store}><PluginImpl {...props} /></Provider>,
Plugin: (props) => <Provider store={store}><PluginsContext plugins={getPlugins(additionalPlugins)}><PluginImpl {...props} {...pluginProps}/></PluginsContext></Provider>,
store,
actions,
containers
};
};

/**
* Helper to get a lazy loaded plugin configured for testing.
*
* @param {object} pluginDef plugin definition as loaded from require / import
* @param {object} storeState optional initial state for redux store (overrides default store built using plugin's reducers)
* @param {object} [plugins] optional plugins definition list ({MyPlugin: <definition>, ...}), used to filter available containers
* @params {function|function[]} [testEpics] optional epics to intercept actions for test
* @param {function|function[]} [containersReducers] optional additional reducers, that will be enabled, in addition to those defined by the plugin itself
* @param {object} [additionalPlugins] optional list of plugin definitions that will be added to the Plugin context
* @param {object[]} [items] optional list of plugin items (subplugins)
* @returns {Promise<object>} an object with the following properties:
* - Plugin: plugin propertly connected to a mocked store
* - store: the mocked store
* - actions: list of dispatched actions (can be read at any time to test actions launched)
* - containers: list of plugins supported containers
*
* @example
* import MyPlugin from './MyPlugin';
* getLazyPluginForTest(MyPlugin, {}, {ContainerPlugin: {}}).then(({ Plugin, store, actions, containers }) => {
* ...
* });
*/
export const getLazyPluginForTest = ({
plugin,
storeState = {},
plugins = {},
testEpics = [],
containersReducers,
actions = [],
additionalPlugins = {},
items = []} ) => {
const PluginImpl = getImplementation(plugin);
if (PluginImpl.loadPlugin) {
return new Promise((resolve) => {
PluginImpl.loadPlugin((lazy) => {
resolve(getPluginForTest({
...plugin,
LazyPlugin: lazy
}, storeState, plugins, testEpics, containersReducers, actions, additionalPlugins, items));
});
});
}
return Promise.resolve(getPluginForTest(plugin, storeState, plugins, testEpics, containersReducers, actions, additionalPlugins, items));
};
32 changes: 32 additions & 0 deletions web/client/plugins/print/ActionButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from "react";
import {connect} from "react-redux";
import {createPlugin, handleExpression} from "../../utils/PluginsUtils";
import {Glyphicon, Button} from "react-bootstrap";
import Spinner from 'react-spinkit';
import Message from "../../components/I18N/Message";

export const ActionButton = (props) => {
const {text, actions, action, enabled, actionConfig, loading, className = ""} = props;
const {glyph, buttonConfig} = actionConfig;
const icon = glyph ? <Glyphicon glyph={glyph}/> : <span/>;
const enable = !!handleExpression({}, {...props}, "{" + enabled + "}");
return (
<Button className={className} disabled={!enable} {...buttonConfig} style={{marginTop: "10px", marginRight: "5px"}} onClick={actions[action]}>
{loading ? <Spinner spinnerName="circle" overrideSpinnerClassName="spinner" noFadeIn /> : icon} <Message msgId={text}/>
</Button>
);
};

export default createPlugin("Action", {
component: connect(
(state) => ({
spec: state?.print?.spec || {},
loading: state.print && state.print.isLoading || false
})
)(ActionButton),
containers: {
Print: {
priority: 1
}
}
});
Loading