From 16a3542463931c89af74c4d1e6bb76ba415ce9b7 Mon Sep 17 00:00:00 2001 From: stefano bovio Date: Tue, 6 Nov 2018 10:42:07 +0100 Subject: [PATCH] Fix #3224 Style Editor Plugin (GeoCSS) (#3273) * Added additional layers actions, selectors and reducers * Improved tabs management of TOCItemsSettings and updated settings params function * Added Style Editor plugin --- .../__tests__/additionallayers-test.js | 57 ++ web/client/actions/__tests__/layers-test.js | 13 +- .../actions/__tests__/styleeditor-test.js | 171 ++++++ web/client/actions/additionallayers.js | 80 +++ web/client/actions/layerCapabilities.js | 3 + web/client/actions/layers.js | 13 +- web/client/actions/styleeditor.js | 243 ++++++++ web/client/api/geoserver/Layers.js | 69 ++- web/client/api/geoserver/Styles.js | 142 ++++- .../api/geoserver/__tests__/Layers-test.js | 28 + .../api/geoserver/__tests__/Styles-test.js | 69 +++ .../components/TOC/TOCItemsSettings.jsx | 40 +- .../TOC/__tests__/TOCItemsSettings-test.jsx | 91 +++ .../__tests__/tocitemssettings-test.js | 57 +- .../TOC/enhancers/tocItemsSettings.js | 39 +- web/client/components/misc/ResizableModal.jsx | 6 +- .../misc/__tests__/ResizableModal-test.jsx | 6 + .../components/misc/cardgrids/SquareCard.jsx | 34 ++ .../cardgrids/__tests__/SquareCard-test.jsx | 84 +++ .../components/misc/panels/PanelHeader.jsx | 4 +- .../misc/panels/__tests__/DockPanel-test.jsx | 2 +- .../panels/__tests__/PanelHeader-test.jsx | 38 +- web/client/components/styleeditor/Editor.jsx | 255 +++++++++ .../components/styleeditor/SVGPreview.jsx | 57 ++ .../components/styleeditor/StyleList.jsx | 97 ++++ .../components/styleeditor/StyleTemplates.jsx | 149 +++++ .../components/styleeditor/StyleToolbar.jsx | 155 +++++ .../styleeditor/__tests__/Editor-test.jsx | 158 +++++ .../styleeditor/__tests__/SVGPreview-test.jsx | 142 +++++ .../styleeditor/__tests__/StyleList-test.jsx | 163 ++++++ .../__tests__/StyleTemplates-test.jsx | 111 ++++ .../__tests__/StyleToolbar-test.jsx | 56 ++ .../components/styleeditor/hint/geocss.js | 107 ++++ .../components/styleeditor/mode/geocss.js | 492 ++++++++++++++++ web/client/epics/__tests__/layers-test.js | 100 +++- .../epics/__tests__/styleeditor-test.js | 540 ++++++++++++++++++ web/client/epics/layers.js | 47 +- web/client/epics/styleeditor.js | 508 ++++++++++++++++ web/client/localConfig.json | 3 +- web/client/plugins/Map.jsx | 3 +- web/client/plugins/StyleEditor.jsx | 172 ++++++ web/client/plugins/TOCItemsSettings.jsx | 27 +- web/client/plugins/styleeditor/index.js | 247 ++++++++ .../plugins/styleeditor/inlineWidgets.js | 32 ++ .../tocitemssettings/defaultSettingsTabs.js | 14 +- web/client/product/plugins.js | 3 +- .../__tests__/additionallayers-test.js | 140 +++++ .../reducers/__tests__/styleeditor-test.js | 133 +++++ web/client/reducers/additionallayers.js | 52 ++ web/client/reducers/styleeditor.js | 117 ++++ .../__tests__/additionallayers-test.js | 52 ++ .../selectors/__tests__/controls-test.js | 39 +- web/client/selectors/__tests__/layers-test.js | 38 ++ .../selectors/__tests__/styleeditor-test.js | 435 ++++++++++++++ web/client/selectors/additionallayers.js | 28 + web/client/selectors/controls.js | 5 +- web/client/selectors/layers.js | 15 +- web/client/selectors/styleeditor.js | 191 +++++++ .../geoserver/rest/layers/TEST_LAYER_2.json | 30 + .../geoserver/rest/layers/TEST_LAYER_3.json | 33 ++ .../rest/styles/test_TEST_LAYER_1.json | 10 + .../geoserver/rest/styles/test_style | 10 + .../geoserver/rest/styles/test_style.css | 1 + .../geoserver/rest/styles/test_style.json | 10 + web/client/themes/default/less/common.less | 24 + web/client/themes/default/less/modal.less | 19 + web/client/themes/default/less/panels.less | 5 + .../themes/default/less/rulesmanager.less | 13 - web/client/themes/default/less/sidegrid.less | 27 + .../themes/default/less/style-editor.less | 218 +++++++ web/client/themes/default/ms2-theme.less | 1 + web/client/translations/data.de-DE | 43 ++ web/client/translations/data.en-US | 43 ++ web/client/translations/data.es-ES | 43 ++ web/client/translations/data.fr-FR | 43 ++ web/client/translations/data.hr-HR | 43 ++ web/client/translations/data.it-IT | 45 +- web/client/translations/data.nl-NL | 43 ++ web/client/translations/data.zh-ZH | 43 ++ web/client/utils/StyleEditorUtils.js | 145 +++++ .../utils/__tests__/StyleEditorUtils-test.js | 131 +++++ .../utils/styleeditor/stylesTemplates.js | 391 +++++++++++++ 82 files changed, 7425 insertions(+), 161 deletions(-) create mode 100644 web/client/actions/__tests__/additionallayers-test.js create mode 100644 web/client/actions/__tests__/styleeditor-test.js create mode 100644 web/client/actions/additionallayers.js create mode 100644 web/client/actions/styleeditor.js create mode 100644 web/client/components/misc/cardgrids/SquareCard.jsx create mode 100644 web/client/components/misc/cardgrids/__tests__/SquareCard-test.jsx create mode 100644 web/client/components/styleeditor/Editor.jsx create mode 100644 web/client/components/styleeditor/SVGPreview.jsx create mode 100644 web/client/components/styleeditor/StyleList.jsx create mode 100644 web/client/components/styleeditor/StyleTemplates.jsx create mode 100644 web/client/components/styleeditor/StyleToolbar.jsx create mode 100644 web/client/components/styleeditor/__tests__/Editor-test.jsx create mode 100644 web/client/components/styleeditor/__tests__/SVGPreview-test.jsx create mode 100644 web/client/components/styleeditor/__tests__/StyleList-test.jsx create mode 100644 web/client/components/styleeditor/__tests__/StyleTemplates-test.jsx create mode 100644 web/client/components/styleeditor/__tests__/StyleToolbar-test.jsx create mode 100644 web/client/components/styleeditor/hint/geocss.js create mode 100644 web/client/components/styleeditor/mode/geocss.js create mode 100644 web/client/epics/__tests__/styleeditor-test.js create mode 100644 web/client/epics/styleeditor.js create mode 100644 web/client/plugins/StyleEditor.jsx create mode 100644 web/client/plugins/styleeditor/index.js create mode 100644 web/client/plugins/styleeditor/inlineWidgets.js create mode 100644 web/client/reducers/__tests__/additionallayers-test.js create mode 100644 web/client/reducers/__tests__/styleeditor-test.js create mode 100644 web/client/reducers/additionallayers.js create mode 100644 web/client/reducers/styleeditor.js create mode 100644 web/client/selectors/__tests__/additionallayers-test.js create mode 100644 web/client/selectors/__tests__/styleeditor-test.js create mode 100644 web/client/selectors/additionallayers.js create mode 100644 web/client/selectors/styleeditor.js create mode 100644 web/client/test-resources/geoserver/rest/layers/TEST_LAYER_2.json create mode 100644 web/client/test-resources/geoserver/rest/layers/TEST_LAYER_3.json create mode 100644 web/client/test-resources/geoserver/rest/styles/test_TEST_LAYER_1.json create mode 100644 web/client/test-resources/geoserver/rest/styles/test_style create mode 100644 web/client/test-resources/geoserver/rest/styles/test_style.css create mode 100644 web/client/test-resources/geoserver/rest/styles/test_style.json create mode 100644 web/client/themes/default/less/style-editor.less create mode 100644 web/client/utils/StyleEditorUtils.js create mode 100644 web/client/utils/__tests__/StyleEditorUtils-test.js create mode 100644 web/client/utils/styleeditor/stylesTemplates.js diff --git a/web/client/actions/__tests__/additionallayers-test.js b/web/client/actions/__tests__/additionallayers-test.js new file mode 100644 index 0000000000..63efd27714 --- /dev/null +++ b/web/client/actions/__tests__/additionallayers-test.js @@ -0,0 +1,57 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const expect = require('expect'); + +const { + UPDATE_ADDITIONAL_LAYER, + REMOVE_ADDITIONAL_LAYER, + UPDATE_OPTIONS_BY_OWNER, + updateAdditionalLayer, + updateOptionsByOwner, + removeAdditionalLayer +} = require('../additionallayers'); + +describe('Test additional layers actions', () => { + + it('Test updateAdditionalLayer action creator', () => { + const id = 'layer_001'; + const owner = 'owner'; + const actionType = 'override'; + const options = { + style: 'generic' + }; + const retval = updateAdditionalLayer(id, owner, actionType, options); + expect(retval).toExist(); + expect(retval.id).toBe(id); + expect(retval.owner).toBe(owner); + expect(retval.actionType).toBe(actionType); + expect(retval.options).toBe(options); + expect(retval.type).toBe(UPDATE_ADDITIONAL_LAYER); + }); + + it('Test updateOptionsByOwner action creator', () => { + const owner = 'owner'; + const options = [{ style: 'point' }]; + const retval = updateOptionsByOwner(owner, options); + expect(retval).toExist(); + expect(retval.owner).toBe(owner); + expect(retval.options).toBe(options); + expect(retval.type).toBe(UPDATE_OPTIONS_BY_OWNER); + }); + + it('Test removeAdditionalLayer action creator', () => { + const id = 'layer_001'; + const owner = 'owner'; + const retval = removeAdditionalLayer({id, owner}); + expect(retval).toExist(); + expect(retval.id).toBe(id); + expect(retval.owner).toBe(owner); + expect(retval.type).toBe(REMOVE_ADDITIONAL_LAYER); + }); +}); diff --git a/web/client/actions/__tests__/layers-test.js b/web/client/actions/__tests__/layers-test.js index 2ee50fa74f..48bf237be4 100644 --- a/web/client/actions/__tests__/layers-test.js +++ b/web/client/actions/__tests__/layers-test.js @@ -31,6 +31,7 @@ var { FILTER_LAYERS, SHOW_LAYER_METADATA, HIDE_LAYER_METADATA, + UPDATE_SETTINGS_PARAMS, changeLayerProperties, toggleNode, sortNode, @@ -53,7 +54,8 @@ var { selectNode, filterLayers, showLayerMetadata, - hideLayerMetadata + hideLayerMetadata, + updateSettingsParams } = require('../layers'); var {getLayerCapabilities} = require('../layerCapabilities'); @@ -293,4 +295,13 @@ describe('Test correctness of the layers actions', () => { const action = hideLayerMetadata(); expect(action.type).toBe(HIDE_LAYER_METADATA); }); + + it('update settings params', () => { + const newParams = { style: 'new_style' }; + const update = true; + const action = updateSettingsParams(newParams, update); + expect(action.type).toBe(UPDATE_SETTINGS_PARAMS); + expect(action.newParams).toBe(newParams); + expect(action.update).toBe(update); + }); }); diff --git a/web/client/actions/__tests__/styleeditor-test.js b/web/client/actions/__tests__/styleeditor-test.js new file mode 100644 index 0000000000..e80ad9dfd1 --- /dev/null +++ b/web/client/actions/__tests__/styleeditor-test.js @@ -0,0 +1,171 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const expect = require('expect'); + +const { + UPDATE_TEMPORARY_STYLE, + UPDATE_STATUS, + TOGGLE_STYLE_EDITOR, + RESET_STYLE_EDITOR, + SELECT_STYLE_TEMPLATE, + CREATE_STYLE, + LOADING_STYLE, + LOADED_STYLE, + ADD_STYLE, + ERROR_STYLE, + UPDATE_STYLE_CODE, + EDIT_STYLE_CODE, + DELETE_STYLE, + INIT_STYLE_SERVICE, + SET_EDIT_PERMISSION, + updateTemporaryStyle, + updateStatus, + toggleStyleEditor, + resetStyleEditor, + selectStyleTemplate, + createStyle, + loadingStyle, + loadedStyle, + addStyle, + errorStyle, + updateStyleCode, + editStyleCode, + deleteStyle, + initStyleService, + setEditPermissionStyleEditor +} = require('../styleeditor'); + +describe('Test the styleeditor actions', () => { + + it('updateTemporaryStyle', () => { + const temporaryId = '3214'; + const templateId = '4567'; + const code = '* { stroke: #333333; }'; + const format = 'css'; + const init = true; + const retval = updateTemporaryStyle({ + temporaryId, + templateId, + code, + format, + init + }); + expect(retval).toExist(); + expect(retval.type).toBe(UPDATE_TEMPORARY_STYLE); + expect(retval.temporaryId).toBe(temporaryId); + expect(retval.templateId).toBe(templateId); + expect(retval.code).toBe(code); + expect(retval.format).toBe(format); + expect(retval.init).toBe(init); + }); + it('updateStatus', () => { + const status = 'edit'; + const retval = updateStatus(status); + expect(retval).toExist(); + expect(retval.type).toBe(UPDATE_STATUS); + expect(retval.status).toBe(status); + }); + it('toggleStyleEditor', () => { + const layer = {id: 'layerId', name: 'layerName'}; + const enabled = true; + const retval = toggleStyleEditor(layer, enabled); + expect(retval).toExist(); + expect(retval.type).toBe(TOGGLE_STYLE_EDITOR); + expect(retval.layer).toBe(layer); + expect(retval.enabled).toBe(enabled); + }); + it('resetStyleEditor', () => { + const retval = resetStyleEditor(); + expect(retval).toExist(); + expect(retval.type).toBe(RESET_STYLE_EDITOR); + }); + it('selectStyleTemplate', () => { + const templateId = '4567'; + const code = '* { stroke: #333333; }'; + const format = 'css'; + const init = true; + const retval = selectStyleTemplate({ code, templateId, format, init }); + expect(retval).toExist(); + expect(retval.type).toBe(SELECT_STYLE_TEMPLATE); + expect(retval.templateId).toBe(templateId); + expect(retval.code).toBe(code); + expect(retval.format).toBe(format); + expect(retval.init).toBe(init); + }); + it('createStyle', () => { + const settings = { title: 'Title', _abstract: ''}; + const retval = createStyle(settings); + expect(retval).toExist(); + expect(retval.type).toBe(CREATE_STYLE); + expect(retval.settings).toBe(settings); + }); + it('loadingStyle', () => { + const status = 'edit'; + const retval = loadingStyle(status); + expect(retval).toExist(); + expect(retval.type).toBe(LOADING_STYLE); + expect(retval.status).toBe(status); + }); + it('loadedStyle', () => { + const retval = loadedStyle(); + expect(retval).toExist(); + expect(retval.type).toBe(LOADED_STYLE); + }); + it('addStyle', () => { + const add = true; + const retval = addStyle(add); + expect(retval).toExist(); + expect(retval.type).toBe(ADD_STYLE); + expect(retval.add).toBe(add); + }); + it('errorStyle', () => { + const status = 'edit'; + const error = { statusText: 'Not found', status: 404 }; + const retval = errorStyle(status, error); + expect(retval).toExist(); + expect(retval.type).toBe(ERROR_STYLE); + expect(retval.status).toBe(status); + expect(retval.error).toBe(error); + }); + it('updateStyleCode', () => { + const retval = updateStyleCode(); + expect(retval).toExist(); + expect(retval.type).toBe(UPDATE_STYLE_CODE); + }); + it('editStyleCode', () => { + const code = '* { stroke: #ff0000; }'; + const retval = editStyleCode(code); + expect(retval).toExist(); + expect(retval.type).toBe(EDIT_STYLE_CODE); + expect(retval.code).toBe(code); + }); + it('deleteStyle', () => { + const styleName = 'name'; + const retval = deleteStyle(styleName); + expect(retval).toExist(); + expect(retval.type).toBe(DELETE_STYLE); + expect(retval.styleName).toBe(styleName); + }); + it('initStyleService', () => { + const service = { baseUrl: '/geoserver/' }; + const canEdit = true; + const retval = initStyleService(service, canEdit); + expect(retval).toExist(); + expect(retval.type).toBe(INIT_STYLE_SERVICE); + expect(retval.service).toBe(service); + expect(retval.canEdit).toBe(canEdit); + }); + it('setEditPermissionStyleEditor', () => { + const canEdit = true; + const retval = setEditPermissionStyleEditor(canEdit); + expect(retval).toExist(); + expect(retval.type).toBe(SET_EDIT_PERMISSION); + expect(retval.canEdit).toBe(canEdit); + }); +}); diff --git a/web/client/actions/additionallayers.js b/web/client/actions/additionallayers.js new file mode 100644 index 0000000000..d6678e566c --- /dev/null +++ b/web/client/actions/additionallayers.js @@ -0,0 +1,80 @@ + +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const UPDATE_ADDITIONAL_LAYER = 'ADDITIONALLAYER:UPDATE_ADDITIONAL_LAYER'; +const UPDATE_OPTIONS_BY_OWNER = 'ADDITIONALLAYER:UPDATE_OPTIONS_BY_OWNER'; +const REMOVE_ADDITIONAL_LAYER = 'ADDITIONALLAYER:REMOVE_ADDITIONAL_LAYER'; + +/** + * Add/updated an additional layer to the list. + * Additional layer will be update only if id match with an existing one. + * @memberof actions.additionallayers + * @param {string} id identifier + * @param {string} owner a string that define the plugin is using following layer + * @param {string} actionType type of action to perform in the layer selector, currently only `override` is supported + * @param {string} options layer properties to apply based on the actionType, + * eg: in case of actionType = `override` object options will be merged with the layer object with same id + * @return {object} of type `UPDATE_ADDITIONAL_LAYER` with id, owner, actionType, settings and options + */ +const updateAdditionalLayer = (id, owner, actionType = 'override', options) => { + return { + type: UPDATE_ADDITIONAL_LAYER, + id, + owner, + actionType, + options + }; +}; + +/** + * Update options of addibinal layers selected by owner + * @memberof actions.additionallayers + * @param {string} owner string that define the plugin is using following layers + * @param {array|object} options an array of options or an object with key equal to ids, eg: [ {style: 'generic'}, {style: ''} ] | { firstLayerId: {style: 'generic'}, secondLayerId: {style: ''} } + * @return {object} of type `UPDATE_OPTIONS_BY_OWNER` with owner and options + */ +const updateOptionsByOwner = (owner, options) => { + return { + type: UPDATE_OPTIONS_BY_OWNER, + owner, + options + }; +}; + +/** + * Remove additional layers by id or owner. + * If owner is defined all layers in the same owner group will be deleted. + * owner key has priority. + * @memberof actions.additionallayers + * @param {object} identifier and object with id or owner keys, eg: { id: 'firstLayerId', ower: 'myplugin' } + * @return {object} of type `REMOVE_ADDITIONAL_LAYER` id and owner + */ +const removeAdditionalLayer = ({id, owner} = {}) => { + return { + type: REMOVE_ADDITIONAL_LAYER, + id, + owner + }; +}; + +/** + * Actions for additionallayers. + * Additional layers will be used to perform override action on the layers without apply new proprties to the original layer object. + * It can be used to preview changes of the layers. + * @name actions.additionallayers + */ + +module.exports = { + UPDATE_ADDITIONAL_LAYER, + updateAdditionalLayer, + REMOVE_ADDITIONAL_LAYER, + removeAdditionalLayer, + UPDATE_OPTIONS_BY_OWNER, + updateOptionsByOwner +}; diff --git a/web/client/actions/layerCapabilities.js b/web/client/actions/layerCapabilities.js index 9ecbf4dd70..39ed937385 100644 --- a/web/client/actions/layerCapabilities.js +++ b/web/client/actions/layerCapabilities.js @@ -46,6 +46,9 @@ function getDescribeLayer(url, layer, options) { } return dispatch(updateNode(layer.id, "id", {describeLayer: describeLayer || {"error": "no describe Layer found"}})); + }) + .catch((error) => { + return dispatch(updateNode(layer.id, "id", {describeLayer: {"error": error.status}})); }); }; } diff --git a/web/client/actions/layers.js b/web/client/actions/layers.js index 9c66af9726..949ef50124 100644 --- a/web/client/actions/layers.js +++ b/web/client/actions/layers.js @@ -33,6 +33,7 @@ const SELECT_NODE = 'LAYERS:SELECT_NODE'; const FILTER_LAYERS = 'LAYERS:FILTER_LAYERS'; const SHOW_LAYER_METADATA = 'LAYERS:SHOW_LAYER_METADATA'; const HIDE_LAYER_METADATA = 'LAYERS:HIDE_LAYER_METADATA'; +const UPDATE_SETTINGS_PARAMS = 'LAYERS:UPDATE_SETTINGS_PARAMS'; function showSettings(node, nodeType, options) { return { @@ -257,13 +258,21 @@ function hideLayerMetadata() { }; } +function updateSettingsParams(newParams, update) { + return { + type: UPDATE_SETTINGS_PARAMS, + newParams, + update + }; +} + module.exports = { changeLayerProperties, changeLayerParams, changeGroupProperties, toggleNode, sortNode, removeNode, contextNode, updateNode, layerLoading, layerLoad, layerError, addLayer, removeLayer, showSettings, hideSettings, updateSettings, refreshLayers, layersRefreshed, layersRefreshError, refreshLayerVersion, updateLayerDimension, browseData, clearLayers, selectNode, filterLayers, showLayerMetadata, - hideLayerMetadata, download, + hideLayerMetadata, download, updateSettingsParams, CHANGE_LAYER_PROPERTIES, CHANGE_LAYER_PARAMS, CHANGE_GROUP_PROPERTIES, TOGGLE_NODE, SORT_NODE, REMOVE_NODE, UPDATE_NODE, LAYER_LOADING, LAYER_LOAD, LAYER_ERROR, ADD_LAYER, REMOVE_LAYER, SHOW_SETTINGS, HIDE_SETTINGS, UPDATE_SETTINGS, CONTEXT_NODE, REFRESH_LAYERS, LAYERS_REFRESHED, LAYERS_REFRESH_ERROR, UPDATE_LAYERS_DIMENSION, BROWSE_DATA, DOWNLOAD, - CLEAR_LAYERS, SELECT_NODE, FILTER_LAYERS, SHOW_LAYER_METADATA, HIDE_LAYER_METADATA + CLEAR_LAYERS, SELECT_NODE, FILTER_LAYERS, SHOW_LAYER_METADATA, HIDE_LAYER_METADATA, UPDATE_SETTINGS_PARAMS }; diff --git a/web/client/actions/styleeditor.js b/web/client/actions/styleeditor.js new file mode 100644 index 0000000000..f6cb39d251 --- /dev/null +++ b/web/client/actions/styleeditor.js @@ -0,0 +1,243 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const TOGGLE_STYLE_EDITOR = 'STYLEEDITOR:TOGGLE_STYLE_EDITOR'; +const SELECT_STYLE_TEMPLATE = 'STYLEEDITOR:SELECT_STYLE_TEMPLATE'; +const UPDATE_TEMPORARY_STYLE = 'STYLEEDITOR:UPDATE_TEMPORARY_STYLE'; +const UPDATE_STATUS = 'STYLEEDITOR:UPDATE_STATUS'; +const RESET_STYLE_EDITOR = 'STYLEEDITOR:RESET_STYLE_EDITOR'; +const ADD_STYLE = 'STYLEEDITOR:ADD_STYLE'; +const CREATE_STYLE = 'STYLEEDITOR:CREATE_STYLE'; +const LOADING_STYLE = 'STYLEEDITOR:LOADING_STYLE'; +const LOADED_STYLE = 'STYLEEDITOR:LOADED_STYLE'; +const ERROR_STYLE = 'STYLEEDITOR:ERROR_STYLE'; +const UPDATE_STYLE_CODE = 'STYLEEDITOR:UPDATE_STYLE_CODE'; +const EDIT_STYLE_CODE = 'STYLEEDITOR:EDIT_STYLE_CODE'; +const DELETE_STYLE = 'STYLEEDITOR:DELETE_STYLE'; +const INIT_STYLE_SERVICE = 'STYLEEDITOR:INIT_STYLE_SERVICE'; +const SET_EDIT_PERMISSION = 'STYLEEDITOR:SET_EDIT_PERMISSION'; + +/** +* Toggle style editor, it triggers an epic to initialize or stop the style editor +* @memberof actions.styleeditor +* @param {object} layer +* @param {bool} enabled +* @return {object} of type `TOGGLE_STYLE_EDITOR` with layer and enabled params +*/ +function toggleStyleEditor(layer, enabled) { + return { + type: TOGGLE_STYLE_EDITOR, + layer, + enabled + }; +} +/** +* Update status of style editor +* @memberof actions.styleeditor +* @param {object} status +* @return {object} of type `UPDATE_STATUS` with status +*/ +function updateStatus(status) { + return { + type: UPDATE_STATUS, + status + }; +} +/** +* Update template style data sending a request to the service +* @memberof actions.styleeditor +* @param {object} styleProps { code, templateId, format, init } init set initialCode +* @return {object} of type `SELECT_STYLE_TEMPLATE` styleProps +*/ +function selectStyleTemplate({ code, templateId, format, init } = {}) { + return { + type: SELECT_STYLE_TEMPLATE, + code, + templateId, + format, + init + }; +} +/** +* Store all temporary style information if request of create a temporary style has success +* @memberof actions.styleeditor +* @param {object} styleProps { temporaryId, templateId, code, format, init } init set initialCode +* @return {object} of type `UPDATE_TEMPORARY_STYLE` styleProps +*/ +function updateTemporaryStyle({ temporaryId, templateId, code, format, init } = {}) { + return { + type: UPDATE_TEMPORARY_STYLE, + temporaryId, + templateId, + code, + format, + init + }; +} +/** +* Start loading state of style editor +* @memberof actions.styleeditor +* @param {string|bool} status +* @return {object} of type `LOADING_STYLE` with status +*/ +function loadingStyle(status) { + return { + type: LOADING_STYLE, + status + }; +} +/** +* Stop loading state of style editor +* @memberof actions.styleeditor +* @return {object} of type `LOADED_STYLE` +*/ +function loadedStyle() { + return { + type: LOADED_STYLE + }; +} +/** +* Send settings to epic to create a new style +* @memberof actions.styleeditor +* @param {object} settings { title: '', _abstract: '' } +* @return {object} of type `CREATE_STYLE` +*/ +function createStyle(settings) { + return { + type: CREATE_STYLE, + settings + }; +} +/** +* Reset style editor state +* @memberof actions.styleeditor +* @return {object} of type `RESET_STYLE_EDITOR` +*/ +function resetStyleEditor() { + return { + type: RESET_STYLE_EDITOR + }; +} + +function addStyle(add) { + return { + type: ADD_STYLE, + add + }; +} +/** +* Set error of style editor +* @memberof actions.styleeditor +* @param {string} status { title: '', _abstract: '' } +* @param {object} error error object +* @return {object} of type `ERROR_STYLE` +*/ +function errorStyle(status, error) { + return { + type: ERROR_STYLE, + status, + error + }; +} +/** +* Update and save original style in editing +* @memberof actions.styleeditor +* @return {object} of type `UPDATE_STYLE_CODE` +*/ +function updateStyleCode() { + return { + type: UPDATE_STYLE_CODE + }; +} +/** +* Triggers an epic to update current code in editing (temporary style) by sending a request to the service +* @memberof actions.styleeditor +* @param {string} code edited code +* @return {object} of type `EDIT_STYLE_CODE` +*/ +function editStyleCode(code) { + return { + type: EDIT_STYLE_CODE, + code + }; +} +/** +* Delete a style +* @memberof actions.styleeditor +* @param {string} styleName name of style +* @return {object} of type `DELETE_STYLE` +*/ +function deleteStyle(styleName) { + return { + type: DELETE_STYLE, + styleName + }; +} +/** +* Setup the style editor service +* @memberof actions.styleeditor +* @param {object} service style editor service +* @param {bool} canEdit flag to enable/disable style editor in current session +* @return {object} of type `INIT_STYLE_SERVICE` +*/ +function initStyleService(service, canEdit) { + return { + type: INIT_STYLE_SERVICE, + service, + canEdit + }; +} +/** +* Enable/disable style editor in current session, after resetStyleEditor canEdit is true +* @memberof actions.styleeditor +* @param {bool} canEdit flag to enable/disable style editor in current session +* @return {object} of type `SET_EDIT_PERMISSION` +*/ +function setEditPermissionStyleEditor(canEdit) { + return { + type: SET_EDIT_PERMISSION, + canEdit + }; +} + +/** +* Actions for styleeditor +* @name actions.styleeditor +*/ +module.exports = { + UPDATE_TEMPORARY_STYLE, + UPDATE_STATUS, + TOGGLE_STYLE_EDITOR, + RESET_STYLE_EDITOR, + SELECT_STYLE_TEMPLATE, + CREATE_STYLE, + LOADING_STYLE, + LOADED_STYLE, + ADD_STYLE, + ERROR_STYLE, + UPDATE_STYLE_CODE, + EDIT_STYLE_CODE, + DELETE_STYLE, + INIT_STYLE_SERVICE, + SET_EDIT_PERMISSION, + updateTemporaryStyle, + updateStatus, + toggleStyleEditor, + resetStyleEditor, + selectStyleTemplate, + createStyle, + loadingStyle, + loadedStyle, + addStyle, + errorStyle, + updateStyleCode, + editStyleCode, + deleteStyle, + initStyleService, + setEditPermissionStyleEditor +}; diff --git a/web/client/api/geoserver/Layers.js b/web/client/api/geoserver/Layers.js index bbcaf0173e..1be777aadc 100644 --- a/web/client/api/geoserver/Layers.js +++ b/web/client/api/geoserver/Layers.js @@ -6,11 +6,78 @@ * LICENSE file in the root directory of this source tree. */ const axios = require('../../libs/ajax'); +const { getNameParts } = require('../../utils/StyleEditorUtils'); -var Api = { +/** +* Api for GeoServer layers via rest +* @name api.geoserver +*/ +const Api = { getLayer: function(geoserverBaseUrl, layerName, options) { let url = geoserverBaseUrl + "layers/" + layerName + ".json"; return axios.get(url, options).then((response) => {return response.data && response.data.layer; }); + }, + /** + * Remove styles from available styles of geoserver layer object + * @memberof api.geoserver + * @param {object} params {baseUrl, layerName, styles = [], options = {}} + * @param {string} params.baseUrl base url of GeoServer eg: /geoserver/ + * @param {array} params.styles array of style to remove to geoserver layer object + * @param {string} params.layerName name of layer + * @return {object} geoserver layer object with updated styles + */ + removeStyles: ({baseUrl, layerName, styles = [], options = {}}) => { + const { name, workspace } = getNameParts(layerName); + const url = `${baseUrl}rest/${workspace && `workspaces/${workspace}/` || ''}layers/${name}.json`; + return axios.get(url, options) + .then(({data}) => { + const layer = data.layer || {}; + const currentAvailableStyle = layer.styles && layer.styles.style || []; + const stylesNames = styles.map(({name: styleName}) => styleName); + const layerObj = { + 'layer': { + ...layer, + 'styles': { + '@class': 'linked-hash-set', + 'style': currentAvailableStyle.filter(({name: styleName}) => stylesNames.indexOf(styleName) === -1) + } + } + }; + return layerObj; + }) + .then(layerObj => axios.put(url, layerObj).then(() => layerObj)); + }, + /** + * Update available styles of geoserver layer object + * @memberof api.geoserver + * @param {object} params {baseUrl, layerName, styles = [], options = {}} + * @param {string} params.baseUrl base url of GeoServer eg: /geoserver/ + * @param {array} params.styles array of style to add to geoserver layer object + * @param {string} params.layerName name of layer + * @return {object} geoserver layer object with updated styles + */ + updateAvailableStyles: ({baseUrl, layerName, styles = [], options = {}}) => { + const { name, workspace } = getNameParts(layerName); + const url = `${baseUrl}rest/${workspace && `workspaces/${workspace}/` || ''}layers/${name}.json`; + return axios.get(url, options) + .then(({data}) => { + const layer = data.layer || {}; + const currentAvailableStyle = layer.styles && layer.styles.style || {}; + const layerObj = { + 'layer': { + ...layer, + 'styles': { + '@class': 'linked-hash-set', + 'style': [ + ...currentAvailableStyle, + ...styles + ] + } + } + }; + return layerObj; + }) + .then(layerObj => axios.put(url, layerObj).then(() => layerObj)); } }; module.exports = Api; diff --git a/web/client/api/geoserver/Styles.js b/web/client/api/geoserver/Styles.js index 7d5614e674..7bb6b5bf21 100644 --- a/web/client/api/geoserver/Styles.js +++ b/web/client/api/geoserver/Styles.js @@ -7,13 +7,153 @@ */ const axios = require('../../libs/ajax'); const assign = require('object-assign'); +const { getNameParts, stringifyNameParts } = require('../../utils/StyleEditorUtils'); -var Api = { +const contentTypes = { + css: 'application/vnd.geoserver.geocss+css', + sld: 'application/vnd.ogc.sld+xml', + // sldse: 'application/vnd.ogc.se+xml', + zip: 'application/zip' +}; + +const formatRequestData = ({options = {}, format, baseUrl, name, workspace}, isNameParam) => { + const paramName = isNameParam ? {name: encodeURIComponent(name)} : {}; + const opts = { + ...options, + params: { + ...options.params, + ...paramName + }, + headers: { + ...(options.headers || {}), + 'Content-Type': contentTypes[format] + } + }; + const url = `${baseUrl}rest/${workspace && `workspaces/${workspace}/` || ''}styles/${!isNameParam ? encodeURIComponent(name) : ''}`; + return { + options: opts, + url + }; +}; + +const getStyleBaseUrl = ({geoserverBaseUrl, workspace, name, fileName}) => `${geoserverBaseUrl}rest/${workspace && `workspaces/${workspace}/` || ''}styles/${ fileName ? fileName : `${encodeURIComponent(name)}.json`}`; + +/** +* Api for GeoServer styles via rest +* @name api.geoserver +*/ +const Api = { saveStyle: function(geoserverBaseUrl, styleName, body, options) { let url = geoserverBaseUrl + "styles/" + encodeURI(styleName); let opts = assign({}, options); opts.headers = assign({}, opts.headers, {"Content-Type": "application/vnd.ogc.sld+xml"}); return axios.put(url, body, opts); + }, + /** + * Get style object + * @memberof api.geoserver + * @param {object} params {options, format, baseUrl, styleName} + * @param {string} params.baseUrl base url of GeoServer eg: /geoserver/ + * @param {string} params.format style format eg: css or sld + * @param {string} params.styleName style name + * @return {object} GeoServer style object + */ + getStyle: ({options, format, baseUrl, styleName}) => { + const {name, workspace} = getNameParts(styleName); + const data = formatRequestData({options, format, baseUrl, name, workspace}); + return axios.get(data.url, data.options); + }, + /** + * Create a new style + * @memberof api.geoserver + * @param {object} params {baseUrl, style, options, format, styleName} + * @param {string} params.baseUrl base url of GeoServer eg: /geoserver/ + * @param {string} params.format style format eg: css or sld + * @param {string} params.styleName style name + * @param {string} params.code style code + * @return {object} response + */ + createStyle: ({baseUrl, code, options, format = 'sld', styleName}) => { + const {name, workspace} = getNameParts(styleName); + const data = formatRequestData({options, format, baseUrl, name, workspace}, true); + return axios.post(data.url, code, data.options); + }, + /** + * Update a style + * @memberof api.geoserver + * @param {object} params {baseUrl, style, options, format, styleName} + * @param {string} params.baseUrl base url of GeoServer eg: /geoserver/ + * @param {string} params.format style format eg: css or sld + * @param {string} params.styleName style name + * @param {string} params.code style code + * @return {object} response + */ + updateStyle: ({baseUrl, code, options, format = 'sld', styleName}) => { + const {name, workspace} = getNameParts(styleName); + const data = formatRequestData({options, format, baseUrl, name, workspace}); + return axios.put(data.url, code, data.options); + }, + /** + * Delete a style + * @memberof api.geoserver + * @param {object} params {baseUrl, options, format, styleName} + * @param {string} params.baseUrl base url of GeoServer eg: /geoserver/ + * @param {string} params.styleName style name + * @return {object} response + */ + deleteStyle: ({baseUrl, options, format = 'sld', styleName}) => { + const {name, workspace} = getNameParts(styleName); + const data = formatRequestData({options, format, baseUrl, name, workspace}); + return axios.delete(data.url, data.options); + }, + /** + * Retrive style info an merge them to the provided list of styles + * @memberof api.geoserver + * @param {object} params {baseUrl, styles} + * @param {string} params.baseUrl base url of GeoServer eg: /geoserver/ + * @param {string} params.styles list of styles eg: [ { name: 'style.css', title: 'Style', _abstract: '' } ] + * @return {array} array of style provided with additional info as format, filename and languageVersion + */ + getStylesInfo: ({baseUrl: geoserverBaseUrl, styles = []}) => { + let responses = []; + let count = styles.length; + return new Promise(function(resolve) { + if (!styles || styles.length === 0) { + resolve([]); + } else { + styles.forEach(({name}, idx) => + axios.get(getStyleBaseUrl({...getNameParts(name), geoserverBaseUrl})) + .then(({data}) => { + responses[idx] = assign({}, styles[idx], data && data.style && {...data.style, name: stringifyNameParts(data.style)} || {}); + count--; + if (count === 0) resolve(responses.filter(val => val)); + }) + .catch(() => { + responses[idx] = assign({}, styles[idx]); + count--; + if (count === 0) resolve(responses.filter(val => val)); + }) + ); + } + }); + }, + /** + * Get style code + * @memberof api.geoserver + * @param {object} params {baseUrl, styleName, workspace} + * @param {string} params.baseUrl base url of GeoServer eg: /geoserver/ + * @param {string} params.styleName style name + * @return {object} GeoServer style object with code params eg: {...otherStyleParams, code: '* { stroke: #ff0000; }'} + */ + getStyleCodeByName: ({baseUrl: geoserverBaseUrl, styleName, options}) => { + const {name, workspace} = getNameParts(styleName); + const url = getStyleBaseUrl({name, workspace, geoserverBaseUrl}); + return axios.get(url, options) + .then(response => { + return response.data && response.data.style && response.data.style.filename ? + axios.get(getStyleBaseUrl({workspace, geoserverBaseUrl, fileName: response.data.style.filename})).then(({data: code}) => ({...response.data.style, code})) + : null; + }); } }; diff --git a/web/client/api/geoserver/__tests__/Layers-test.js b/web/client/api/geoserver/__tests__/Layers-test.js index c5e5d5baec..4dd77c7934 100644 --- a/web/client/api/geoserver/__tests__/Layers-test.js +++ b/web/client/api/geoserver/__tests__/Layers-test.js @@ -18,4 +18,32 @@ describe('Test layers rest API', () => { done(); }); }); + it('test removeStyles', (done) => { + const LAYER_NAME = "TEST_LAYER_2"; + API.removeStyles({ + baseUrl: 'base/web/client/test-resources/geoserver/', + layerName: LAYER_NAME, + styles: [{name: 'generic'}] + }).then((layer)=> { + expect(layer).toExist(); + expect(layer.layer.styles.style.length).toBe(1); + expect(layer.layer.styles.style[0].name).toBe('point'); + done(); + }); + }); + it('test updateAvailableStyles', (done) => { + const LAYER_NAME = "TEST_LAYER_2"; + API.updateAvailableStyles({ + baseUrl: 'base/web/client/test-resources/geoserver/', + layerName: LAYER_NAME, + styles: [{name: 'polygon'}] + }).then((layer)=> { + expect(layer).toExist(); + expect(layer.layer.styles.style.length).toBe(3); + expect(layer.layer.styles.style[0].name).toBe('point'); + expect(layer.layer.styles.style[1].name).toBe('generic'); + expect(layer.layer.styles.style[2].name).toBe('polygon'); + done(); + }); + }); }); diff --git a/web/client/api/geoserver/__tests__/Styles-test.js b/web/client/api/geoserver/__tests__/Styles-test.js index 3a8337cc60..25212a6bc4 100644 --- a/web/client/api/geoserver/__tests__/Styles-test.js +++ b/web/client/api/geoserver/__tests__/Styles-test.js @@ -17,4 +17,73 @@ describe('Test styles rest API', () => { done(); }); }); + it('test getStyle', (done) => { + API.getStyle({ + baseUrl: 'base/web/client/test-resources/geoserver/', + styleName: 'test_TEST_LAYER_1' + }) + .then((response)=> { + try { + expect(response.data).toEqual({ + style: { + filename: 'test_TEST_LAYER_1.sld', + format: 'sld', + languageVersion: {version: '1.0.0'}, + name: 'test_TEST_LAYER_1' + } + }); + } catch(e) { + done(e); + } + done(); + }); + }); + it('test getStylesInfo', (done) => { + API.getStylesInfo({ + baseUrl: 'base/web/client/test-resources/geoserver/', + styles: [ + { + name: 'test_TEST_LAYER_1', + title: 'Solid fill', + _abstract: 'basic style' + }, + { + name: 'test_TEST_LAYER_2', + title: 'Square', + _abstract: 'small square' + } + ] + }) + .then((response)=> { + expect(response).toEqual([{ + filename: 'test_TEST_LAYER_1.sld', + format: 'sld', + languageVersion: {version: '1.0.0'}, + name: 'test_TEST_LAYER_1', + title: 'Solid fill', + _abstract: 'basic style' + }, + { + name: 'test_TEST_LAYER_2', + title: 'Square', + _abstract: 'small square' + }]); + done(); + }); + }); + it('test getStyleCodeByName', (done) => { + API.getStyleCodeByName({ + baseUrl: 'base/web/client/test-resources/geoserver/', + styleName: 'test_style' + }).then((response)=> { + expect(response).toEqual({ + code: '* { stroke: #ff0000; }', + filename: 'test_style.css', + format: 'css', + languageVersion: {version: '1.0.0'}, + name: 'test_style' + }); + done(); + }); + }); }); diff --git a/web/client/components/TOC/TOCItemsSettings.jsx b/web/client/components/TOC/TOCItemsSettings.jsx index fd504cf0d0..01fc134f89 100644 --- a/web/client/components/TOC/TOCItemsSettings.jsx +++ b/web/client/components/TOC/TOCItemsSettings.jsx @@ -56,6 +56,18 @@ const TOCItemSettings = (props, context) => { } = props; const tabs = getTabs(props, context); + const ToolbarComponent = head(tabs.filter(tab => tab.id === activeTab && tab.toolbarComponent).map(tab => tab.toolbarComponent)); + + const tabsCloseActions = tabs && tabs.map(tab => tab && tab.onClose).filter(val => val) || []; + + const toolbarButtons = [ + { + glyph: 'floppy-disk', + tooltipId: 'save', + visible: !!onSave, + onClick: () => onSave(tabsCloseActions) + }, + ...(head(tabs.filter(tab => tab.id === activeTab && tab.toolbar).map(tab => tab.toolbar)) || [])]; return (
@@ -64,7 +76,14 @@ const TOCItemSettings = (props, context) => { glyph="wrench" title={element.title && isObject(element.title) && (element.title[currentLocale] || element.title.default) || isString(element.title) && element.title || ''} className={className} - onClose={onClose ? () => { onClose(); } : onHideSettings} + onClose={() => { + if (onClose) { + onClose(false, tabsCloseActions); + } else { + tabsCloseActions.forEach(tabOnClose => { tabOnClose(); }); + onHideSettings(); + } + }} size={width} style={dockStyle} showFullscreen={showFullscreen} @@ -74,15 +93,11 @@ const TOCItemSettings = (props, context) => { header={[ - + : tab.id === activeTab && tab.toolbar).map(tab => tab.toolbar)) || [])]}/> + buttons={toolbarButtons}/>} , ...(tabs.length > 1 ? [ @@ -93,7 +108,10 @@ const TOCItemSettings = (props, context) => { key={'ms-tab-settings-' + tab.id} tooltip={ } eventKey={tab.id} - onClick={() => onSetTab(tab.id)}> + onClick={() => { + onSetTab(tab.id); + if (tab.onClick) { tab.onClick(); } + }}> )} @@ -125,7 +143,7 @@ const TOCItemSettings = (props, context) => { { bsStyle: 'primary', text: , - onClick: () => onClose(true) + onClick: () => onClose(true, tabsCloseActions) }, { bsStyle: 'primary', diff --git a/web/client/components/TOC/__tests__/TOCItemsSettings-test.jsx b/web/client/components/TOC/__tests__/TOCItemsSettings-test.jsx index 8a21d1fd7b..deaee195bc 100644 --- a/web/client/components/TOC/__tests__/TOCItemsSettings-test.jsx +++ b/web/client/components/TOC/__tests__/TOCItemsSettings-test.jsx @@ -9,6 +9,7 @@ const React = require('react'); const expect = require('expect'); const ReactDOM = require('react-dom'); const TOCItemsSettings = require('../TOCItemsSettings'); +const TestUtils = require('react-dom/test-utils'); const layers = [ { @@ -106,4 +107,94 @@ describe("test TOCItemsSettings", () => { }); + it('test onClick function on tab', () => { + + const testHandlers = { + onClick: () => {} + }; + + const spyOnClick = expect.spyOn(testHandlers, 'onClick'); + + ReactDOM.render( [ + { + id: 'general', + titleId: 'layerProperties.general', + tooltipId: 'layerProperties.general', + glyph: 'wrench', + onClick: testHandlers.onClick, + Component: () =>
+ }, + { + id: 'display', + titleId: 'layerProperties.display', + tooltipId: 'layerProperties.display', + glyph: 'eye-open', + Component: () =>
+ } + ]} element={layers[0]}/>, document.getElementById("container")); + + const tabs = document.querySelectorAll('.nav > li > a'); + expect(tabs.length).toBe(2); + TestUtils.Simulate.click(tabs[0]); + expect(spyOnClick).toHaveBeenCalled(); + }); + + it('test onClose function on tab', () => { + + const testHandlers = { + onClose: () => {} + }; + + const spyOnClose = expect.spyOn(testHandlers, 'onClose'); + + ReactDOM.render( [ + { + id: 'general', + titleId: 'layerProperties.general', + tooltipId: 'layerProperties.general', + glyph: 'wrench', + onClose: testHandlers.onClose, + Component: () =>
+ }, + { + id: 'display', + titleId: 'layerProperties.display', + tooltipId: 'layerProperties.display', + glyph: 'eye-open', + Component: () =>
+ } + ]} onClose={null} element={layers[0]}/>, document.getElementById("container")); + + const closeButton = document.querySelectorAll('.ms-close'); + expect(closeButton.length).toBe(1); + TestUtils.Simulate.click(closeButton[0]); + expect(spyOnClose).toHaveBeenCalled(); + }); + + it('test ToolbarComponent from tab', () => { + + ReactDOM.render( [ + { + id: 'general', + titleId: 'layerProperties.general', + tooltipId: 'layerProperties.general', + glyph: 'wrench', + toolbarComponent: () =>
, + Component: () =>
+ }, + { + id: 'display', + titleId: 'layerProperties.display', + tooltipId: 'layerProperties.display', + glyph: 'eye-open', + Component: () =>
+ } + ]} element={layers[0]}/>, document.getElementById("container")); + + const btnGroup = document.querySelector('.btn-group'); + expect(btnGroup).toNotExist(); + const customToolbar = document.querySelector('.custom-toolbar'); + expect(customToolbar).toExist(); + }); + }); diff --git a/web/client/components/TOC/enhancers/__tests__/tocitemssettings-test.js b/web/client/components/TOC/enhancers/__tests__/tocitemssettings-test.js index c3f0352fb4..9d5002b0a7 100644 --- a/web/client/components/TOC/enhancers/__tests__/tocitemssettings-test.js +++ b/web/client/components/TOC/enhancers/__tests__/tocitemssettings-test.js @@ -158,7 +158,7 @@ describe("test updateSettingsLifecycle", () => { const spyOnUpdateOriginalSettings = expect.spyOn(testHandlers, 'onUpdateOriginalSettings'); const spyOnUpdateInitialSettings = expect.spyOn(testHandlers, 'onUpdateInitialSettings'); - const Component = settingsLifecycle(({onSave}) =>
); + const Component = settingsLifecycle(({onSave}) =>
onSave()}>
); ReactDOM.render( { expect(spyOnUpdateOriginalSettings).toHaveBeenCalled(); expect(spyOnUpdateInitialSettings).toHaveBeenCalled(); }); - - it('test component on update params', () => { - const testHandlers = { - onUpdateOriginalSettings: () => {}, - onUpdateSettings: () => {}, - onUpdateNode: () => {} - }; - - const spyOnUpdateOriginalSettings = expect.spyOn(testHandlers, 'onUpdateOriginalSettings'); - const spyOnUpdateSettings = expect.spyOn(testHandlers, 'onUpdateSettings'); - const spyOnUpdateNode = expect.spyOn(testHandlers, 'onUpdateNode'); - - const Component = settingsLifecycle(({onUpdateParams}) =>
onUpdateParams({newParam: 'param'}, true)}>
); - ReactDOM.render(, document.getElementById("container")); - - const testUpdateParams = document.getElementById('test-update-params'); - TestUtils.Simulate.click(testUpdateParams); - expect(spyOnUpdateOriginalSettings.calls.length).toBe(2); - expect(spyOnUpdateSettings).toHaveBeenCalled(); - expect(spyOnUpdateNode).toHaveBeenCalled(); - }); - - it('test component on update params realtime update to false', () => { - const testHandlers = { - onUpdateOriginalSettings: () => {}, - onUpdateSettings: () => {}, - onUpdateNode: () => {} - }; - - const spyOnUpdateOriginalSettings = expect.spyOn(testHandlers, 'onUpdateOriginalSettings'); - const spyOnUpdateSettings = expect.spyOn(testHandlers, 'onUpdateSettings'); - const spyOnUpdateNode = expect.spyOn(testHandlers, 'onUpdateNode'); - - const Component = settingsLifecycle(({onUpdateParams}) =>
onUpdateParams({newParam: 'param'}, false)}>
); - ReactDOM.render(, document.getElementById("container")); - - const testUpdateParams = document.getElementById('test-update-params'); - TestUtils.Simulate.click(testUpdateParams); - expect(spyOnUpdateOriginalSettings.calls.length).toBe(2); - expect(spyOnUpdateSettings).toHaveBeenCalled(); - expect(spyOnUpdateNode).toNotHaveBeenCalled(); - }); - }); diff --git a/web/client/components/TOC/enhancers/tocItemsSettings.js b/web/client/components/TOC/enhancers/tocItemsSettings.js index 1333ddedf7..212032264d 100644 --- a/web/client/components/TOC/enhancers/tocItemsSettings.js +++ b/web/client/components/TOC/enhancers/tocItemsSettings.js @@ -11,15 +11,12 @@ const { withState, withHandlers, compose, lifecycle } = require('recompose'); /** * Enhancer for settings state needed in TOCItemsSettings plugin - * - originalSettings and initialSettings are the settings of node needed in onUpdateParams * - onShowAlertModal, alert modal appears on close in case of changes in TOCItemsSettings * - onShowEditor, edit modal appears on format info in TOCItemsSettings * @memberof enhancers.settingsState * @class */ const settingsState = compose( - withState('originalSettings', 'onUpdateOriginalSettings', {}), - withState('initialSettings', 'onUpdateInitialSettings', {}), withState('alertModal', 'onShowAlertModal', false), withState('showEditor', 'onShowEditor', false) ); @@ -27,7 +24,6 @@ const settingsState = compose( /** * Basic toc settings lificycle used in TOCItemsSettings plugin with TOCItemsSettings component * Handlers: - * - onUpdateParams: update settings by key and value of the current node * - onClose: triggers onHideSettings only if the settings doesn't change, in case of changes will trigger onShowAlertModal * - onSave: triggers onHideSettings * Lifecycle: @@ -39,36 +35,12 @@ const settingsState = compose( */ const settingsLifecycle = compose( withHandlers({ - onUpdateParams: ({ - settings = {}, - initialSettings = {}, - originalSettings: orig, - onUpdateOriginalSettings = () => {}, - onUpdateSettings = () => {}, - onUpdateNode = () => {} - }) => (newParams, update = true) => { - let originalSettings = { ...(orig || {}) }; - // TODO one level only storage of original settings for the moment - Object.keys(newParams).forEach((key) => { - originalSettings[key] = initialSettings && initialSettings[key]; - }); - // update changed keys to verify only modified values (internal state) - onUpdateOriginalSettings(originalSettings); - - onUpdateSettings(newParams); - if (update) { - onUpdateNode( - settings.node, - settings.nodeType, - { ...settings.options, ...newParams } - ); - } - }, - onClose: ({ onUpdateInitialSettings = () => {}, onUpdateOriginalSettings = () => {}, onUpdateNode, originalSettings, settings, onHideSettings, onShowAlertModal }) => forceClose => { + onClose: ({ onUpdateInitialSettings = () => {}, onUpdateOriginalSettings = () => {}, onUpdateNode, originalSettings, settings, onHideSettings, onShowAlertModal }) => (forceClose, tabsCloseActions = []) => { const originalOptions = Object.keys(settings.options).reduce((options, key) => ({ ...options, [key]: key === 'opacity' && !originalSettings[key] && 1.0 || originalSettings[key] }), {}); if (!isEqual(originalOptions, settings.options) && !forceClose) { onShowAlertModal(true); } else { + tabsCloseActions.forEach(tabOnClose => { tabOnClose(); }); onUpdateNode( settings.node, settings.nodeType, @@ -81,7 +53,8 @@ const settingsLifecycle = compose( onUpdateInitialSettings({}); } }, - onSave: ({ onUpdateInitialSettings = () => {}, onUpdateOriginalSettings = () => {}, onHideSettings = () => { }, onShowAlertModal = () => { } }) => () => { + onSave: ({ onUpdateInitialSettings = () => {}, onUpdateOriginalSettings = () => {}, onHideSettings = () => { }, onShowAlertModal = () => { } }) => (tabsCloseActions = []) => { + tabsCloseActions.forEach(tabOnClose => { tabOnClose(); }); onHideSettings(); onShowAlertModal(false); // clean up internal settings state @@ -98,7 +71,7 @@ const settingsLifecycle = compose( } = this.props; // store changed keys - onUpdateOriginalSettings({ ...element }); + onUpdateOriginalSettings({ }); // store initial settings (internal state) onUpdateInitialSettings({ ...element }); }, @@ -124,7 +97,7 @@ const settingsLifecycle = compose( if (!settings.expanded && newProps.settings && newProps.settings.expanded) { // update initial and original settings - onUpdateOriginalSettings({ ...newProps.element }); + onUpdateOriginalSettings({ }); onUpdateInitialSettings({ ...newProps.element }); onSetTab('general'); } diff --git a/web/client/components/misc/ResizableModal.jsx b/web/client/components/misc/ResizableModal.jsx index 404efaac3e..8ed7a5554f 100644 --- a/web/client/components/misc/ResizableModal.jsx +++ b/web/client/components/misc/ResizableModal.jsx @@ -56,6 +56,7 @@ const fullscreen = { * @prop {string} size size of modal sm, lg or md/empty. Default '' * @prop {string} bodyClassName custom class for modal body. * @prop {bool} draggable enable modal drag. + * @prop {bool} fitContent try to fit content if modal height is less than maximum size */ const ResizableModal = ({ @@ -75,7 +76,8 @@ const ResizableModal = ({ draggable = false, fullscreenState, onFullscreen, - fade = false + fade = false, + fitContent }) => { const sizeClassName = sizes[size] || ''; const fullscreenClassName = showFullscreen && fullscreenState === 'expanded' && fullscreen.className[fullscreenType] || ''; @@ -88,7 +90,7 @@ const ResizableModal = ({ containerClassName="ms-resizable-modal" draggable={draggable} modal - className={'modal-dialog modal-content' + sizeClassName + fullscreenClassName}> + className={'modal-dialog modal-content' + sizeClassName + fullscreenClassName + (fitContent ? ' ms-fit-content' : '')}>

{title}
diff --git a/web/client/components/misc/__tests__/ResizableModal-test.jsx b/web/client/components/misc/__tests__/ResizableModal-test.jsx index 6602c7dcd7..d86a3db048 100644 --- a/web/client/components/misc/__tests__/ResizableModal-test.jsx +++ b/web/client/components/misc/__tests__/ResizableModal-test.jsx @@ -94,4 +94,10 @@ describe('ResizableModal component', () => { expect(document.querySelector('.ms-lg')).toExist(); }); + + it('ResizableModal rendering with fitContent', () => { + ReactDOM.render(, document.getElementById("container")); + const modalWithFitContent = document.querySelector('.ms-fit-content'); + expect(modalWithFitContent).toExist(); + }); }); diff --git a/web/client/components/misc/cardgrids/SquareCard.jsx b/web/client/components/misc/cardgrids/SquareCard.jsx new file mode 100644 index 0000000000..58773ef5fa --- /dev/null +++ b/web/client/components/misc/cardgrids/SquareCard.jsx @@ -0,0 +1,34 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); + +/** + * Component for rendering a square card with preview and title. + * @memberof components.misc.cardgrids + * @name SquareCard + * @class + * @prop {bool} selected hilglight the card with selected style + * @prop {node} preview insert a glyphicon or img node + * @prop {string} previewSrc src for img preview + * @prop {node|string} title text for title + * @prop {function} onClick callback on card click + */ + +const SquareCard = ({ selected, title, preview, previewSrc, onClick = () => { } }) => ( +
onClick()}> + {(preview || previewSrc) &&
+ {preview || previewSrc && } +
} + {title} +
+); + +module.exports = SquareCard; diff --git a/web/client/components/misc/cardgrids/__tests__/SquareCard-test.jsx b/web/client/components/misc/cardgrids/__tests__/SquareCard-test.jsx new file mode 100644 index 0000000000..4cfe4e2a62 --- /dev/null +++ b/web/client/components/misc/cardgrids/__tests__/SquareCard-test.jsx @@ -0,0 +1,84 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); +const ReactDOM = require('react-dom'); +const ReactTestUtils = require('react-dom/test-utils'); + +const expect = require('expect'); +const SquareCard = require('../SquareCard'); + +describe('SquareCard component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('SquareCard rendering with defaults', () => { + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const el = container.querySelector('.ms-square-card'); + expect(el).toExist(); + expect(container.querySelector('.ms-selected')).toNotExist(); + }); + + it('SquareCard rendering with selected', () => { + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const el = container.querySelector('.ms-square-card'); + expect(el).toExist(); + expect(container.querySelector('.ms-selected')).toExist(); + }); + + it('SquareCard rendering with title and preview', () => { + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const el = container.querySelector('.ms-square-card'); + expect(el).toExist(); + const preview = container.querySelector('.ms-preview'); + expect(preview).toExist(); + expect(preview.innerHTML).toBe('Preview'); + const title = container.querySelector('small'); + expect(title).toExist(); + expect(title.innerHTML).toBe('Title'); + }); + + it('SquareCard rendering with title and previewSrc', () => { + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const el = container.querySelector('.ms-square-card'); + expect(el).toExist(); + const previewImg = container.querySelector('img'); + expect(previewImg).toExist(); + const title = container.querySelector('small'); + expect(title).toExist(); + expect(title.innerHTML).toBe('Title'); + }); + + it('SquareCard test on select', () => { + const actions = { + onClick: () => {} + }; + const spyOnClick = expect.spyOn(actions, 'onClick'); + + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const el = container.querySelector('.ms-square-card'); + expect(el).toExist(); + ReactTestUtils.Simulate.click(el); + expect(spyOnClick).toHaveBeenCalled(); + }); +}); diff --git a/web/client/components/misc/panels/PanelHeader.jsx b/web/client/components/misc/panels/PanelHeader.jsx index 71d37ed2e8..d750ec9e86 100644 --- a/web/client/components/misc/panels/PanelHeader.jsx +++ b/web/client/components/misc/panels/PanelHeader.jsx @@ -46,7 +46,7 @@ const fullscreenGlyph = { module.exports = ({ position = 'right', - onClose = () => {}, + onClose, bsStyle = 'default', title = '', fullscreen = false, @@ -55,7 +55,7 @@ module.exports = ({ additionalRows, onFullscreen = () => {} }) => { - const closeButton = ( + const closeButton = !onClose ? null : ( diff --git a/web/client/components/misc/panels/__tests__/DockPanel-test.jsx b/web/client/components/misc/panels/__tests__/DockPanel-test.jsx index e95cb5c24f..be6f1ea38e 100644 --- a/web/client/components/misc/panels/__tests__/DockPanel-test.jsx +++ b/web/client/components/misc/panels/__tests__/DockPanel-test.jsx @@ -58,7 +58,7 @@ describe("test DockPanel", () => { }); it('test fullscreen', () => { - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render( {}}/>, document.getElementById("container")); const buttons = document.getElementsByClassName('square-button'); expect(buttons.length).toBe(2); expect(buttons[0].children[0].getAttribute('class')).toBe('glyphicon glyphicon-chevron-left'); diff --git a/web/client/components/misc/panels/__tests__/PanelHeader-test.jsx b/web/client/components/misc/panels/__tests__/PanelHeader-test.jsx index c5bdffab79..1c0dc0dbd3 100644 --- a/web/client/components/misc/panels/__tests__/PanelHeader-test.jsx +++ b/web/client/components/misc/panels/__tests__/PanelHeader-test.jsx @@ -23,7 +23,7 @@ describe("test PanelHeader", () => { }); it('test rendering', () => { - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render( {}}/>, document.getElementById("container")); const domComp = document.getElementsByClassName('ms-header')[0]; expect(domComp).toExist(); const styleClass = document.getElementsByClassName('ms-default')[0]; @@ -34,7 +34,7 @@ describe("test PanelHeader", () => { }); it('test left position', () => { - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render( {}}/>, document.getElementById("container")); const domComp = document.getElementsByClassName('ms-header')[0]; expect(domComp).toExist(); const styleClass = document.getElementsByClassName('ms-default')[0]; @@ -45,7 +45,7 @@ describe("test PanelHeader", () => { }); it('test additional rows', () => { - ReactDOM.render(}/>, document.getElementById("container")); + ReactDOM.render(} onClose={() => {}}/>, document.getElementById("container")); const domComp = document.getElementsByClassName('ms-header')[0]; expect(domComp).toExist(); const styleClass = document.getElementsByClassName('ms-default')[0]; @@ -58,7 +58,7 @@ describe("test PanelHeader", () => { }); it('test additional bsStyle', () => { - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render( {}}/>, document.getElementById("container")); const domComp = document.getElementsByClassName('ms-header')[0]; expect(domComp).toExist(); const styleClass = document.getElementsByClassName('ms-primary')[0]; @@ -71,41 +71,53 @@ describe("test PanelHeader", () => { it('test fullscreen glyphs', () => { // right - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render( {}}/>, document.getElementById("container")); let fullscreenGlyph = document.getElementsByClassName('glyphicon-chevron-left')[0]; expect(fullscreenGlyph).toExist(); - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render( {}}/>, document.getElementById("container")); fullscreenGlyph = document.getElementsByClassName('glyphicon-chevron-right')[0]; expect(fullscreenGlyph).toExist(); // left - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render( {}}/>, document.getElementById("container")); fullscreenGlyph = document.getElementsByClassName('glyphicon-chevron-right')[0]; expect(fullscreenGlyph).toExist(); - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render( {}}/>, document.getElementById("container")); fullscreenGlyph = document.getElementsByClassName('glyphicon-chevron-left')[0]; expect(fullscreenGlyph).toExist(); // bottom - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render( {}}/>, document.getElementById("container")); fullscreenGlyph = document.getElementsByClassName('glyphicon-chevron-up')[0]; expect(fullscreenGlyph).toExist(); - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render( {}}/>, document.getElementById("container")); fullscreenGlyph = document.getElementsByClassName('glyphicon-chevron-down')[0]; expect(fullscreenGlyph).toExist(); // top - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render( {}}/>, document.getElementById("container")); fullscreenGlyph = document.getElementsByClassName('glyphicon-chevron-down')[0]; expect(fullscreenGlyph).toExist(); - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render( {}}/>, document.getElementById("container")); fullscreenGlyph = document.getElementsByClassName('glyphicon-chevron-up')[0]; expect(fullscreenGlyph).toExist(); }); it('test icon not button', () => { - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render( {}}/>, document.getElementById("container")); const domComp = document.getElementsByClassName('ms-header')[0]; expect(domComp).toExist(); const icon = document.getElementsByClassName('bg-primary'); expect(icon.length).toBe(1); expect(icon[0].tagName.toLowerCase()).toBe('div'); }); + + it('test icon not button onClose is null', () => { + ReactDOM.render(, document.getElementById("container")); + const closeButton = document.querySelector('.ms-close'); + expect(closeButton).toNotExist(); + }); + + it('test icon not button onClose is function', () => { + ReactDOM.render( {}}/>, document.getElementById("container")); + const closeButton = document.querySelector('.ms-close'); + expect(closeButton).toExist(); + }); }); diff --git a/web/client/components/styleeditor/Editor.jsx b/web/client/components/styleeditor/Editor.jsx new file mode 100644 index 0000000000..da0abb37e8 --- /dev/null +++ b/web/client/components/styleeditor/Editor.jsx @@ -0,0 +1,255 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); +const PropTypes = require('prop-types'); +const { Controlled: Codemirror } = require('react-codemirror2'); +const { debounce, isEqual, endsWith, isFunction } = require('lodash'); +const CM = require('codemirror/lib/codemirror'); +const BorderLayout = require('../layout/BorderLayout'); +const Loader = require('../misc/Loader'); +const InfoPopover = require('../widgets/widget/InfoPopover'); +const assign = require('object-assign'); + +require('codemirror/lib/codemirror.css'); +require('codemirror/addon/search/searchcursor'); +require('codemirror/addon/selection/mark-selection'); +require('codemirror/addon/hint/show-hint.css'); +require('codemirror/addon/hint/show-hint'); + +/* SLD styling highlight */ +require('codemirror/mode/xml/xml'); + +require('./mode/geocss')(CM); +require('./hint/geocss')(CM); + +/** + * Component for rendering a grid of style templates. + * @memberof components.styleeditor + * @name Editor + * @class + * @prop {string} mode code mode, 'geocss' or 'xml' + * @prop {string} code initial code text + * @prop {function} onChange triggered after change value in textarea, arg. code + * @prop {number} waitTime dobunce time for trigger onChange, default 1000 + * @prop {object} hintProperties properties added to hint list + * @prop {object} error error object, eg: {line: 2, message: 'Error'} + * @prop {array} inlineWidgets array of inline widget, see example + * @prop {bool} loading loading state + * @example + * ``` + * // inline widgets example + * const inlineWidgets = [ + * { + * type: 'color', // must be unique type + * active: token => token.type === 'atom', // function must return true or false + * style: token => ({backgroundColor: token.string}), // style to apply to inline widget button, function or object + * Widget: ({token, value, onChange = () => {}}) => (
onChange('valueToApply')}>{value || token.string}
) // Component displayed after clicking on widget button + * } + * ]; + * + * + * ``` + */ + +class Editor extends React.Component { + static propTypes = { + mode: PropTypes.string, + theme: PropTypes.string, + style: PropTypes.object, + code: PropTypes.string, + onChange: PropTypes.func, + waitTime: PropTypes.number, + hintProperties: PropTypes.object, + error: PropTypes.object, + inlineWidgets: PropTypes.array, + loading: PropTypes.bool + }; + + static defaultProps = { + mode: 'geocss', + theme: 'lesser-dark', + style: {}, + code: '', + onChange: () => { }, + waitTime: 1000, + hintProperties: {}, + inlineWidgets: [] + }; + + state = {} + + componentWillMount() { + this.setState({ code: this.props.code }); + } + + componentWillUpdate(newProps) { + if (!isEqual(this.props.error, newProps.error)) { + if (this.marker) { + this.marker.clear(); + this.marker = null; + } + if (newProps.error && newProps.error.line) { + const startLine = newProps.error.line - 1; + const endLine = newProps.error.line; + this.marker = this.editor.markText( + {line: startLine, ch: 0}, + {line: endLine, ch: 0}, + {className: 'ms-style-editor-error'}); + } + } + } + + onRenderToken = editor => { + + const lineCount = editor.lineCount(); + + if (this.inlineWidgets) { + this.inlineWidgets.forEach(widget => { + if (widget && widget.parentNode && widget.parentNode.removeChild) { + widget.parentNode.removeChild(widget); + } + }); + } + + this.inlineWidgets = []; + + editor.doc.iter(0, lineCount, line => { + const lineNo = line.lineNo(); + const lineTokens = editor.getLineTokens(lineNo); + lineTokens.forEach(token => { + + this.inlineWidgets = this.props.inlineWidgets.reduce((widgets, {type, style = {}, active = () => false, className = ''}) => { + + if (active(token)) { + const inlineWidget = this.getInlineWidget({ + className, + token, + onClick: () => this.setState({ inlineWidgetType: type, token, lineNo }), + style: isFunction(style) && style(token) || style + }); + editor.addWidget({ line: lineNo, ch: token.start - 1 }, inlineWidget, false); + return [...widgets, inlineWidget]; + } + + return [...widgets]; + + }, [...this.inlineWidgets]); + }); + }); + }; + + onAutocomplete = instance => { + if (instance && instance.state && instance.state.completionActive) return; + const cur = instance.getCursor(); + const token = instance.getTokenAt(cur); + if (token.string && (endsWith(token.string, '-') || token.string.match(/^[.`\w@]\w*$/)) && token.string.length > 0) { + CM.commands.autocomplete(instance, null, { completeSingle: false }); + } + }; + + onUpdate = () => { + this.update.cancel(); + this.update(); + } + + getInlineWidget = ({ onClick = () => {}, token = {}, className = '', style = {} }) => { + const inlineWidget = document.createElement('div'); + inlineWidget.setAttribute('class', `${className} ms-style-editor-inline-widget`); + assign(inlineWidget.style, style); + inlineWidget.onclick = () => onClick({ ...token }); + return inlineWidget; + }; + + render() { + return ( + + {this.props.loading && } + {this.props.error && } +

+ }> + { this.cfgEditor = cmp; }} + key="style-editor" + value={this.state.code} + editorDidMount={editor => { + this.onRenderToken(editor); + this.editor = editor; + editor.on('inputRead', this.onAutocomplete); + this.update = debounce(() => { + this.props.onChange(this.state.code); + }, this.props.waitTime); + CM.extendMode(this.props.mode, { hintProperties: this.props.hintProperties }); + }} + editorWillUnmount={editor => editor.off('inputRead', this.onAutocomplete)} + onBeforeChange={(editor, data, code) => this.setState({ code })} + onChange={editor => { + this.onRenderToken(editor); + this.onUpdate(); + }} + options={{ + theme: this.props.theme, + mode: this.props.mode, + lineNumbers: true, + styleSelectedText: true, + indentUnit: 2, + tabSize: 2 + }} /> + {this.state.token && +
+
+
+
+ {this.props.inlineWidgets + .filter(({type}) => type === this.state.inlineWidgetType) + .map(({ Widget }) => + this.setState({ value })}/> + )} +
+
} + + ); + } +} + +module.exports = Editor; diff --git a/web/client/components/styleeditor/SVGPreview.jsx b/web/client/components/styleeditor/SVGPreview.jsx new file mode 100644 index 0000000000..4b4c768c95 --- /dev/null +++ b/web/client/components/styleeditor/SVGPreview.jsx @@ -0,0 +1,57 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); + +/** + * Component for rendering SVG previews for polygons, linestrings and points styles. + * @memberof components.styleeditor + * @name SVGPreview + * @class + * @prop {string} type geometry type, polygon, linestring or point + * @prop {array} patterns defined paths to use as pattern in svg elements, id and icon keys needed, eg: [{id: 'tree', icon: { d: 'M0.1 0.9 L0.5 0.1 L0.9 0.9Z', fill: '#98c390'}}] + * @prop {array} paths array of path object, type must be defined, eg: type='linestring' paths=[{stroke: '#999999', strokeWidth: 6}, {stroke: '#ffffff', strokeWidth: 2}] + * @prop {array} texts array of text object, eg: [{ text: 'HELLO', fill: '#f2f2f2', style: {fontSize: 70, fontWeight: 'bold'}}] + * @prop {string} backgroundColor background color of the preview + * @example + * ``` + * // point + * + * + * // linestring + * + * + * // polygon + * + * + * // polygon with pattern + * + * + * // text + * + * + * ``` + */ + +const SVGPreview = ({ type, patterns, paths, texts, backgroundColor = '#ffffff' }) => + + + {patterns && patterns.map(pattern => + {pattern.icon && } + )} + + + {paths && paths.map(({type: pathType, ...props}) => + (pathType || type) === 'polygon' && + || (pathType || type) === 'linestring' && + || (pathType || type) === 'point' && + )} + {texts && texts.map(({text, ...props}) => {text})} + ; + +module.exports = SVGPreview; diff --git a/web/client/components/styleeditor/StyleList.jsx b/web/client/components/styleeditor/StyleList.jsx new file mode 100644 index 0000000000..003803b127 --- /dev/null +++ b/web/client/components/styleeditor/StyleList.jsx @@ -0,0 +1,97 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); + +const { Glyphicon: GlyphiconRB } = require('react-bootstrap'); + +const BorderLayout = require('../layout/BorderLayout'); +const emptyState = require('../misc/enhancers/emptyState'); +const withLocal = require("../misc/enhancers/localizedProps"); +const Filter = withLocal('filterPlaceholder')(require('../misc/Filter')); +const SVGPreview = require('./SVGPreview'); +const Message = require('../I18N/Message'); +const tooltip = require('../misc/enhancers/tooltip'); +const Glyphicon = tooltip(GlyphiconRB); + +const SideGrid = emptyState( + ({items}) => items.length === 0, + { + title: , + glyph: '1-stilo' + } +)(require('../misc/cardgrids/SideGrid')); + +/** + * Component for rendering a grid of style templates. + * @memberof components.styleeditor + * @name StyleList + * @class + * @prop {string} enabledStyle name of style in use + * @prop {string} defaultStyle name of default style + * @prop {array} availableStyles array of all available styles, eg: [{TYPE_NAME: "WMS_1_3_0.Style", filename: "style.sld", format: "sld", languageVersion: {version: "1.0.0"}, legendURL: [{…}], name: "point", title: "Title", _abstract: ""}] + * @prop {function} onSelect triggered by clicking on cards, arg. {style} + * @prop {string} formatColors object of colors, key should be the format name and value an hexadecimal color, it changes color of text in preview + * @prop {string} filterText + * @prop {function} onFilter arg. text value from input filter + */ + +const StyleList = ({ + enabledStyle, + defaultStyle, + availableStyles = [], + onSelect, + formatColors = { + sld: '#33ffaa', + css: '#ffaa33' + }, + filterText, + onFilter = () => {} +}) => ( + + }> + onSelect({ style: defaultStyle === name ? '' : name }, true)} + items={availableStyles + .filter(({name = '', title = '', _abstract = ''}) => !filterText + || filterText && ( + name.indexOf(filterText) !== -1 + || title.indexOf(filterText) !== -1 + || _abstract.indexOf(filterText) !== -1 + )) + .map(style => ({ + ...style, + title: style.label || style.title || style.name, + description: style._abstract, + selected: enabledStyle === style.name, + preview: style.format && + || , + tools: defaultStyle === style.name && + + || + }))} /> + + ); + +module.exports = StyleList; diff --git a/web/client/components/styleeditor/StyleTemplates.jsx b/web/client/components/styleeditor/StyleTemplates.jsx new file mode 100644 index 0000000000..44cdf7974e --- /dev/null +++ b/web/client/components/styleeditor/StyleTemplates.jsx @@ -0,0 +1,149 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); +const { head } = require('lodash'); +const { Form, FormGroup, FormControl: FormControlRB, ControlLabel, Alert } = require('react-bootstrap'); + +const BorderLayout = require('../layout/BorderLayout'); +const emptyState = require('../misc/enhancers/emptyState'); + +const SquareCard = require('../misc/cardgrids/SquareCard'); +const withLocal = require("../misc/enhancers/localizedProps"); + +const Filter = withLocal('filterPlaceholder')(require('../misc/Filter')); +const FormControl = withLocal('placeholder')(FormControlRB); + +const ResizableModal = require('../misc/ResizableModal'); +const Message = require('../I18N/Message'); +const HTML = require('../I18N/HTML'); + +const SideGrid = emptyState( + ({items}) => items.length === 0, + { + title: , + glyph: '1-stilo' + } +)(require('../misc/cardgrids/SideGrid')); + +const validateAlphaNumeric = ({title, _abstract}) => { + const regex = /^[a-zA-Z0-9\s]*$/; + const validTitle = title && title.match(regex) !== null; + return validTitle && !_abstract || validTitle && _abstract && _abstract.match(regex) !== null; +}; + +/** + * Component for rendering a grid of style templates. + * @memberof components.styleeditor + * @name StyleTemplates + * @class + * @prop {array} templates array of template object eg: [{styleId: 'id00125', types: ['linestring', 'vector'], title: 'Line', code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n* {\n\tstroke: #999999;\n}", preview: }] + * @prop {string} geometryType one of 'point', 'linestring', 'polygon', 'vector' or 'raster' + * @prop {bool} add display form modal to update styleSettings + * @prop {array} styleSettings object of settings, default { title: 'Title', _abstract: 'Abstract' } + * @prop {string} selectedStyle name of selected style template + * @prop {function} onSelect triggered clicking on card, arg. {code, templateId, format} + * @prop {function} onClose triggered clicking on X button + * @prop {function} onSave triggered clicking on save button, arg. styleSettings object + * @prop {function} onUpdate update settings, arg. styleSettings object + * @prop {string} filterText + * @prop {function} onFilter arg. text value from input filter + */ +const StyleTemplates = ({ + selectedStyle, + add, + styleSettings = {}, + geometryType = '', + templates = [], + filterText, + availableFormats = [ + 'sld', + 'css' + ], + formFields = [ + { + key: 'title', + placeholder: 'styleeditor.titleSettingsplaceholder', + title: + }, + { + key: '_abstract', + placeholder: 'styleeditor.abstractSettingsplaceholder', + title: + } + ], + onFilter = () => {}, + onSelect = () => {}, + onClose = () => {}, + onSave = () => {}, + onUpdate = () => {} +}) => ( + + +
+ +
+ + }> + { + onSelect({ + code, + templateId, + format: format || 'css' + }); + onUpdate({...styleSettings, title: head(templates.filter(({styleId}) => styleId === templateId).map(({title}) => title))}); + }} + items={templates + .filter(({title}) => !filterText || filterText && title.indexOf(filterText) !== -1) + .filter(({types, format}) => (!types || head(types.filter(type => type === geometryType)) && availableFormats.indexOf(format) !== -1)) + .map(styleTemplate => ({ ...styleTemplate, selected: styleTemplate.styleId === selectedStyle }))}/> + } + onClose={() => onClose()} + buttons={[ + { + text: , + bsStyle: 'primary', + disabled: !validateAlphaNumeric(styleSettings), + onClick: () => onSave(styleSettings) + } + ]}> +
+ + {formFields.map(({title, placeholder, key}) => ( + {title} + onUpdate({...styleSettings, [key]: event.target.value})}/> + ))} + + {!validateAlphaNumeric(styleSettings) && + + } +
+
+
+ ); + +module.exports = StyleTemplates; diff --git a/web/client/components/styleeditor/StyleToolbar.jsx b/web/client/components/styleeditor/StyleToolbar.jsx new file mode 100644 index 0000000000..974d686de4 --- /dev/null +++ b/web/client/components/styleeditor/StyleToolbar.jsx @@ -0,0 +1,155 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); +const Toolbar = require('../misc/toolbar/Toolbar'); +const ResizableModal = require('../misc/ResizableModal'); +const Message = require('../I18N/Message'); +const { Alert } = require('react-bootstrap'); + +/** + * Component for rendering Toolbar for Style Editor. + * @memberof components.styleeditor + * @name StyleToolbar + * @class + * @prop {string} status status of style editor, '', 'edit' or 'template' + * @prop {array|node} buttons additional buttons, array of buttons object or toolbar node + * @prop {bool} editEnabled enable/disable edit/templates buttons + * @prop {array} defaultStyles array of style names not editable + * @prop {bool} loading loading state + */ + +const StyleToolbar = ({ + status, + buttons = [], + templateId, + error, + isCodeChanged, + showModal, + onShowModal, + loading, + selectedStyle, + editEnabled, + defaultStyles = [ + 'generic', + 'point', + 'line', + 'polygon', + 'raster' + ], + onBack = () => {}, + onAdd = () => {}, + onReset = () => {}, + onDelete = () => {}, + onSelectStyle = () => {}, + onEditStyle = () => {}, + onUpdate = () => {} +}) => ( +
+ { + if (status === 'edit' && isCodeChanged) { + onShowModal({ + title: , + message: ( + + ), + buttons: [ + { + text: , + bsStyle: 'primary', + onClick: () => { + onShowModal(null); + onBack(); + onReset(); + } + } + ] + }); + } else { + onBack(); + onReset(); + } + } + }, + { + glyph: '1-stilo', + tooltipId: 'styleeditor.createNewStyle', + visible: !status && editEnabled ? true : false, + disabled: !!loading, + onClick: () => onSelectStyle() + }, + { + glyph: 'code', + tooltipId: 'styleeditor.editSelectedStyle', + visible: !status && editEnabled ? true : false, + disabled: !!loading || defaultStyles.indexOf(selectedStyle) !== -1, + onClick: () => onEditStyle() + }, + { + glyph: 'ok', + tooltipId: 'styleeditor.saveCurrentStyle', + disabled: !!(error && error.edit && error.edit.status) || !!loading || defaultStyles.indexOf(selectedStyle) !== -1, + visible: status === 'edit' && editEnabled ? true : false, + onClick: () => onUpdate() + }, + { + glyph: 'plus', + tooltipId: 'styleeditor.addSelectedTemplate', + visible: status === 'template' && templateId && editEnabled ? true : false, + disabled: !!loading, + onClick: () => onAdd() + }, + { + glyph: 'trash', + tooltipId: 'styleeditor.deleteSelectedStyle', + disabled: !!loading || defaultStyles.indexOf(selectedStyle) !== -1 || !selectedStyle, + visible: !status && editEnabled ? true : false, + onClick: () => { + onShowModal({ + title: , + message: ( + + ), + buttons: [ + { + text: , + bsStyle: 'primary', + onClick: () => { + onShowModal(null); + onDelete(selectedStyle); + } + } + ] + }); + } + }, + ...(!!status ? [] : buttons) + ]} /> + onShowModal(null)} + buttons={showModal && showModal.buttons}> + {showModal && showModal.message} + +
+); + +module.exports = StyleToolbar; diff --git a/web/client/components/styleeditor/__tests__/Editor-test.jsx b/web/client/components/styleeditor/__tests__/Editor-test.jsx new file mode 100644 index 0000000000..121b721e26 --- /dev/null +++ b/web/client/components/styleeditor/__tests__/Editor-test.jsx @@ -0,0 +1,158 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); +const ReactDOM = require('react-dom'); +const Editor = require('../Editor'); +const TestUtils = require('react-dom/test-utils'); +const expect = require('expect'); + +describe('test Editor module component (Style Editor)', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('test Editor creation', () => { + const comp = ReactDOM.render(, document.getElementById("container")); + expect(comp).toExist(); + expect(comp.editor).toExist(); + }); + + it('test Editor geocss mode, highlight values', () => { + const code = "@styleTitle 'Title';\n@styleAbstract 'Abstract';\n\n* {\n\tmark: symbol(square);\n\t:mark {\n\t\tstroke: #ff33aa;\n\t\tstroke-width: 2;\n\t};\n}\n\n[NAME = 'Rome'] {\n\t:mark {\n\t\tstroke: #1741ff;\n\t};\n}"; + const comp = ReactDOM.render(, document.getElementById("container")); + expect(comp).toExist(); + expect(comp.editor).toExist(); + + // mark, stroke, stroke-width + const cmProperties = document.querySelectorAll('.cm-property'); + expect(cmProperties.length).toBe(4); + + // #ff33aa, #1741ff + const cmAtom = document.querySelectorAll('.cm-atom'); + expect(cmAtom.length).toBe(2); + + // 2 + const cmNumber = document.querySelectorAll('.cm-number'); + expect(cmNumber.length).toBe(1); + + // @styleTitle, @styleAbstract + const cmVariable2 = document.querySelectorAll('.cm-variable-2'); + expect(cmVariable2.length).toBe(2); + + // NAME + const cmFilter = document.querySelectorAll('.cm-filter'); + expect(cmFilter.length).toBe(1); + + // :mark + const cmVariable3 = document.querySelectorAll('.cm-variable-3'); + expect(cmVariable3.length).toBe(2); + }); + + it('test Editor geocss mode add inline widgets', () => { + + const ORIGINAL_VALUE = '#00ff00'; + const CHANGED_VALUE = '#ff00ff'; + const code = `@styleTitle 'Title';\n@styleAbstract 'Abstract';\n\n* {\n\tstroke: ${ORIGINAL_VALUE};\n}`; + + const comp = ReactDOM.render( token.type === 'atom', + Widget: ({token, value, onChange = () => {}}) => ( +
onChange(CHANGED_VALUE)}>{value || token.string}
+ ) + } + ]} + />, document.getElementById("container")); + expect(comp).toExist(); + expect(comp.editor).toExist(); + let cmAtom = document.querySelectorAll('.cm-atom'); + expect(cmAtom.length).toBe(1); + expect(cmAtom[0].innerHTML).toBe(ORIGINAL_VALUE); + + const inlineWidgetsButtons = document.querySelectorAll('.ms-style-editor-inline-widget'); + expect(inlineWidgetsButtons.length).toBe(1); + + // open widget + inlineWidgetsButtons[0].click(); + + const widgetPanel = document.querySelector('.custom-widget-panel'); + + // change value + TestUtils.Simulate.click(widgetPanel); + + // close panel and update code text + const closeButton = document.querySelector('.close'); + TestUtils.Simulate.click(closeButton); + + cmAtom = document.querySelectorAll('.cm-atom'); + expect(cmAtom.length).toBe(1); + expect(cmAtom[0].innerHTML).toBe(CHANGED_VALUE); + }); + + it('test Editor error', () => { + + const code = "@styleTitle 'Error';\n@styleAbstract 'Abstract';\n\n {\n\tstroke: #00ff00;\n}"; + + ReactDOM.render(, document.getElementById("container")); + + let editorError = document.querySelectorAll('.ms-style-editor-error'); + expect(editorError.length).toBe(0); + let infoPopover = document.querySelector('.mapstore-info-popover'); + expect(infoPopover).toNotExist(); + + ReactDOM.render(, document.getElementById("container")); + + editorError = document.querySelectorAll('.ms-style-editor-error'); + expect(editorError.length > 0).toBe(true); + + infoPopover = document.querySelector('.mapstore-info-popover'); + expect(infoPopover).toExist(); + + }); + + it('test Editor loading', () => { + ReactDOM.render(, document.getElementById("container")); + + let loadingDOM = document.querySelector('.mapstore-small-size-loader'); + expect(loadingDOM).toExist(); + + ReactDOM.render(, document.getElementById("container")); + + loadingDOM = document.querySelector('.mapstore-small-size-loader'); + expect(loadingDOM).toNotExist(); + }); +}); diff --git a/web/client/components/styleeditor/__tests__/SVGPreview-test.jsx b/web/client/components/styleeditor/__tests__/SVGPreview-test.jsx new file mode 100644 index 0000000000..3af84452b0 --- /dev/null +++ b/web/client/components/styleeditor/__tests__/SVGPreview-test.jsx @@ -0,0 +1,142 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); +const ReactDOM = require('react-dom'); +const SVGPreview = require('../SVGPreview'); + +const expect = require('expect'); + +describe('test SVGPreview module component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('test SVGPreview creation', () => { + ReactDOM.render(, document.getElementById("container")); + const paths = document.querySelectorAll('path'); + expect(paths.length).toBe(1); + }); + + it('test SVGPreview point', () => { + ReactDOM.render( + + , document.getElementById("container")); + const paths = document.querySelectorAll('path'); + expect(paths.length).toBe(2); + expect(paths[1].getAttribute('d')).toBe('M30 160 L100 40'); + expect(paths[1].getAttribute('stroke')).toBe('#999999'); + }); + + it('test SVGPreview linestring', () => { + ReactDOM.render( + + , document.getElementById("container")); + const paths = document.querySelectorAll('path'); + expect(paths.length).toBe(3); + expect(paths[1].getAttribute('d')).toBe('M30 160 L100 40 L170 160'); + expect(paths[1].getAttribute('stroke')).toBe('#999999'); + expect(paths[2].getAttribute('d')).toBe('M30 160 L100 40 L170 160'); + expect(paths[2].getAttribute('stroke')).toBe('#ffffff'); + }); + + it('test SVGPreview polygon', () => { + ReactDOM.render( + + , document.getElementById("container")); + const paths = document.querySelectorAll('path'); + expect(paths.length).toBe(2); + expect(paths[1].getAttribute('d')).toBe('M20 20 L180 20 L180 180 L20 180Z'); + expect(paths[1].getAttribute('fill')).toBe('#999999'); + }); + + it('test SVGPreview polygon with pattern', () => { + ReactDOM.render( + + , document.getElementById("container")); + const paths = document.querySelectorAll('path'); + expect(paths.length).toBe(4); + + const patterns = document.querySelectorAll('pattern'); + expect(patterns.length).toBe(1); + + expect(paths[0].getAttribute('d')).toBe('M0.1 0.9 L0.5 0.1 L0.9 0.9Z'); + expect(paths[0].getAttribute('fill')).toBe('#98c390'); + + expect(paths[2].getAttribute('d')).toBe('M20 20 L180 20 L180 180 L20 180Z'); + expect(paths[2].getAttribute('fill')).toBe('#c1ffb3'); + expect(paths[3].getAttribute('d')).toBe('M20 20 L180 20 L180 180 L20 180Z'); + expect(paths[3].getAttribute('fill')).toBe('url(#tree)'); + + }); + + it('test SVGPreview text', () => { + ReactDOM.render( + + , document.getElementById("container")); + const paths = document.querySelectorAll('path'); + expect(paths.length).toBe(1); + + expect(paths[0].getAttribute('fill')).toBe('#333333'); + + const text = document.querySelectorAll('text'); + expect(text.length).toBe(1); + + expect(text[0].innerHTML).toBe('HELLO'); + }); + +}); diff --git a/web/client/components/styleeditor/__tests__/StyleList-test.jsx b/web/client/components/styleeditor/__tests__/StyleList-test.jsx new file mode 100644 index 0000000000..9028fcd00b --- /dev/null +++ b/web/client/components/styleeditor/__tests__/StyleList-test.jsx @@ -0,0 +1,163 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); +const ReactDOM = require('react-dom'); +const StyleList = require('../StyleList'); +const TestUtils = require('react-dom/test-utils'); +const expect = require('expect'); + +describe('test StyleList module component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('test StyleList show list', () => { + + ReactDOM.render(, document.getElementById("container")); + + const cards = document.querySelectorAll('.mapstore-side-card'); + expect(cards.length).toBe(3); + + const selectedTitle = document.querySelector('.mapstore-side-card.selected .mapstore-side-card-title span'); + expect(selectedTitle.innerHTML).toBe('Square'); + + const svgIconsText = document.querySelectorAll('svg text'); + expect(svgIconsText.length).toBe(3); + + expect(svgIconsText[0].innerHTML).toBe('SLD'); + expect(svgIconsText[1].innerHTML).toBe('CSS'); + expect(svgIconsText[2].innerHTML).toBe('CSS'); + }); + + it('test StyleList onSelect', () => { + + const testHandlers = { + onSelect: () => {} + }; + + const spyOnSelect = expect.spyOn(testHandlers, 'onSelect'); + + ReactDOM.render(, document.getElementById("container")); + + const cards = document.querySelectorAll('.mapstore-side-card'); + expect(cards.length).toBe(3); + + TestUtils.Simulate.click(cards[2]); + + expect(spyOnSelect).toHaveBeenCalledWith({style: 'circle'}, true); + }); + + it('test StyleList onSelect default', () => { + + const testHandlers = { + onSelect: () => {} + }; + + const spyOnSelect = expect.spyOn(testHandlers, 'onSelect'); + + ReactDOM.render(, document.getElementById("container")); + + const cards = document.querySelectorAll('.mapstore-side-card'); + expect(cards.length).toBe(3); + + TestUtils.Simulate.click(cards[0]); + + expect(spyOnSelect).toHaveBeenCalledWith({style: ''}, true); + }); +}); diff --git a/web/client/components/styleeditor/__tests__/StyleTemplates-test.jsx b/web/client/components/styleeditor/__tests__/StyleTemplates-test.jsx new file mode 100644 index 0000000000..2c52cd9ea7 --- /dev/null +++ b/web/client/components/styleeditor/__tests__/StyleTemplates-test.jsx @@ -0,0 +1,111 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); +const ReactDOM = require('react-dom'); +const StyleTemplates = require('../StyleTemplates'); +const TestUtils = require('react-dom/test-utils'); +const expect = require('expect'); + +const tempates = [ + { + types: ['point', 'vector'], + title: 'Point style', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n* {\n\mark: symbol(square);\n}", + preview: '', + styleId: '001' + }, + { + types: ['linestring', 'vector'], + title: 'LineString style', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n* {\n\tstroke: #777777;\n}", + preview: '', + styleId: '002' + }, + { + types: ['polygon', 'vector'], + title: 'Polygon fill', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n* {\n\tfill: #777777;\n}", + preview: '', + styleId: '003' + }, + { + types: ['raster'], + title: 'Raster', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n* {\n\tstroke: #777777;\n}", + preview: '', + styleId: '004' + } +]; + +describe('test StyleTemplates module component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('test StyleTemplates geometryType', () => { + + ReactDOM.render(, document.getElementById("container")); + let cards = document.querySelectorAll('.ms-square-card'); + expect(cards.length).toBe(3); + + ReactDOM.render(, document.getElementById("container")); + cards = document.querySelectorAll('.ms-square-card'); + expect(cards.length).toBe(1); + + ReactDOM.render(, document.getElementById("container")); + cards = document.querySelectorAll('.ms-square-card'); + expect(cards.length).toBe(1); + + ReactDOM.render(, document.getElementById("container")); + cards = document.querySelectorAll('.ms-square-card'); + expect(cards.length).toBe(1); + + ReactDOM.render(, document.getElementById("container")); + cards = document.querySelectorAll('.ms-square-card'); + expect(cards.length).toBe(1); + }); + + it('test StyleTemplates onSelect', done => { + ReactDOM.render( { + expect(value).toEqual({ + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n* {\n\mark: symbol(square);\n}", + templateId: '001', + format: 'css' + }); + done(); + }} + geometryType="vector"/>, document.getElementById("container")); + const cards = document.querySelectorAll('.ms-square-card'); + expect(cards.length).toBe(3); + TestUtils.Simulate.click(cards[0]); + }); +}); diff --git a/web/client/components/styleeditor/__tests__/StyleToolbar-test.jsx b/web/client/components/styleeditor/__tests__/StyleToolbar-test.jsx new file mode 100644 index 0000000000..8db503be72 --- /dev/null +++ b/web/client/components/styleeditor/__tests__/StyleToolbar-test.jsx @@ -0,0 +1,56 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); +const ReactDOM = require('react-dom'); +const StyleToolbar = require('../StyleToolbar'); +const expect = require('expect'); + +describe('test StyleToolbar module component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('test StyleToolbar creation', () => { + ReactDOM.render(, document.getElementById("container")); + const buttons = document.querySelectorAll('.btn'); + expect(buttons.length).toBe(0); + }); + + it('test StyleToolbar no status', () => { + ReactDOM.render(, document.getElementById("container")); + const buttons = document.querySelectorAll('.btn'); + expect(buttons.length).toBe(3); + }); + + it('test StyleToolbar status template', () => { + ReactDOM.render(, document.getElementById("container")); + let buttons = document.querySelectorAll('.btn'); + expect(buttons.length).toBe(1); + + ReactDOM.render(, document.getElementById("container")); + buttons = document.querySelectorAll('.btn'); + expect(buttons.length).toBe(2); + }); + + it('test StyleToolbar status edit', () => { + ReactDOM.render(, document.getElementById("container")); + const buttons = document.querySelectorAll('.btn'); + expect(buttons.length).toBe(2); + const disabledButtons = document.querySelectorAll('button:disabled'); + expect(disabledButtons.length).toBe(0); + }); + +}); diff --git a/web/client/components/styleeditor/hint/geocss.js b/web/client/components/styleeditor/hint/geocss.js new file mode 100644 index 0000000000..2e50f0dc58 --- /dev/null +++ b/web/client/components/styleeditor/hint/geocss.js @@ -0,0 +1,107 @@ + +// Implemented from css hint + +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: http://codemirror.net/LICENSE + +const { head } = require('lodash'); + +const parseLocalPart = { + 'short': 'number', + 'float': 'number', + 'double': 'number', + 'long': 'number', + 'decimal': 'number', + 'int': 'number' +}; + +const getType = ({localPart, prefix}) => { + return prefix === 'gml' && 'geometry' + || parseLocalPart[localPart] || localPart || ''; +}; + +module.exports = function(CodeMirror) { + const {Pos: codeMirrorPos} = CodeMirror; + + CodeMirror.registerHelper('hint', 'geocss', function(cm) { + const cur = cm.getCursor(); + const token = cm.getTokenAt(cur); + + const lineTokens = cm.getLineTokens(cur.line); + const property = lineTokens && head(lineTokens.filter(({type, start}) => type === 'property' && start < token.start).map(({string}) => string)); + + const inner = CodeMirror.innerMode(cm.getMode(), token.state); + if (inner.mode.name !== 'geocss') return null; + + let start = token.start; + let end = cur.ch; + let word = token.string.slice(0, end - start); + + if (/[^\w$_-]/.test(word)) { + word = ''; + start = end = cur.ch; + } + + const { propertyKeywords, pseudoProperties, envKeywords } = CodeMirror.resolveMode('text/geocss') || {}; + const { hintProperties } = CodeMirror.getMode({}, 'text/geocss') || {}; + + let selectedKeywords = {}; + let includeAll = false; + const st = inner.state.state; + + if (st === 'pseudo' || token.type === 'variable-3') { + selectedKeywords = {...pseudoProperties}; + } else if (st === 'prop') { + includeAll = true; + selectedKeywords = propertyKeywords && propertyKeywords[property] && propertyKeywords[property].values + && {...propertyKeywords[property].values} || {}; + } else if (token.type === 'variable-2') { + selectedKeywords = {...envKeywords}; + } else if (token.type === 'filter') { + includeAll = true; + selectedKeywords = {...hintProperties}; + } else if (st === 'block' || st === 'maybeprop') { + selectedKeywords = {...propertyKeywords}; + } + + let list = Object.keys(selectedKeywords).reduce((results, name) => { + const wordMatch = (!word || name.lastIndexOf(word, 0) === 0); + return [ + ...results, + ...(wordMatch && [name] || []) + ]; + }, []); + + list = list.length === 0 && includeAll ? + Object.keys(selectedKeywords).reduce((results, name) => [ + ...results, + name + ], []) + : [...list]; + + if (list.length > 0) { + return { + list: list.map(item => { + return { + text: item, + displayText: item, + render(el, editor, data) { + const icon = document.createElement('span'); + const type = getType(selectedKeywords[data.displayText] || {}); + icon.innerHTML = type && `{${type}} ` || ''; + const text = document.createElement('span'); + text.innerText = data.displayText; + el.appendChild(icon); + el.appendChild(text); + } + }; + }), + from: codeMirrorPos(cur.line, start), + to: codeMirrorPos(cur.line, end) + }; + } + return null; + }); +}; + + diff --git a/web/client/components/styleeditor/mode/geocss.js b/web/client/components/styleeditor/mode/geocss.js new file mode 100644 index 0000000000..1bf24e7043 --- /dev/null +++ b/web/client/components/styleeditor/mode/geocss.js @@ -0,0 +1,492 @@ + + +// Implemented from css mode + +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: http://codemirror.net/LICENSE + +const {startsWith, trim} = require('lodash'); + +module.exports = (CodeMirror) => { + + CodeMirror.defineMode('geocss', function(config, parserConfig = {}) { + + const { indentUnit } = config; + const { + propertyKeywords = {}, + colorKeywords = {}, + valueKeywords = {}, + logicKeywords = {}, + allowNested + } = parserConfig.propertyKeywords && parserConfig || CodeMirror.resolveMode('text/geocss'); + + let type; + let override; + let states = {}; + + const ret = (style, tp) => { + type = tp; + return style; + }; + + const tokenString = quote => { + return (stream, state) => { + let escaped = false; + let ch = stream.next(); + while (ch) { + if (ch === quote && !escaped) { + if (quote === ')') stream.backUp(1); + break; + } + escaped = !escaped && ch === '\\'; + ch = stream.next(); + } + if (ch === quote || !escaped && quote !== ')') { + state.tokenize = null; + } + return ret('string', 'string'); + }; + }; + + const tokenComment = (stream, state) => { + let maybeEnd = false; + let ch = stream.next(); + while (ch) { + if (maybeEnd && ch === '/') { + state.tokenize = null; + break; + } + maybeEnd = (ch === '*'); + ch = stream.next(); + } + return ['comment', 'comment']; + }; + + const tokenBase = (stream, state) => { + let ch = stream.next(); + if (ch === '@') { + if (stream.eat('{')) return [null, 'interpolation']; + if (stream.match(/^(sd|scale)\b/)) return ['filter', null]; + stream.eatWhile(/[\w\\\-]/); + if (stream.match(/^\s*:/, false)) { + return ['variable-2', 'variable-definition']; + } + return ['variable-2', 'variable']; + } else if (ch === '/') { + if (stream.eat('*')) { + state.tokenize = tokenComment; + return tokenComment(stream, state); + } + return ['operator', 'operator']; + } else if (ch === "\"" || ch === "'") { + state.tokenize = tokenString(ch); + return state.tokenize(stream, state); + } else if (ch === "#") { + stream.eatWhile(/[\w\\\-]/); + return ret("atom", "hash"); + } else if (/\d/.test(ch) || ch === "." && stream.eat(/\d/)) { + stream.eatWhile(/[\w.%]/); + return ret("number", "unit"); + } else if (ch === "-") { + if (/[\d.]/.test(stream.peek())) { + stream.eatWhile(/[\w.%]/); + return ret("number", "unit"); + } else if (stream.match(/^-[\w\\\-]+/)) { + stream.eatWhile(/[\w\\\-]/); + if (stream.match(/^\s*:/, false)) { + return ret("variable-2", "variable-definition"); + } + return ret("variable-2", "variable"); + } else if (stream.match(/^\w+-/)) { + return ret("meta", "meta"); + } + } else if (/[,+>*\/]/.test(ch)) { + return ret(null, "select-op"); + } else if (ch === "." && stream.match(/^-?[_a-z][_a-z0-9-]*/i)) { + return ret("qualifier", "qualifier"); + } else if (/[:;{}\[\]\(\)]/.test(ch)) { + return ret(null, ch); + } else if (/[\w\\\-]/.test(ch)) { + stream.eatWhile(/[\w\\\-]/); + return ret("property", "word"); + } + return ret(null, null); + }; + + function Context(currentType, indent, prev) { + this.type = currentType; + this.indent = indent; + this.prev = prev; + } + + const pushContext = (state, stream, currentType, indent) => { + state.context = new Context(currentType, stream.indentation() + (indent === false ? 0 : indentUnit), state.context); + return currentType; + }; + + const popContext = (state) => { + if (state.context.prev) { + state.context = state.context.prev; + } + return state.context.type; + }; + + const pass = (currentType, stream, state) => { + return states[state.context.type](currentType, stream, state); + }; + + const popAndPass = (currentType, stream, state, n) => { + for (let i = n || 1; i > 0; i--) { + state.context = state.context.prev; + } + return pass(currentType, stream, state); + }; + + const wordAsValue = function(stream) { + let word = stream.current().toLowerCase(); + if (valueKeywords.hasOwnProperty(word)) { + override = "atom"; + } else if (colorKeywords.hasOwnProperty(word)) { + override = "keyword"; + } else { + override = 'variable'; + } + }; + + states.top = (currentType, stream, state) => { + if (currentType === "{") { + return pushContext(state, stream, "block"); + } else if (currentType === "}" && state.context.prev) { + return popContext(state); + } else if (currentType === "hash") { + override = "builtin"; + } else if (currentType === "word") { + override = "tag"; + } else if (currentType === "variable-definition") { + return "maybeprop"; + } else if (currentType === "interpolation") { + return pushContext(state, stream, "interpolation"); + } else if (currentType === ":") { + return "pseudo"; + } else if (allowNested && currentType === "(") { + return pushContext(state, stream, "parens"); + } + return state.context.type; + }; + + states.block = (currentType, stream, state) => { + + if (currentType === 'word') { + let word = stream.current().toLowerCase(); + if (propertyKeywords.hasOwnProperty(word)) { + override = 'property'; + return 'maybeprop'; + } else if (logicKeywords.hasOwnProperty(trim(word))) { + override = "logic"; + return 'maybeprop'; + } else if (startsWith(trim(stream.string), '[')) { + override = "filter"; + return 'maybeprop'; + } /*else if (allowNested) { + override = stream.match(/^\s*:(?:\s|$)/, false) ? 'property' : 'tag'; + return 'block'; + }*/ + override += ' error'; + return 'maybeprop'; + } else if (currentType === 'meta') { + return 'block'; + } else if (!allowNested && (currentType === 'hash' || currentType === 'qualifier')) { + override = 'error'; + return 'block'; + } + return states.top(currentType, stream, state); + }; + + states.maybeprop = (currentType, stream, state) => { + if (currentType === ':') { + return pushContext(state, stream, "prop"); + } + return pass(currentType, stream, state); + }; + + states.prop = (currentType, stream, state) => { + if (currentType === ";") return popContext(state); + if (currentType === "{" && allowNested) return pushContext(state, stream, "propBlock"); + if (currentType === "}" || currentType === "{") return popAndPass(currentType, stream, state); + if (currentType === "(") return pushContext(state, stream, "parens"); + + if (currentType === "hash" && !/^#([0-9a-fA-f]{3,4}|[0-9a-fA-f]{6}|[0-9a-fA-f]{8})$/.test(stream.current())) { + override += " error"; + } else if (currentType === "word") { + wordAsValue(stream); + } else if (currentType === "interpolation") { + return pushContext(state, stream, "interpolation"); + } + return "prop"; + }; + + states.propBlock = (currentType, _stream, state) => { + if (currentType === "}") return popContext(state); + if (currentType === "word") { override = "property"; return "maybeprop"; } + return state.context.type; + }; + + states.parens = (currentType, stream, state) => { + if (currentType === "{" || currentType === "}") return popAndPass(currentType, stream, state); + if (currentType === ")") return popContext(state); + if (currentType === "(") return pushContext(state, stream, "parens"); + if (currentType === "interpolation") return pushContext(state, stream, "interpolation"); + if (currentType === "word") wordAsValue(stream); + return "parens"; + }; + + states.pseudo = (currentType, stream, state) => { + if (currentType === 'word') { + override = 'variable-3'; + return state.context.type; + } + return pass(type, stream, state); + }; + + states.at = (currentType, stream, state) => { + if (currentType === ";") return popContext(state); + if (currentType === "{" || currentType === "}") return popAndPass(currentType, stream, state); + if (currentType === "word") override = "tag"; + else if (currentType === "hash") override = "builtin"; + return "at"; + }; + + states.interpolation = (currentType, stream, state) => { + if (currentType === "}") return popContext(state); + if (currentType === "{" || currentType === ";") return popAndPass(currentType, stream, state); + if (currentType === "word") { + override = "variable"; + } else if (currentType !== "variable" && currentType !== "(" && currentType !== ")") { + override = "error"; + } + return "interpolation"; + }; + + return { + startState: base => { + return { + tokenize: null, + state: 'top', + stateArg: null, + context: new Context('block', base || 0, null) + }; + }, + + token: (stream, state) => { + if (!state.tokenize && stream.eatSpace()) return null; + let style = (state.tokenize || tokenBase)(stream, state); + if (style && typeof style === 'object') { + type = style[1]; + style = style[0]; + } + override = style; + state.state = states[state.state](type, stream, state); + return override; + }, + + indent: (state, textAfter) => { + let cx = state.context; + let ch = textAfter && textAfter.charAt(0); + let indent = cx.indent; + if (cx.type === "prop" && (ch === "}" || ch === ")")) cx = cx.prev; + if (cx.prev) { + if (ch === "}" && (cx.type === "block" || cx.type === "top" || + cx.type === "interpolation")) { + // Resume indentation from parent context. + cx = cx.prev; + indent = cx.indent; + } else if (ch === ")" && (cx.type === "parens") || + ch === "{" && (cx.type === "at" || cx.type === "atBlock")) { + // Dedent relative to current context. + indent = Math.max(0, cx.indent - indentUnit); + cx = cx.prev; + } + } + return indent; + }, + electricChars: "}", + blockCommentStart: "/*", + blockCommentEnd: "*/", + fold: "brace" + }; + }); + + const keywords = { + colorKeywords: [ + "aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige", + "bisque", "black", "blanchedalmond", "blue", "blueviolet", "brown", + "burlywood", "cadetblue", "chartreuse", "chocolate", "coral", "cornflowerblue", + "cornsilk", "crimson", "cyan", "darkblue", "darkcyan", "darkgoldenrod", + "darkgray", "darkgreen", "darkkhaki", "darkmagenta", "darkolivegreen", + "darkorange", "darkorchid", "darkred", "darksalmon", "darkseagreen", + "darkslateblue", "darkslategray", "darkturquoise", "darkviolet", + "deeppink", "deepskyblue", "dimgray", "dodgerblue", "firebrick", + "floralwhite", "forestgreen", "fuchsia", "gainsboro", "ghostwhite", + "gold", "goldenrod", "gray", "grey", "green", "greenyellow", "honeydew", + "hotpink", "indianred", "indigo", "ivory", "khaki", "lavender", + "lavenderblush", "lawngreen", "lemonchiffon", "lightblue", "lightcoral", + "lightcyan", "lightgoldenrodyellow", "lightgray", "lightgreen", "lightpink", + "lightsalmon", "lightseagreen", "lightskyblue", "lightslategray", + "lightsteelblue", "lightyellow", "lime", "limegreen", "linen", "magenta", + "maroon", "mediumaquamarine", "mediumblue", "mediumorchid", "mediumpurple", + "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise", + "mediumvioletred", "midnightblue", "mintcream", "mistyrose", "moccasin", + "navajowhite", "navy", "oldlace", "olive", "olivedrab", "orange", "orangered", + "orchid", "palegoldenrod", "palegreen", "paleturquoise", "palevioletred", + "papayawhip", "peachpuff", "peru", "pink", "plum", "powderblue", + "purple", "rebeccapurple", "red", "rosybrown", "royalblue", "saddlebrown", + "salmon", "sandybrown", "seagreen", "seashell", "sienna", "silver", "skyblue", + "slateblue", "slategray", "snow", "springgreen", "steelblue", "tan", + "teal", "thistle", "tomato", "turquoise", "violet", "wheat", "white", + "whitesmoke", "yellow", "yellowgreen" + ], + valueKeywords: [ + 'round' + ], + pseudoProperties: [ + 'mark', + 'shield', + 'stroke', + 'fill', + 'symbol', + 'nth-mark', + 'nth-shield', + 'nth-stroke', + 'nth-fill', + 'nth-symbol' + ], + logicKeywords: [ + 'and', + 'or' + ] + }; + + CodeMirror.defineMIME('text/geocss', { + ...Object.keys(keywords).reduce((allKeywords, name) => ({ + ...allKeywords, + [name]: keywords[name].reduce((keywordObj, key) => ({ + ...keywordObj, + [key]: true + }), {}) + }), {}), + propertyKeywords: { + "mark": { + values: { + // missing updaload resources + // 'url(circle)': true, + 'symbol(circle)': true + } + }, + "mark-composite": true, + "mark-mime": true, + "mark-geometry": true, + "mark-size": true, + "mark-rotation": true, + "mark-label-obstacle": true, + "mark-anchor": true, + "mark-offset": true, + "z-index": true, + "stroke": true, + "stroke-composite": true, + "stroke-geometry": true, + "stroke-offset": true, + "stroke-mime": true, + "stroke-opacity": true, + "stroke-width": true, + "stroke-size": true, + "stroke-rotation": true, + "stroke-linecap": true, + "stroke-linejoin": true, + "stroke-dasharray": true, + "stroke-dashoffset": true, + "stroke-repeat": true, + "stroke-label-obstacle": true, + "fill": true, + "fill-composite": true, + "fill-geometry": true, + "fill-mime": true, + "fill-opacity": true, + "fill-size": true, + "fill-rotation": true, + "fill-label-obstacle": true, + "graphic-margin": true, + "random": true, + "random-seed": true, + "random-rotation": true, + "random-symbol-count": true, + "random-tile-size": true, + "label": true, + "label-geometry": true, + "label-anchor": true, + "label-offset": true, + "label-rotation": true, + "label-z-index": true, + "shield": true, + "shield-mime": true, + "font-family": true, + "font-fill": true, + "font-style": true, + "font-weight": true, + "font-size": true, + "halo-radius": true, + "halo-color": true, + "halo-opacity": true, + "label-padding": true, + "label-group": true, + "label-max-displacement": true, + "label-min-group-distance": true, + "label-repeat": true, + "label-all-group": true, + "label-remove-overlaps": true, + "label-allow-overruns": true, + "label-follow-line": true, + "label-max-angle-delta": true, + "label-auto-wrap": true, + "label-force-ltr": true, + "label-conflict-resolution": true, + "label-fit-goodness": true, + "label-priority": true, + "shield-resize": true, + "shield-margin": true, + "label-underline-text": true, + "label-strikethrough-text": true, + "label-char-spacing": true, + "label-word-spacing": true, + "raster-channels": true, + "raster-composite": true, + "raster-geometry": true, + "raster-opacity": true, + "raster-contrast-enhancement": true, + "raster-contrast-enhancement-algorithm": true, + "raster-contrast-enhancement-min": true, + "raster-contrast-enhancement-max": true, + "raster-gamma": true, + "raster-z-index": true, + "raster-color-map": true, + "raster-color-map-type": true, + "composite": true, + "composite-base": true, + "geometry": true, + "sort-by": true, + "sort-by-group": true, + "transform": true, + "size": true, + "rotation": true + }, + envKeywords: { + sd: { + localPart: 'env' + }, + scale: { + localPart: 'env' + } + }, + allowNested: true, + name: 'geocss' + }); +}; diff --git a/web/client/epics/__tests__/layers-test.js b/web/client/epics/__tests__/layers-test.js index 579807b475..d9c6951e9c 100644 --- a/web/client/epics/__tests__/layers-test.js +++ b/web/client/epics/__tests__/layers-test.js @@ -12,13 +12,14 @@ const configureMockStore = require('redux-mock-store').default; const { createEpicMiddleware, combineEpics } = require('redux-observable'); const { refreshLayers, LAYERS_REFRESHED, LAYERS_REFRESH_ERROR, UPDATE_NODE, - updateLayerDimension, CHANGE_LAYER_PARAMS + updateLayerDimension, CHANGE_LAYER_PARAMS, updateSettingsParams, UPDATE_SETTINGS } = require('../../actions/layers'); +const { SET_CONTROL_PROPERTY } = require('../../actions/controls'); const { testEpic } = require('./epicTestUtils'); -const { refresh, updateDimension } = require('../layers'); +const { refresh, updateDimension, updateSettingsParamsEpic } = require('../layers'); const rootEpic = combineEpics(refresh); const epicMiddleware = createEpicMiddleware(rootEpic); const mockStore = configureMockStore([epicMiddleware]); @@ -126,4 +127,99 @@ describe('layers Epics', () => { done(); }, state); }); + + it('test updateSettingsParamsEpic with update to false', done => { + + const state = { + controls: { + layersettings: { + initialSettings: { + id: 'layerid', + name: 'layerName', + style: '' + }, + originalSettings: { + + } + } + } + }; + + testEpic( + updateSettingsParamsEpic, + 2, + updateSettingsParams({style: 'generic'}), + actions => { + expect(actions.length).toBe(2); + actions.map((action) => { + switch (action.type) { + case SET_CONTROL_PROPERTY: + expect(action.control).toBe('layersettings'); + expect(action.property).toBe('originalSettings'); + expect(action.value).toEqual({ style: '' }); + break; + case UPDATE_SETTINGS: + expect(action.options).toEqual({style: 'generic'}); + break; + default: + expect(true).toBe(false); + } + }); + done(); + }, state); + }); + + it('test updateSettingsParamsEpic with update to true', done => { + + const state = { + controls: { + layersettings: { + initialSettings: { + id: 'layerId', + name: 'layerName', + style: '' + }, + originalSettings: { + + } + } + }, + layers: { + settings: { + expanded: true, + node: 'layerId', + nodeType: 'layers', + options: { opacity: 1 } + } + } + }; + + testEpic( + updateSettingsParamsEpic, + 3, + updateSettingsParams({style: 'generic'}, true), + actions => { + expect(actions.length).toBe(3); + actions.map((action) => { + switch (action.type) { + case SET_CONTROL_PROPERTY: + expect(action.control).toBe('layersettings'); + expect(action.property).toBe('originalSettings'); + expect(action.value).toEqual({ style: '' }); + break; + case UPDATE_SETTINGS: + expect(action.options).toEqual({style: 'generic'}); + break; + case UPDATE_NODE: + expect(action.node).toEqual('layerId'); + expect(action.nodeType).toEqual('layers'); + expect(action.options).toEqual({opacity: 1, style: 'generic'}); + break; + default: + expect(true).toBe(false); + } + }); + done(); + }, state); + }); }); diff --git a/web/client/epics/__tests__/styleeditor-test.js b/web/client/epics/__tests__/styleeditor-test.js new file mode 100644 index 0000000000..9d079a97c7 --- /dev/null +++ b/web/client/epics/__tests__/styleeditor-test.js @@ -0,0 +1,540 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const expect = require('expect'); + +const { + UPDATE_NODE, + UPDATE_SETTINGS_PARAMS +} = require('../../actions/layers'); + +const { + SET_CONTROL_PROPERTY +} = require('../../actions/controls'); + +const { + SHOW_NOTIFICATION +} = require('../../actions/notifications'); + +const { + REMOVE_ADDITIONAL_LAYER, + UPDATE_OPTIONS_BY_OWNER +} = require('../../actions/additionallayers'); + +const { + RESET_STYLE_EDITOR, + LOADING_STYLE, + SELECT_STYLE_TEMPLATE, + LOADED_STYLE, + ERROR_STYLE, + UPDATE_TEMPORARY_STYLE, + toggleStyleEditor, + updateStatus, + selectStyleTemplate, + createStyle, + updateStyleCode, + deleteStyle +} = require('../../actions/styleeditor'); + +const { + toggleStyleEditorEpic, + updateLayerOnStatusChangeEpic, + updateTemporaryStyleEpic, + createStyleEpic, + updateStyleCodeEpic, + deleteStyleEpic +} = require('../styleeditor'); + +const { testEpic } = require('./epicTestUtils'); + +describe('styleeditor Epics', () => { + + it('test toggleStyleEditorEpic enabled to true', (done) => { + + const state = { + layers: { + flat: [ + { + id: 'layerId', + name: 'layerName', + url: '/geoserver/' + } + ], + selected: [ + 'layerId' + ] + } + }; + const NUMBER_OF_ACTIONS = 1; + + const results = (actions) => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + try { + actions.map((action) => { + switch (action.type) { + case LOADING_STYLE: + expect(action.status).toBe('global'); + break; + default: + expect(true).toBe(false); + } + }); + } catch(e) { + done(e); + } + done(); + }; + + testEpic( + toggleStyleEditorEpic, + NUMBER_OF_ACTIONS, + toggleStyleEditor(undefined, true), + results, + state); + + }); + it('test toggleStyleEditorEpic enabled to false', (done) => { + + const state = { + layers: { + flat: [ + { + id: 'layerId', + name: 'layerName', + url: '/geoserver/' + } + ], + selected: [ + 'layerId' + ] + } + }; + const NUMBER_OF_ACTIONS = 2; + + const results = (actions) => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + try { + actions.map((action) => { + switch (action.type) { + case RESET_STYLE_EDITOR: + expect(action.type).toBe(RESET_STYLE_EDITOR); + break; + case REMOVE_ADDITIONAL_LAYER: + expect(action.owner).toBe('styleeditor'); + break; + default: + expect(true).toBe(false); + } + }); + } catch(e) { + done(e); + } + done(); + }; + + testEpic( + toggleStyleEditorEpic, + NUMBER_OF_ACTIONS, + toggleStyleEditor(undefined, false), + results, + state); + + }); + it('test updateLayerOnStatusChangeEpic status template', (done) => { + + const state = { + layers: { + flat: [ + { + id: 'layerId', + name: 'layerName', + url: '/geoserver/' + } + ], + selected: [ + 'layerId' + ] + } + }; + const NUMBER_OF_ACTIONS = 1; + + const results = (actions) => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + try { + actions.map((action) => { + switch (action.type) { + case LOADING_STYLE: + expect(action.status).toBe('global'); + break; + default: + expect(true).toBe(false); + } + }); + } catch(e) { + done(e); + } + done(); + }; + + testEpic( + updateLayerOnStatusChangeEpic, + NUMBER_OF_ACTIONS, + updateStatus('template'), + results, + state); + + }); + it('test updateLayerOnStatusChangeEpic status edit with describe', (done) => { + + const state = { + layers: { + flat: [ + { + id: 'layerId', + name: 'layerName', + url: 'base/web/client/test-resources/geoserver/', + describeFeatureType: {}, + style: 'test_style' + } + ], + selected: [ + 'layerId' + ] + }, + styleeditor: { + service: { + baseUrl: 'base/web/client/test-resources/geoserver/' + } + } + }; + const NUMBER_OF_ACTIONS = 1; + const results = (actions) => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + try { + actions.map((action) => { + switch (action.type) { + case SELECT_STYLE_TEMPLATE: + expect(action.code).toBe('* { stroke: #ff0000; }'); + expect(action.format).toBe('css'); + expect(action.init).toBe(true); + break; + default: + expect(true).toBe(false); + } + }); + } catch(e) { + done(e); + } + done(); + }; + + testEpic( + updateLayerOnStatusChangeEpic, + NUMBER_OF_ACTIONS, + updateStatus('edit'), + results, + state); + }); + it('test updateTemporaryStyleEpic', (done) => { + const state = { + layers: { + flat: [ + { + id: 'layerId', + name: 'layerName', + url: 'base/web/client/test-resources/geoserver/', + describeFeatureType: {}, + style: 'test_style' + } + ], + selected: [ + 'layerId' + ] + }, + styleeditor: { + service: { + baseUrl: 'base/web/client/test-resources/geoserver/' + } + } + }; + const NUMBER_OF_ACTIONS = 3; + const results = (actions) => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + try { + actions.map((action) => { + switch (action.type) { + case LOADING_STYLE: + expect(action.status).toBe(undefined); + break; + case ERROR_STYLE: + expect(action.status).toBe(undefined); + expect(action.error).toExist(); + break; + case LOADED_STYLE: + expect(action).toExist(); + break; + default: + expect(true).toBe(false); + } + }); + } catch(e) { + done(e); + } + done(); + }; + + testEpic( + updateTemporaryStyleEpic, + NUMBER_OF_ACTIONS, + selectStyleTemplate({ code: '* { stroke: #ff0000; }', templateId: '', format: 'css', init: false }), + results, + state); + }); + + it('test updateTemporaryStyleEpic with temporaryId', (done) => { + const state = { + layers: { + flat: [ + { + id: 'layerId', + name: 'layerName', + url: 'base/web/client/test-resources/geoserver/', + describeFeatureType: {}, + style: 'test_style' + } + ], + selected: [ + 'layerId' + ] + }, + styleeditor: { + service: { + baseUrl: 'base/web/client/test-resources/geoserver/' + }, + temporaryId: 'test_style' + } + }; + const NUMBER_OF_ACTIONS = 4; + const results = (actions) => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + try { + actions.map((action) => { + switch (action.type) { + case LOADING_STYLE: + expect(action.status).toBe(undefined); + break; + case LOADED_STYLE: + expect(action).toExist(); + break; + case UPDATE_OPTIONS_BY_OWNER: + expect(action.owner).toBe('styleeditor'); + expect(action.options[0].style).toBe('test_style'); + expect(action.options[0]._v_).toExist(); + expect(action.options[0].singleTile).toBe(true); + break; + case UPDATE_TEMPORARY_STYLE: + expect(action.temporaryId).toBe('test_style'); + expect(action.templateId).toBe(''); + expect(action.code).toBe('* { stroke: #ff0000; }'); + expect(action.format).toBe('css'); + expect(action.init).toBe(false); + break; + default: + expect(true).toBe(false); + } + }); + } catch(e) { + done(e); + } + done(); + }; + + testEpic( + updateTemporaryStyleEpic, + NUMBER_OF_ACTIONS, + selectStyleTemplate({ code: '* { stroke: #ff0000; }', templateId: '', format: 'css', init: false }), + results, + state); + }); + + it('test createStyleEpic', (done) => { + const state = { + layers: { + flat: [ + { + id: 'layerId', + name: 'layerName', + url: 'base/web/client/test-resources/geoserver/', + describeFeatureType: {}, + style: 'test_style' + } + ], + selected: [ + 'layerId' + ] + }, + styleeditor: { + service: { + baseUrl: 'base/web/client/test-resources/geoserver/' + } + } + }; + const NUMBER_OF_ACTIONS = 3; + const results = (actions) => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + try { + actions.map((action) => { + switch (action.type) { + case LOADING_STYLE: + expect(action.status).toBe(''); + break; + case ERROR_STYLE: + expect(action.status).toBe(''); + expect(action.error).toExist(); + break; + case LOADED_STYLE: + expect(action).toExist(); + break; + default: + expect(true).toBe(false); + } + }); + } catch(e) { + done(e); + } + done(); + }; + + testEpic( + createStyleEpic, + NUMBER_OF_ACTIONS, + createStyle({title: 'style TitLe'}), + results, + state); + }); + + it('test updateStyleCodeEpic', (done) => { + const state = { + layers: { + flat: [ + { + id: 'layerId', + name: 'layerName', + url: 'base/web/client/test-resources/geoserver/', + describeFeatureType: {}, + style: 'test_style' + } + ], + selected: [ + 'layerId' + ] + }, + styleeditor: { + service: { + baseUrl: 'base/web/client/test-resources/geoserver/' + }, + code: '* { stroke: #ff0000; }' + } + }; + const NUMBER_OF_ACTIONS = 5; + const results = (actions) => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + try { + actions.map((action) => { + switch (action.type) { + case LOADING_STYLE: + expect(action.status).toBe('global'); + break; + case UPDATE_NODE: + expect(action.node).toBe('layerId'); + expect(action.nodeType).toBe('layer'); + expect(action.options._v_).toExist(); + break; + case LOADED_STYLE: + expect(action).toExist(); + break; + case UPDATE_TEMPORARY_STYLE: + expect(action.temporaryId).toBe(undefined); + expect(action.templateId).toBe(''); + expect(action.code).toBe('* { stroke: #ff0000; }'); + expect(action.format).toBe('css'); + expect(action.init).toBe(true); + break; + case SHOW_NOTIFICATION: + expect(action.title).toBe('styleeditor.savedStyleTitle'); + expect(action.message).toBe('styleeditor.savedStyleMessage'); + expect(action.uid).toBe('savedStyleTitle'); + expect(action.autoDismiss).toBe(5); + expect(action.level).toBe('success'); + break; + default: + expect(true).toBe(false); + } + }); + } catch(e) { + done(e); + } + done(); + }; + + testEpic( + updateStyleCodeEpic, + NUMBER_OF_ACTIONS, + updateStyleCode(), + results, + state); + }); + + it('test deleteStyleEpic', (done) => { + const state = { + layers: { + flat: [ + { + id: 'layerId', + name: 'TEST_LAYER_3', + url: 'base/web/client/test-resources/geoserver/', + describeFeatureType: {}, + style: 'test_style' + } + ], + selected: [ + 'layerId' + ] + }, + styleeditor: { + service: { + baseUrl: 'base/web/client/test-resources/geoserver/' + }, + code: '* { stroke: #ff0000; }' + } + }; + const NUMBER_OF_ACTIONS = 6; + const results = (actions) => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + try { + expect(actions[1].type).toBe(UPDATE_SETTINGS_PARAMS); + expect(actions[2].type).toBe(LOADED_STYLE); + expect(actions[3].type).toBe(SET_CONTROL_PROPERTY); + expect(actions[4].type).toBe(SET_CONTROL_PROPERTY); + expect(actions[5].type).toBe(SHOW_NOTIFICATION); + + expect(actions[5].level).toBe('success'); + } catch(e) { + done(e); + } + done(); + }; + + testEpic( + deleteStyleEpic, + NUMBER_OF_ACTIONS, + deleteStyle('test_style'), + results, + state); + }); +}); diff --git a/web/client/epics/layers.js b/web/client/epics/layers.js index 83e0c5057f..618a37017b 100644 --- a/web/client/epics/layers.js +++ b/web/client/epics/layers.js @@ -8,8 +8,11 @@ const Rx = require('rxjs'); const Api = require('../api/WMS'); -const { REFRESH_LAYERS, UPDATE_LAYERS_DIMENSION, layersRefreshed, updateNode, layersRefreshError, changeLayerParams} = require('../actions/layers'); -const {getLayersWithDimension} = require('../selectors/layers'); +const { REFRESH_LAYERS, UPDATE_LAYERS_DIMENSION, UPDATE_SETTINGS_PARAMS, layersRefreshed, updateNode, updateSettings, layersRefreshError, changeLayerParams} = require('../actions/layers'); +const {getLayersWithDimension, layerSettingSelector} = require('../selectors/layers'); + +const { setControlProperty } = require('../actions/controls'); +const { initialSettingsSelector, originalSettingsSelector } = require('../selectors/controls'); const LayersUtils = require('../utils/LayersUtils'); @@ -108,7 +111,45 @@ const updateDimension = (action$, {getState = () => {}} = {}) => ) ) ); + +/** + * Update original and initial state of layer settings. + * Initial settings is the layer object before settings session started. + * Original settings contains only changed properties keys with initial value stored during settings session. + * Action performed: updateSettings, setControlProperty and updateNode (updateNode only if action.update is true) + * @memberof epics.layers + * @param {external:Observable} action$ manages `UPDATE_SETTINGS_PARAMS` + * @return {external:Observable} + */ +const updateSettingsParamsEpic = (action$, store) => + action$.ofType(UPDATE_SETTINGS_PARAMS) + .switchMap(({ newParams = {}, update }) => { + + const state = store.getState(); + const settings = layerSettingSelector(state); + const initialSettings = initialSettingsSelector(state); + const orig = originalSettingsSelector(state); + + let originalSettings = { ...(orig || {}) }; + // TODO one level only storage of original settings for the moment + Object.keys(newParams).forEach((key) => { + originalSettings[key] = initialSettings && initialSettings[key]; + }); + + return Rx.Observable.of( + updateSettings(newParams), + // update changed keys to verify only modified values (internal state) + setControlProperty('layersettings', 'originalSettings', originalSettings), + ...(update ? [updateNode( + settings.node, + settings.nodeType, + { ...settings.options, ...newParams } + )] : []) + ); + }); + module.exports = { refresh, - updateDimension + updateDimension, + updateSettingsParamsEpic }; diff --git a/web/client/epics/styleeditor.js b/web/client/epics/styleeditor.js new file mode 100644 index 0000000000..15b17d4166 --- /dev/null +++ b/web/client/epics/styleeditor.js @@ -0,0 +1,508 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const Rx = require('rxjs'); +const { head, isArray, template } = require('lodash'); +const { success, error } = require('../actions/notifications'); +const { UPDATE_NODE, updateNode, updateSettingsParams } = require('../actions/layers'); +const { updateAdditionalLayer, removeAdditionalLayer, updateOptionsByOwner } = require('../actions/additionallayers'); +const { getDescribeLayer, getLayerCapabilities } = require('../actions/layerCapabilities'); +const { setControlProperty } = require('../actions/controls'); +const url = require('url'); + +const { + SELECT_STYLE_TEMPLATE, + updateTemporaryStyle, + TOGGLE_STYLE_EDITOR, + resetStyleEditor, + UPDATE_STATUS, + loadingStyle, + LOADED_STYLE, + loadedStyle, + CREATE_STYLE, + updateStatus, + selectStyleTemplate, + errorStyle, + UPDATE_STYLE_CODE, + EDIT_STYLE_CODE, + DELETE_STYLE, + setEditPermissionStyleEditor +} = require('../actions/styleeditor'); + +const StylesAPI = require('../api/geoserver/Styles'); +const LayersAPI = require('../api/geoserver/Layers'); + +const { + temporaryIdSelector, + codeStyleSelector, + formatStyleSelector, + statusStyleSelector, + selectedStyleSelector, + enabledStyleEditorSelector, + loadingStyleSelector, + styleServiceSelector, + getUpdatedLayer +} = require('../selectors/styleeditor'); + +const { getSelectedLayer } = require('../selectors/layers'); +const { isAdminUserSelector } = require('../selectors/security'); +const { generateTemporaryStyleId, generateStyleId, STYLE_OWNER_NAME, getNameParts } = require('../utils/StyleEditorUtils'); +const { normalizeUrl } = require('../utils/PrintUtils'); +const { initialSettingsSelector, originalSettingsSelector } = require('../selectors/controls'); +/* + * Observable to get code of a style, it works only in edit status + */ +const getStyleCodeObservable = ({status, styleName, baseUrl}) => + status === 'edit' ? + Rx.Observable.defer(() => + StylesAPI.getStyleCodeByName({ + baseUrl, + styleName + }) + ) + .switchMap(style => Rx.Observable.of( + selectStyleTemplate({ + code: style.code, + templateId: '', + format: style.format, + init: true + }) + )) + .catch(err => Rx.Observable.of(errorStyle('edit', err))) + : Rx.Observable.empty(); +/* + * Observable delete styles. + * silent to false hide notifications + */ +const deleteStyleObservable = ({styleName, baseUrl}, silent) => + Rx.Observable.defer(() => + StylesAPI.deleteStyle({ + baseUrl, + styleName + }) + ) + .switchMap(() => silent ? Rx.Observable.empty() : Rx.Observable.of( + success({ + title: "styleeditor.deletedStyleSuccessTitle", + message: "styleeditor.deletedStyleSuccessMessage", + uid: "deletedStyleSuccess", + autoDismiss: 5 + }) + ) + ) + .catch(() => silent ? Rx.Observable.empty() : Rx.Observable.of( + error({ + title: "styleeditor.deletedStyleErrorTitle", + message: "styleeditor.deletedStyleErrorMessage", + uid: "deletedStyleError", + autoDismiss: 5 + }) + ) +); +/* + * Observable to delete temporary style from server and reset state of style editor + */ +const resetStyleEditorObservable = state => { + const styleName = temporaryIdSelector(state); + const { baseUrl = '' } = styleServiceSelector(state); + return Rx.Observable.of( + resetStyleEditor(), + removeAdditionalLayer({ owner: STYLE_OWNER_NAME }) + ) + .merge(styleName ? deleteStyleObservable({styleName, baseUrl}, true) : Rx.Observable.empty()); +}; +/* + * Observable to add a style to available style list and update the layer object on the server + */ +const updateAvailableStylesObservable = ({baseUrl, layer, styleName, format, title, _abstract}) => + Rx.Observable.defer(() => + LayersAPI.updateAvailableStyles({ + baseUrl, + layerName: layer.name, + styles: [{ name: styleName }] + }) + ) + .switchMap(() => { + const newStyle = { + filename: `${styleName}.${format}`, + format, + name: styleName, + title, + _abstract + }; + const defaultStyle = head(layer.availableStyles); + const availableStyles = layer.availableStyles && [defaultStyle, newStyle, ...layer.availableStyles.filter((sty, idx) => idx > 0)] || [newStyle]; + return Rx.Observable.of( + updateSettingsParams({ availableStyles }, true), + loadedStyle() + ); + }) + .catch(() => Rx.Observable.of(loadedStyle())) + .startWith(loadingStyle('global')); + +/* + * Observable to create/update style + */ +const createUpdateStyleObservable = ({baseUrl, update, code, format, styleName, status}, successActions = [], errorActions = []) => + Rx.Observable.defer(() => + StylesAPI[update ? 'updateStyle' : 'createStyle']({ + baseUrl, + code, + format, + styleName + }) + ) + .switchMap(() => isArray(successActions) && Rx.Observable.of(loadedStyle(), ...successActions) || successActions) + .catch((err) => Rx.Observable.of(errorStyle(status, err), loadedStyle(), ...errorActions)) + .startWith(loadingStyle(status)); + +/* + * Observable to verify if getLayerCapabilities/getDescribeLayer are correctly updated + */ +const updateLayerSettingsObservable = (action$, store, filter = () => true, startActions = [], endObservable = () => {}) => + Rx.Observable.of(loadingStyle('global'), ...startActions) + .merge( + action$.ofType(UPDATE_NODE) + .filter(() => { + const layer = getSelectedLayer(store.getState()); + return filter(layer); + }) + .switchMap(() => { + const layer = getSelectedLayer(store.getState()); + return endObservable(layer); + }) + .catch(err => Rx.Observable.of(errorStyle('global', err), loadedStyle())) + .takeUntil(action$.ofType(LOADED_STYLE)) + ); + +/** + * Epics for Style Editor + * @name epics.styleeditor + * @type {object} + */ +module.exports = { + /** + * Gets every `TOGGLE_STYLE_EDITOR` event. + * Initialize or reset style editor based on action.enabled. + * Send a get capaibilities to retrieve availables style and then a rest style request for every + * style to get all info needed (eg. format, filename, ...) + * @param {external:Observable} action$ manages `TOGGLE_STYLE_EDITOR` and `UPDATE_NODE` + * @memberof epics.styleeditor + * @return {external:Observable} + */ + toggleStyleEditorEpic: (action$, store) => + action$.ofType(TOGGLE_STYLE_EDITOR) + .filter(() => !loadingStyleSelector(store.getState())) + .switchMap((action) => { + + const state = store.getState(); + + if (!action.enabled) return resetStyleEditorObservable(state); + if (enabledStyleEditorSelector(state)) return Rx.Observable.empty(); + + const layer = action.layer || getSelectedLayer(state); + if (!layer || layer && !layer.url) return Rx.Observable.empty(); + + const normalizedUrl = normalizeUrl(layer.url); + const parsedUrl = url.parse(normalizedUrl); + + return updateLayerSettingsObservable(action$, store, + updatedLayer => updatedLayer && updatedLayer.capabilities, + [getLayerCapabilities(layer)], + (updatedLayer) => { + + // layer groups have not available styles + if (!updatedLayer.availableStyles) { + return Rx.Observable.of(errorStyle('availableStyles', { status: 401 })); + } + + const setAdditionalLayers = (availableStyles = []) => Rx.Observable.of( + updateAdditionalLayer(updatedLayer.id, STYLE_OWNER_NAME, 'override', {}), + updateSettingsParams({ availableStyles }), + loadedStyle() + ); + if (!isAdminUserSelector(state)) { + return setAdditionalLayers(updatedLayer.availableStyles); + } + + return Rx.Observable.defer(() => + StylesAPI.getStylesInfo({ + baseUrl: `${parsedUrl.protocol}//${parsedUrl.host}/geoserver/`, + styles: updatedLayer && updatedLayer.availableStyles || [] + }) + ) + .switchMap(availableStyles => { + return setAdditionalLayers(availableStyles); + }); + } + ); + }), + /** + * Gets every `UPDATE_STATUS` event. + * If status is true a describe layer request is sent to get all feature properties needed. + * If status is equal to 'edit' a rest style request is sent to get the style code. + * @param {external:Observable} action$ manages `UPDATE_STATUS` and `UPDATE_NODE` + * @memberof epics.styleeditor + * @return {external:Observable} + */ + updateLayerOnStatusChangeEpic: (action$, store) => + action$.ofType(UPDATE_STATUS) + .filter(({ status }) => !!status) + .switchMap((action) => { + + const state = store.getState(); + + const layer = getUpdatedLayer(state); + + const describeAction = layer && !layer.describeFeatureType && getDescribeLayer(layer.url, layer); + const selectedStyle = selectedStyleSelector(state); + const styleName = selectedStyle || layer.availableStyles && layer.availableStyles[0] && layer.availableStyles[0].name; + + const { baseUrl = '' } = styleServiceSelector(state); + + return describeAction && updateLayerSettingsObservable(action$, store, + updatedLayer => updatedLayer && updatedLayer.describeLayer, + [ describeAction ], + (updatedLayer) => { + return Rx.Observable.concat( + getStyleCodeObservable({ + status: action.status, + styleName, + baseUrl + }), + Rx.Observable.of( + setEditPermissionStyleEditor(!(updatedLayer + && updatedLayer.describeLayer + && updatedLayer.describeLayer.error === 401)), + loadedStyle() + ) + ); + } + ) || getStyleCodeObservable({ + status: action.status, + styleName, + baseUrl + }); + }), + /** + * Gets every `SELECT_STYLE_TEMPLATE`, `EDIT_STYLE_CODE` events. + * Creates/Updates a temporary style used to preview templates or edits of style code. + * @param {external:Observable} action$ manages `SELECT_STYLE_TEMPLATE` and `EDIT_STYLE_CODE` + * @memberof epics.styleeditor + * @return {external:Observable} + */ + updateTemporaryStyleEpic: (action$, store) => + action$.ofType(SELECT_STYLE_TEMPLATE, EDIT_STYLE_CODE) + .switchMap(action => { + + const state = store.getState(); + const temporaryId = temporaryIdSelector(state); + const styleName = temporaryId || generateTemporaryStyleId(); + const format = action.format || formatStyleSelector(state); + const status = statusStyleSelector(state); + const { baseUrl = '', formats } = styleServiceSelector(state); + + const updateTmpCode = createUpdateStyleObservable( + { + update: true, + code: action.code, + format, + styleName, + status, + baseUrl + }, + [ + updateOptionsByOwner(STYLE_OWNER_NAME, [{ style: styleName, _v_: Date.now(), singleTile: true }]), + updateTemporaryStyle({ + temporaryId: styleName, + templateId: action.templateId || '', + code: action.code, + format, + init: action.init + }) + ], + status === 'edit' ? [] : [ + error({ + title: "styleeditor.updateTmpErrorTitle", + message: "styleeditor.updateTmpStyleErrorMessage", + uid: "updateTmpStyleError", + autoDismiss: 5 + }) + ] + ); + + const availableFormat = isArray(formats) && formats.indexOf('css') !== -1 && 'css' || 'sld'; + // valid code needed to initialize and create temp style + const baseCode = availableFormat === 'css' && '* { stroke: #888888; }' || + availableFormat === 'sld' && '\n\n\n\t\n\t\tDefault Style\n\t\t\n\t\t\t${styleTitle}\n\t\t\t${styleAbstract}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tRule Name\n\t\t\t\t\tRule Title\n\t\t\t\t\tRule Abstract\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t#0000FF\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tsquare\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t#FF0000\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n' + || ''; + + return temporaryId && updateTmpCode || + createUpdateStyleObservable({ + code: baseCode, + format: availableFormat, + styleName, + status, + baseUrl + }, + updateTmpCode, + [ + error({ + title: "styleeditor.createTmpErrorTitle", + message: "styleeditor.createTmpStyleErrorMessage", + uid: "createTmpStyleError", + autoDismiss: 5 + }) + ] + ); + }), + /** + * Gets every `CREATE_STYLE` event. + * Create a new style. + * @param {external:Observable} action$ manages `CREATE_STYLE` + * @memberof epics.styleeditor + * @return {external:Observable} + */ + createStyleEpic: (action$, store) => + action$.ofType(CREATE_STYLE) + .switchMap(action => { + const state = store.getState(); + const code = codeStyleSelector(state); + const layer = getUpdatedLayer(state); + const { workspace } = getNameParts(layer.name); + // add new style to layer workspace + const styleName = `${workspace ? `${workspace}:` : ''}${generateStyleId(action.settings)}`; + const format = formatStyleSelector(state); + const { title = '', _abstract = '' } = action.settings || {}; + const { baseUrl = '' } = styleServiceSelector(state); + + return createUpdateStyleObservable( + { + code: template(code)({styleTitle: title, styleAbstract: _abstract}), + format, + styleName, + status, + baseUrl + }, + Rx.Observable.of( + updateOptionsByOwner(STYLE_OWNER_NAME, [{}]), + updateSettingsParams({style: styleName || ''}, true), + updateStatus(''), + loadedStyle() + ) + .merge( + updateAvailableStylesObservable({layer, styleName, format, title, _abstract, baseUrl}) + ), + [ + error({ + title: "styleeditor.createStyleErrorTitle", + message: "styleeditor.createStyleErrorMessage", + uid: "createStyleError", + autoDismiss: 5 + }) + ] + ); + }), + /** + * Gets every `UPDATE_STYLE_CODE` event. + * Update and save accepted chenges of edited code + * @param {external:Observable} action$ manages `UPDATE_STYLE_CODE` + * @memberof epics.styleeditor + * @return {external:Observable} + */ + updateStyleCodeEpic: (action$, store) => + action$.ofType(UPDATE_STYLE_CODE) + .switchMap(() => { + + const state = store.getState(); + + const format = formatStyleSelector(state); + const code = codeStyleSelector(state); + const styleName = selectedStyleSelector(state); + const temporaryId = temporaryIdSelector(state); + const layer = getUpdatedLayer(state); + const { baseUrl = '' } = styleServiceSelector(state); + + return createUpdateStyleObservable( + { + update: true, + code, + format, + styleName, + status: 'global', + baseUrl + }, + [ + updateNode(layer.id, 'layer', { _v_: Date.now() }), + updateTemporaryStyle({ + temporaryId: temporaryId, + templateId: '', + code, + format, + init: true + }), + success({ + title: "styleeditor.savedStyleTitle", + message: "styleeditor.savedStyleMessage", + uid: "savedStyleTitle", + autoDismiss: 5 + }) + ], + [ + error({ + title: "styleeditor.updateStyleErrorTitle", + message: "styleeditor.updateStyleErrorMessage", + uid: "updateStyleError", + autoDismiss: 5 + }) + ] + ); + }), + /** + * Gets every `DELETE_STYLE` event. + * Remove style from layer object and delete it + * @param {external:Observable} action$ manages `DELETE_STYLE` + * @memberof epics.styleeditor + * @return {external:Observable} + */ + deleteStyleEpic: (action$, store) => + action$.ofType(DELETE_STYLE) + .filter(({styleName}) => !!styleName) + .switchMap(({styleName}) => { + + const state = store.getState(); + const layer = getUpdatedLayer(state); + const { baseUrl = '' } = styleServiceSelector(state); + const originalSettings = originalSettingsSelector(state); + const initialSettings = initialSettingsSelector(state); + + return Rx.Observable.defer(() => + LayersAPI.removeStyles({ + baseUrl, + layerName: layer.name, + styles: [{ name: styleName }] + }) + ) + .switchMap(() => { + const availableStyles = layer.availableStyles && layer.availableStyles.filter(({name}) => name !== styleName) || []; + return Rx.Observable.concat( + Rx.Observable.of( + updateSettingsParams({style: '', availableStyles}, true), + loadedStyle(), + // style has been deleted so original and initial settings must be overrided + setControlProperty('layersettings', 'originalSettings', {...originalSettings, style: ''}), + setControlProperty('layersettings', 'initialSettings', {...initialSettings, style: ''}) + ), + deleteStyleObservable({styleName, baseUrl}) + ); + }) + .catch(() => Rx.Observable.of(loadedStyle())) + .startWith(() => Rx.Observable.of(loadingStyle('global'))); + }) +}; + diff --git a/web/client/localConfig.json b/web/client/localConfig.json index a31fcfce26..0ea98d38ee 100644 --- a/web/client/localConfig.json +++ b/web/client/localConfig.json @@ -344,7 +344,8 @@ }, "OmniBar", "Login", "Save", "SaveAs", "BurgerMenu", "Expander", "Undo", "Redo", "FullScreen", "GlobeViewSwitcher", "SearchServicesConfig", "WidgetsBuilder", "Widgets", "FloatingLegend", - "FeedbackMask" + "FeedbackMask", + "StyleEditor" ], "embedded": [{ "name": "Map", diff --git a/web/client/plugins/Map.jsx b/web/client/plugins/Map.jsx index 741df828b7..d0210cc6d8 100644 --- a/web/client/plugins/Map.jsx +++ b/web/client/plugins/Map.jsx @@ -348,7 +348,8 @@ module.exports = { reducers: { draw: require('../reducers/draw'), highlight: require('../reducers/highlight'), - maptype: require('../reducers/maptype') + maptype: require('../reducers/maptype'), + additionallayers: require('../reducers/additionallayers') }, epics: assign({}, {handleCreationLayerError, handleCreationBackgroundError, resetMapOnInit}) }; diff --git a/web/client/plugins/StyleEditor.jsx b/web/client/plugins/StyleEditor.jsx new file mode 100644 index 0000000000..4ac979878a --- /dev/null +++ b/web/client/plugins/StyleEditor.jsx @@ -0,0 +1,172 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); +const PropTypes = require('prop-types'); +const { connect } = require('react-redux'); +const { createSelector } = require('reselect'); +const { compose, branch } = require('recompose'); +const assign = require('object-assign'); + +const Loader = require('../components/misc/Loader'); +const BorderLayout = require('../components/layout/BorderLayout'); +const loadingState = require('../components/misc/enhancers/loadingState'); +const emptyState = require('../components/misc/enhancers/emptyState'); +const HTML = require('../components/I18N/HTML'); + +const { + statusStyleSelector, + loadingStyleSelector, + getUpdatedLayer, + errorStyleSelector +} = require('../selectors/styleeditor'); + +const { initStyleService } = require('../actions/styleeditor'); +const { updateSettingsParams } = require('../actions/layers'); + +const { + StyleSelector, + StyleToolbar, + StyleCodeEditor +} = require('./styleeditor/index'); + +const { isSameOrigin } = require('../utils/StyleEditorUtils'); + +class StyleEditorPanel extends React.Component { + static propTypes = { + layer: PropTypes.object, + header: PropTypes.node, + isEditing: PropTypes.bool, + showToolbar: PropTypes.node.bool, + onInit: PropTypes.func, + styleService: PropTypes.object + }; + + static defaultProps = { + layer: {}, + onInit: () => {}, + styleService: { + baseUrl: '/geoserver/', + formats: [ + 'css', + 'sld' + ], + availableUrls: [ + 'http://localhost:8080/geoserver/' + ] + } + }; + + componentWillMount() { + this.props.onInit(this.props.styleService, isSameOrigin(this.props.layer, this.props.styleService)); + } + + render() { + return ( + + {this.props.header} +
+ +
+ : null + } + footer={
}> + {this.props.isEditing ? : } + + ); + } +} +/** + * StyleEditor plugin. + * - Select styles from available styles of the layer + * - Create a new style from a list of template + * - Remove a style + * - Edit css style with preview + * + * Note: current implementation is available only in TOCItemsSettings + * @prop {object} cfg.styleService GeoServer service in use + * @prop {string} cfg.styleService.baseUrl base url of service eg: '/geoserver/' + * @prop {array} cfg.styleService.availableUrls a list of urls that can access directly to the style service + * @prop {array} cfg.styleService.formats supported formats, could be one of [ 'sld' ] or [ 'sld', 'css' ] + * @memberof plugins + * @class StyleEditor + */ +const StyleEditorPlugin = compose( + // No rendering if not active + // eg: now only TOCItemsSettings can active following plugin + branch( + ({ active }) => !active, + () => () => null + ), + // end + connect( + createSelector( + [ + statusStyleSelector, + loadingStyleSelector, + getUpdatedLayer, + errorStyleSelector + ], + (status, loading, layer, error) => ({ + isEditing: status === 'edit', + loading, + layer, + error: !!(error && error.availableStyles) + }) + ), + { + onInit: initStyleService, + onUpdateParams: updateSettingsParams + } + ), + emptyState( + ({ error }) => error, + { + glyph: 'exclamation-mark', + title: , + description: , + style: { + display: 'flex', + width: '100%', + height: '100%', + overflow: 'hidden' + }, + mainViewStyle: { + margin: 'auto', + width: 300 + } + } + ), + loadingState( + ({loading}) => loading === 'global', + { + size: 150, + style: { + margin: 'auto' + } + }, + props =>
+ ) +)(StyleEditorPanel); + +module.exports = { + StyleEditorPlugin: assign(StyleEditorPlugin, { + TOC: { + priority: 1, + container: 'TOCItemSettings', + ToolbarComponent: StyleToolbar + } + }), + reducers: { + styleeditor: require('../reducers/styleeditor') + }, + epics: require('../epics/styleeditor') +}; diff --git a/web/client/plugins/TOCItemsSettings.jsx b/web/client/plugins/TOCItemsSettings.jsx index 8caef3d9c3..2ff9332c13 100644 --- a/web/client/plugins/TOCItemsSettings.jsx +++ b/web/client/plugins/TOCItemsSettings.jsx @@ -10,8 +10,8 @@ const {connect} = require('react-redux'); const {createSelector} = require('reselect'); const {layerSettingSelector, layersSelector, groupsSelector} = require('../selectors/layers'); const {head, isArray} = require('lodash'); -const {withState, compose, defaultProps} = require('recompose'); -const {hideSettings, updateSettings, updateNode} = require('../actions/layers'); +const {compose, defaultProps} = require('recompose'); +const {hideSettings, updateSettings, updateNode, updateSettingsParams} = require('../actions/layers'); const {getLayerCapabilities} = require('../actions/layerCapabilities'); const {currentLocaleSelector} = require('../selectors/locale'); const {updateSettingsLifecycle} = require("../components/TOC/enhancers/tocItemsSettings"); @@ -20,6 +20,9 @@ const defaultSettingsTabs = require('./tocitemssettings/defaultSettingsTabs'); const LayersUtils = require('../utils/LayersUtils'); const {mapLayoutValuesSelector} = require('../selectors/maplayout'); const {isAdminUserSelector} = require('../selectors/security'); +const {setControlProperty} = require('../actions/controls'); +const {toggleStyleEditor} = require('../actions/styleeditor'); +const { initialSettingsSelector, originalSettingsSelector, activeTabSettingsSelector } = require('../selectors/controls'); const tocItemsSettingsSelector = createSelector([ layerSettingSelector, @@ -27,15 +30,21 @@ const tocItemsSettingsSelector = createSelector([ groupsSelector, currentLocaleSelector, state => mapLayoutValuesSelector(state, {height: true}), - isAdminUserSelector -], (settings, layers, groups, currentLocale, dockStyle, isAdmin) => ({ + isAdminUserSelector, + initialSettingsSelector, + originalSettingsSelector, + activeTabSettingsSelector +], (settings, layers, groups, currentLocale, dockStyle, isAdmin, initialSettings, originalSettings, activeTab) => ({ settings, element: settings.nodeType === 'layers' && isArray(layers) && head(layers.filter(layer => layer.id === settings.node)) || settings.nodeType === 'groups' && isArray(groups) && head(groups.filter(group => group.id === settings.node)) || {}, groups, currentLocale, dockStyle, - isAdmin + isAdmin, + initialSettings, + originalSettings, + activeTab })); /** @@ -65,9 +74,13 @@ const TOCItemsSettingsPlugin = compose( onHideSettings: hideSettings, onUpdateSettings: updateSettings, onUpdateNode: updateNode, - onRetrieveLayerData: getLayerCapabilities + onRetrieveLayerData: getLayerCapabilities, + onUpdateOriginalSettings: setControlProperty.bind(null, 'layersettings', 'originalSettings'), + onUpdateInitialSettings: setControlProperty.bind(null, 'layersettings', 'initialSettings'), + onSetTab: setControlProperty.bind(null, 'layersettings', 'activeTab'), + onUpdateParams: updateSettingsParams, + onToggleStyleEditor: toggleStyleEditor }), - withState('activeTab', 'onSetTab', 'general'), updateSettingsLifecycle, defaultProps({ getDimension: LayersUtils.getDimension, diff --git a/web/client/plugins/styleeditor/index.js b/web/client/plugins/styleeditor/index.js new file mode 100644 index 0000000000..9252518dc5 --- /dev/null +++ b/web/client/plugins/styleeditor/index.js @@ -0,0 +1,247 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); +const { connect } = require('react-redux'); +const { createSelector } = require('reselect'); +const { compose, withState, defaultProps, branch, lifecycle } = require('recompose'); + +const inlineWidgets = require('./inlineWidgets'); + +const { + selectStyleTemplate, + updateStatus, + addStyle, + createStyle, + updateStyleCode, + editStyleCode, + deleteStyle +} = require('../../actions/styleeditor'); + +const { updateOptionsByOwner } = require('../../actions/additionallayers'); +const { updateSettingsParams } = require('../../actions/layers'); +const { getLayerCapabilities } = require('../../actions/layerCapabilities'); + +const BorderLayout = require('../../components/layout/BorderLayout'); +const Editor = require('../../components/styleeditor/Editor'); +const withMask = require('../../components/misc/enhancers/withMask'); +const loadingState = require('../../components/misc/enhancers/loadingState'); +const emptyState = require('../../components/misc/enhancers/emptyState'); +const Loader = require('../../components/misc/Loader'); +const Message = require('../../components/I18N/Message'); + +const { + templateIdSelector, + statusStyleSelector, + codeStyleSelector, + geometryTypeSelector, + formatStyleSelector, + loadingStyleSelector, + errorStyleSelector, + layerPropertiesSelector, + initialCodeStyleSelector, + addStyleSelector, + selectedStyleSelector, + canEditStyleSelector, + getAllStyles, + styleServiceSelector, + getUpdatedLayer +} = require('../../selectors/styleeditor'); + +const { isAdminUserSelector } = require('../../selectors/security'); +const { getEditorMode, STYLE_OWNER_NAME, getStyleTemplates } = require('../../utils/StyleEditorUtils'); + +const stylesTemplates = getStyleTemplates(); + +const permissionDeniedEnhancers = emptyState(({canEdit}) => !canEdit, {glyph: 'exclamation-mark', title: }); + +const loadingEnhancers = (funcBool) => loadingState( + funcBool, + { + size: 150, + style: { + margin: 'auto' + } + }, + props =>
+); + +const StyleCodeEditor = compose( + defaultProps({ + inlineWidgets + }), + connect( + createSelector( + [ + codeStyleSelector, + formatStyleSelector, + layerPropertiesSelector, + errorStyleSelector, + loadingStyleSelector, + canEditStyleSelector + ], + (code, format, hintProperties, error, loading, canEdit) => ({ + code, + mode: getEditorMode(format), + hintProperties, + error: error.edit || null, + loading, + canEdit + }) + ), + { + onChange: code => editStyleCode(code) + } + ), + loadingEnhancers(({code, error}) => !code && !error), + permissionDeniedEnhancers, + emptyState(({error}) => error && error.status === 404, {glyph: 'exclamation-mark', title: }) +)(props => ( + + + +)); + +const StyleTemplates = compose( + defaultProps({ + templates: stylesTemplates + }), + connect( + createSelector( + [ + templateIdSelector, + addStyleSelector, + geometryTypeSelector, + canEditStyleSelector, + styleServiceSelector + ], + (selectedStyle, add, geometryType, canEdit, { formats = [] } = {}) => ({ + selectedStyle, + add: add && selectedStyle, + geometryType, + canEdit, + availableFormats: formats + }) + ), + { + onSelect: selectStyleTemplate, + onClose: addStyle.bind(null, false), + onSave: createStyle + } + ), + permissionDeniedEnhancers, + loadingEnhancers(({geometryType}) => !geometryType), + withState('filterText', 'onFilter', ''), + withState('styleSettings', 'onUpdate', {}) +)(require('../../components/styleeditor/StyleTemplates')); + +const StyleList = compose( + connect( + createSelector( + [ + statusStyleSelector, + getAllStyles + ], + (status, { defaultStyle, enabledStyle, availableStyles }) => ({ + status, + defaultStyle, + enabledStyle, + availableStyles + }) + ), + { + onSelect: updateSettingsParams + } + ), + withState('filterText', 'onFilter', ''), + withMask( + ({ status, readOnly }) => status === 'template' && !readOnly, + () => , + { + maskContainerStyle: { + display: 'flex', + position: 'relative' + }, + maskStyle: { + overflowY: 'auto' + } + } + ) +)(require('../../components/styleeditor/StyleList')); + +const StyleToolbar = compose( + withState('showModal', 'onShowModal'), + connect( + createSelector( + [ + statusStyleSelector, + templateIdSelector, + errorStyleSelector, + initialCodeStyleSelector, + codeStyleSelector, + loadingStyleSelector, + selectedStyleSelector, + isAdminUserSelector, + canEditStyleSelector + ], + (status, templateId, error, initialCode, code, loading, selectedStyle, isAdmin, canEdit) => ({ + status, + templateId, + error, + isCodeChanged: initialCode !== code, + loading, + selectedStyle, + editEnabled: isAdmin && canEdit + }) + ), + { + onSelectStyle: updateStatus.bind(null, 'template'), + onEditStyle: updateStatus.bind(null, 'edit'), + onBack: updateStatus.bind(null, ''), + onReset: updateOptionsByOwner.bind(null, STYLE_OWNER_NAME, [{}]), + onAdd: addStyle.bind(null, true), + onUpdate: updateStyleCode, + onDelete: deleteStyle + } + ) +)(require('../../components/styleeditor/StyleToolbar')); + +const ReadOnlyStyleList = compose( + connect(createSelector( + [ + getUpdatedLayer + ], + (layer) => ({ + layer + }) + ), { + onInit: getLayerCapabilities + }), + lifecycle({ + componentWillMount() { + if (this.props.onInit && this.props.layer) { + this.props.onInit(this.props.layer); + } + } + }) +)( + () => + }> + + +); + +module.exports = { + StyleSelector: branch( + ({ readOnly }) => readOnly, + () => ReadOnlyStyleList + )(StyleList), + StyleTemplates, + StyleToolbar, + StyleCodeEditor +}; diff --git a/web/client/plugins/styleeditor/inlineWidgets.js b/web/client/plugins/styleeditor/inlineWidgets.js new file mode 100644 index 0000000000..3730a97187 --- /dev/null +++ b/web/client/plugins/styleeditor/inlineWidgets.js @@ -0,0 +1,32 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); +const { SketchPicker } = require('react-color'); +const tinycolor = require('tinycolor2'); + +/** + * Inline widget structure for Style Editor (Editor) + * @prop {string} type identifier + * @prop {function} active if return the inline widget it applied, arg. token from codemirror + * @prop {function|object} style style of widget, base a square positioned before token string, function arg. token + * @prop {function} Widget the component selector (triggered by clicking on widget button), props {token = {}, value = '', onChange = () => {}} + */ + +module.exports = [ + { + type: 'color', + active: token => token.type === 'atom' && tinycolor(token.string).isValid(), + style: token => ({backgroundColor: token.string}), + Widget: ({token, value, onChange = () => {}}) => ( + onChange(color.hex)}/> + ) + } +]; diff --git a/web/client/plugins/tocitemssettings/defaultSettingsTabs.js b/web/client/plugins/tocitemssettings/defaultSettingsTabs.js index 81f0b38bec..18a42b2e7c 100644 --- a/web/client/plugins/tocitemssettings/defaultSettingsTabs.js +++ b/web/client/plugins/tocitemssettings/defaultSettingsTabs.js @@ -23,8 +23,6 @@ const PluginsUtils = require('../../utils/PluginsUtils'); const General = require('../../components/TOC/fragments/settings/General'); const Display = require('../../components/TOC/fragments/settings/Display'); -const WMSStyle = require('../../components/TOC/fragments/settings/WMSStyle'); - const Elevation = require('../../components/TOC/fragments/settings/Elevation'); const FeatureInfoEditor = require('../../components/TOC/fragments/settings/FeatureInfoEditor'); const LoadingView = require('../../components/misc/LoadingView'); @@ -35,6 +33,9 @@ const responses = { text: require('raw-loader!./featureInfoPreviews/responseText.txt') }; +const { StyleSelector } = require('../styleeditor/index'); +const StyleList = defaultProps({ readOnly: true })(StyleSelector); + const formatCards = { TEXT: { titleId: 'layerProperties.textFormatTitle', @@ -148,8 +149,12 @@ module.exports = ({showFeatureInfoTab = true, ...props}, {plugins, pluginsConfig titleId: 'layerProperties.style', tooltipId: 'layerProperties.style', glyph: 'dropper', + onClose: () => settingsPlugins && settingsPlugins.StyleEditor && props.onToggleStyleEditor && props.onToggleStyleEditor(null, false), + onClick: () => settingsPlugins && settingsPlugins.StyleEditor && props.onToggleStyleEditor && props.onToggleStyleEditor(null, true), visible: props.settings.nodeType === 'layers' && props.element.type === "wms", - Component: props.activeTab === 'style' && props.element.thematic && settingsPlugins.ThematicLayer && getConfiguredPlugin(settingsPlugins.ThematicLayer, loadedPlugins, ) || WMSStyle, + Component: props.activeTab === 'style' && props.element.thematic && settingsPlugins.ThematicLayer && getConfiguredPlugin(settingsPlugins.ThematicLayer, loadedPlugins, ) + || settingsPlugins.StyleEditor && getConfiguredPlugin({...settingsPlugins.StyleEditor, cfg: {...settingsPlugins.StyleEditor.cfg, active: true }}, loadedPlugins, ) + || StyleList, toolbar: [ { glyph: 'list', @@ -169,7 +174,8 @@ module.exports = ({showFeatureInfoTab = true, ...props}, {plugins, pluginsConfig thematic: null }) } - ] + ], + toolbarComponent: settingsPlugins.StyleEditor && settingsPlugins.StyleEditor.ToolbarComponent }, { id: 'feature', diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js index ad9cb01fde..65cd6b5284 100644 --- a/web/client/product/plugins.js +++ b/web/client/product/plugins.js @@ -95,7 +95,8 @@ module.exports = { FloatingLegendPlugin: require('../plugins/FloatingLegend'), TimelinePlugin: require('../plugins/Timeline'), ThematicLayerPlugin: require('../plugins/ThematicLayer'), - FeedbackMaskPlugin: require('../plugins/FeedbackMask') + FeedbackMaskPlugin: require('../plugins/FeedbackMask'), + StyleEditorPlugin: require('../plugins/StyleEditor') }, requires: { ReactSwipe: require('react-swipeable-views').default, diff --git a/web/client/reducers/__tests__/additionallayers-test.js b/web/client/reducers/__tests__/additionallayers-test.js new file mode 100644 index 0000000000..6d196e05d4 --- /dev/null +++ b/web/client/reducers/__tests__/additionallayers-test.js @@ -0,0 +1,140 @@ + +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const expect = require('expect'); + +const { + updateAdditionalLayer, + updateOptionsByOwner, + removeAdditionalLayer +} = require('../../actions/additionallayers'); + +const additionallayers = require('../additionallayers'); + +describe('Test additional layers reducer', () => { + + it('add an additional layer', () => { + const id = 'layer_001'; + const owner = 'owner'; + const actionType = 'override'; + const options = { + style: 'generic' + }; + const state = additionallayers([], updateAdditionalLayer(id, owner, actionType, options)); + + expect(state).toEqual([ + { + id, + owner, + actionType, + options + } + ]); + }); + + it('update options of additional layers by owner', () => { + + const owner = 'owner'; + + const initialState = [ + { + id: 'layer_001', + owner, + actionType: 'override', + options: { + style: 'generic' + } + }, + { + id: 'layer_002', + owner, + actionType: 'override', + options: {} + } + ]; + + const options = [ + {}, + { + style: 'point' + } + ]; + + const state = additionallayers(initialState, updateOptionsByOwner(owner, options)); + + expect(state).toEqual([ + { + id: 'layer_001', + owner, + actionType: 'override', + options: {...options[0]} + }, + { + id: 'layer_002', + owner, + actionType: 'override', + options: {...options[1]} + } + ]); + }); + + it('remove additional layers by owner', () => { + + const owner = 'owner'; + + const initialState = [ + { + id: 'layer_001', + owner, + actionType: 'override', + options: { + style: 'generic' + } + }, + { + id: 'layer_002', + owner, + actionType: 'override', + options: {} + } + ]; + + const state = additionallayers(initialState, removeAdditionalLayer({owner})); + + expect(state).toEqual([]); + }); + + it('remove additional layers by id', () => { + + const owner = 'owner'; + + const initialState = [ + { + id: 'layer_001', + owner, + actionType: 'override', + options: { + style: 'generic' + } + }, + { + id: 'layer_002', + owner, + actionType: 'override', + options: {} + } + ]; + + const state = additionallayers(initialState, removeAdditionalLayer({id: 'layer_001'})); + + expect(state).toEqual([{...initialState[1]}]); + }); + +}); + diff --git a/web/client/reducers/__tests__/styleeditor-test.js b/web/client/reducers/__tests__/styleeditor-test.js new file mode 100644 index 0000000000..e98dfd3987 --- /dev/null +++ b/web/client/reducers/__tests__/styleeditor-test.js @@ -0,0 +1,133 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const expect = require('expect'); + +const styleeditor = require('../styleeditor'); + +const { + initStyleService, + setEditPermissionStyleEditor, + updateTemporaryStyle, + updateStatus, + resetStyleEditor, + addStyle, + loadingStyle, + loadedStyle, + errorStyle +} = require('../../actions/styleeditor'); + +describe('Test styleeditor reducer', () => { + it('test initStyleService', () => { + const service = { + baseUrl: '/geoserver' + }; + const canEdit = true; + const state = styleeditor({}, initStyleService(service, canEdit)); + expect(state).toEqual({ + service, + canEdit: true + }); + }); + it('test setEditPermissionStyleEditor', () => { + const canEdit = true; + const state = styleeditor({}, setEditPermissionStyleEditor(canEdit)); + expect(state).toEqual({ + canEdit: true + }); + }); + it('test updateTemporaryStyle', () => { + const temporaryId = 'temporaryId'; + const templateId = 'templateId'; + const code = '* { stroke: #ff0000; }'; + const format = 'css'; + const init = true; + const state = styleeditor({}, updateTemporaryStyle({ temporaryId, templateId, code, format, init })); + expect(state).toEqual({ + temporaryId, + templateId, + code, + format, + error: null, + initialCode: code + }); + }); + it('test updateStatus', () => { + let state = styleeditor({ }, updateStatus('edit')); + expect(state).toEqual({ + status: 'edit' + }); + + state = styleeditor({}, updateStatus('')); + expect(state).toEqual({ + status: '', + code: '', + templateId: '', + initialCode: '', + addStyle: false, + error: {} + }); + }); + it('test resetStyleEditor', () => { + const state = styleeditor({canEdit: true}, resetStyleEditor()); + expect(state).toEqual({ + service: {}, + canEdit: true + }); + }); + it('test addStyle', () => { + const state = styleeditor({}, addStyle(true)); + expect(state).toEqual({ + addStyle: true + }); + }); + it('test loadingStyle', () => { + const state = styleeditor({}, loadingStyle(true)); + expect(state).toEqual({ + loading: true, + error: {} + }); + }); + it('test loadedStyle', () => { + const state = styleeditor({}, loadedStyle(true)); + expect(state).toEqual({ + loading: false, + enabled: true + }); + }); + + it('test errorStyle', () => { + const state = styleeditor({ }, errorStyle('edit', { status: 400, statusText: 'Error on line 10, column 2' })); + expect(state).toEqual({ + loading: false, + canEdit: true, + error: { + edit: { + status: 400, + message: 'Error on line 10, column 2', + line: 10, + column: 2 + } + } + }); + }); + + it('test errorStyle 401', () => { + const state = styleeditor({ }, errorStyle('', { status: 401, statusText: 'Error no auth' })); + expect(state).toEqual({ + loading: false, + canEdit: false, + error: { + global: { + status: 401, + message: 'Error no auth' + } + } + }); + }); +}); diff --git a/web/client/reducers/additionallayers.js b/web/client/reducers/additionallayers.js new file mode 100644 index 0000000000..6ba8e9fe9e --- /dev/null +++ b/web/client/reducers/additionallayers.js @@ -0,0 +1,52 @@ + +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const { UPDATE_ADDITIONAL_LAYER, REMOVE_ADDITIONAL_LAYER, UPDATE_OPTIONS_BY_OWNER } = require('../actions/additionallayers'); +const { head, pickBy, identity, isObject, isArray } = require('lodash'); + +function additionallayers(state = [], action) { + switch (action.type) { + case UPDATE_ADDITIONAL_LAYER: { + + const newLayerItem = pickBy({ + id: action.id, + owner: action.owner, + actionType: action.actionType, + options: action.options + }, identity); + + const currentLayerItem = head(state.filter(({id}) => id === newLayerItem.id)); + + if (currentLayerItem) { + return state.map(layerItem => layerItem.id === newLayerItem.id ? {...currentLayerItem, ...newLayerItem} : {...layerItem}); + } + + return [ + ...state, + newLayerItem + ]; + } + case UPDATE_OPTIONS_BY_OWNER: { + const {options, owner} = action; + return state.map((layerItem, idx) => layerItem.owner === owner ? { + ...layerItem, + options: isObject(options) && options[layerItem.id] || isArray(options) && options[idx] || {} + } : {...layerItem}); + } + case REMOVE_ADDITIONAL_LAYER: { + const {id, owner} = action; + return owner ? state.filter(layerItem => layerItem.owner !== owner) : state.filter(layerItem => layerItem.id !== id); + } + default: + return state; + } +} + +module.exports = additionallayers; + diff --git a/web/client/reducers/styleeditor.js b/web/client/reducers/styleeditor.js new file mode 100644 index 0000000000..bcb5ce8dd6 --- /dev/null +++ b/web/client/reducers/styleeditor.js @@ -0,0 +1,117 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const { + UPDATE_TEMPORARY_STYLE, + UPDATE_STATUS, + ERROR_STYLE, + ADD_STYLE, + RESET_STYLE_EDITOR, + LOADING_STYLE, + LOADED_STYLE, + INIT_STYLE_SERVICE, + SET_EDIT_PERMISSION +} = require('../actions/styleeditor'); + +function styleeditor(state = {}, action) { + switch (action.type) { + case INIT_STYLE_SERVICE: { + return { + ...state, + service: action.service, + canEdit: action.canEdit + }; + } + case SET_EDIT_PERMISSION: { + return { + ...state, + canEdit: action.canEdit + }; + } + case UPDATE_TEMPORARY_STYLE: { + return { + ...state, + temporaryId: action.temporaryId, + templateId: action.templateId, + code: action.code, + format: action.format, + error: null, + initialCode: action.init ? action.code : state.initialCode + }; + } + case UPDATE_STATUS: { + if (action.status === '') { + return { + ...state, + status: action.status, + code: '', + templateId: '', + initialCode: '', + addStyle: false, + error: {} + }; + } + return { + ...state, + status: action.status + }; + } + case RESET_STYLE_EDITOR: { + return { + service: state.service && {...state.service} || {}, + canEdit: state.canEdit + }; + } + case ADD_STYLE: { + return {...state, addStyle: action.add}; + } + case LOADING_STYLE: { + return { + ...state, + loading: action.status ? action.status : true, + error: {} + }; + } + case LOADED_STYLE: { + return { + ...state, + loading: false, + enabled: true + }; + } + case ERROR_STYLE: { + const message = action.error && action.error.statusText || ''; + const position = message.match(/line\s([\d]+)|column\s([\d]+)/g); + const errorInfo = position && position.length === 2 && position.reduce((info, pos) => { + const splittedValues = pos.split(' '); + const param = splittedValues[0]; + const value = parseFloat(splittedValues[1]); + return param && !isNaN(value) && { + ...info, + [param]: value + } || { ...info }; + }, { message }) || { message }; + return { + ...state, + loading: false, + canEdit: !(action.error && (action.error.status === 401 || action.error.status === 403)), + error: { + ...state.error, + [action.status || 'global']: { + status: action.error && action.error.status || 404, + ...errorInfo + } + } + }; + } + default: + return state; + } +} + +module.exports = styleeditor; diff --git a/web/client/selectors/__tests__/additionallayers-test.js b/web/client/selectors/__tests__/additionallayers-test.js new file mode 100644 index 0000000000..788e5ac35b --- /dev/null +++ b/web/client/selectors/__tests__/additionallayers-test.js @@ -0,0 +1,52 @@ +/* +* Copyright 2018, GeoSolutions Sas. +* All rights reserved. +* +* This source code is licensed under the BSD-style license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +const expect = require('expect'); +const { + additionalLayersSelector +} = require('../additionallayers'); + +const state = { + additionallayers: [ + { + id: 'layer_001', + owner: 'styleeditor', + actionType: 'override', + settings: { + name: 'workspace:layer_001', + properties: { + pop: 500000 + } + }, + options: { + style: 'generic' + } + }, + { + id: 'layer_002', + owner: 'owner', + actionType: 'override', + settings: { + name: 'workspace:layer_002', + properties: { + pop: 500000 + } + }, + options: {} + } + ] +}; + +describe('Test additionallayers selectors', () => { + it('test additionalLayersSelector', () => { + const props = additionalLayersSelector(state); + expect(props).toEqual([...state.additionallayers]); + + }); +}); diff --git a/web/client/selectors/__tests__/controls-test.js b/web/client/selectors/__tests__/controls-test.js index 02c77683a1..4e7b11f7aa 100644 --- a/web/client/selectors/__tests__/controls-test.js +++ b/web/client/selectors/__tests__/controls-test.js @@ -12,7 +12,10 @@ const { wfsDownloadAvailable, wfsDownloadSelector, widgetBuilderAvailable, - widgetBuilderSelector + widgetBuilderSelector, + initialSettingsSelector, + originalSettingsSelector, + activeTabSettingsSelector } = require("../controls"); const state = { @@ -30,6 +33,17 @@ const state = { }, featuregrid: { enabled: true + }, + layersettings: { + initialSettings: { + id: 'layerId', + name: 'layerName', + style: '' + }, + originalSettings: { + style: 'generic' + }, + activeTab: 'style' } } }; @@ -64,4 +78,27 @@ describe('Test controls selectors', () => { expect(retVal).toExist(); expect(retVal).toBe(true); }); + it('test initialSettingsSelector', () => { + const retVal = initialSettingsSelector(state); + expect(retVal).toExist(); + expect(retVal).toEqual({ + id: 'layerId', + name: 'layerName', + style: '' + }); + }); + it('test originalSettingsSelector', () => { + const retVal = originalSettingsSelector(state); + expect(retVal).toExist(); + expect(retVal).toEqual({ + style: 'generic' + }); + }); + it('test activeTabSettingsSelector', () => { + const retVal = activeTabSettingsSelector(state); + expect(retVal).toExist(); + expect(retVal).toBe('style'); + + expect(activeTabSettingsSelector({})).toBe('general'); + }); }); diff --git a/web/client/selectors/__tests__/layers-test.js b/web/client/selectors/__tests__/layers-test.js index 3bddccbc3b..92e95146ac 100644 --- a/web/client/selectors/__tests__/layers-test.js +++ b/web/client/selectors/__tests__/layers-test.js @@ -150,6 +150,44 @@ describe('Test layers selectors', () => { expect(props[1].style).toEqual({...defaultIconStyle, ...style}); }); + + it('test layerSelectorWithMarkers with override layers from additionallayers', () => { + const state = { + additionallayers: [ + { + id: 'layer_001', + owner: 'styleeditor', + actionType: 'override', + settings: { + name: 'workspace:layer_001', + properties: { + pop: 500000 + } + }, + options: { + style: 'generic' + } + } + ], + layers: { + flat: [ + { + type: 'wms', + id: 'layer_001', + style: '' + } + ] + } + }; + const props = layerSelectorWithMarkers(state); + expect(props.length).toBe(1); + expect(props[0]).toEqual({ + type: 'wms', + id: 'layer_001', + style: 'generic' + }); + }); + it('test groupsSelector from layers flat one group', () => { const props = groupsSelector({layers: { flat: [{type: "osm", id: "layer1", group: "group1"}, {type: "wms", id: "layer2", group: "group1"}], diff --git a/web/client/selectors/__tests__/styleeditor-test.js b/web/client/selectors/__tests__/styleeditor-test.js new file mode 100644 index 0000000000..bfa21db7ce --- /dev/null +++ b/web/client/selectors/__tests__/styleeditor-test.js @@ -0,0 +1,435 @@ +/* +* Copyright 2018, GeoSolutions Sas. +* All rights reserved. +* +* This source code is licensed under the BSD-style license found in the +* LICENSE file in the root directory of this source tree. +*/ + +const expect = require('expect'); +const { + temporaryIdSelector, + templateIdSelector, + statusStyleSelector, + errorStyleSelector, + loadingStyleSelector, + formatStyleSelector, + codeStyleSelector, + initialCodeStyleSelector, + selectedStyleSelector, + addStyleSelector, + geometryTypeSelector, + layerPropertiesSelector, + enabledStyleEditorSelector, + styleServiceSelector, + canEditStyleSelector, + getUpdatedLayer, + getAllStyles +} = require('../styleeditor'); + +describe('Test styleeditor selector', () => { + it('test temporaryIdSelector', () => { + const state = { + styleeditor: { + temporaryId: '4d91420-d79b-11e8-94c3-cf252a711708' + } + }; + const retval = temporaryIdSelector(state); + + expect(retval).toExist(); + expect(retval).toBe('4d91420-d79b-11e8-94c3-cf252a711708'); + }); + it('test templateIdSelector', () => { + const state = { + styleeditor: { + templateId: '6f13030-d79e-11e8-95aa-85d7608156db' + } + }; + const retval = templateIdSelector(state); + + expect(retval).toExist(); + expect(retval).toBe('6f13030-d79e-11e8-95aa-85d7608156db'); + }); + it('test templateIdSelector', () => { + const state = { + styleeditor: { + status: 'edit' + } + }; + const retval = statusStyleSelector(state); + + expect(retval).toExist(); + expect(retval).toBe('edit'); + }); + it('test errorStyleSelector', () => { + const state = { + styleeditor: { + error: { + global: { + status: 404 + } + } + } + }; + const retval = errorStyleSelector(state); + + expect(retval).toExist(); + expect(retval).toEqual( + { + global: { + status: 404 + } + } + ); + }); + it('test loadingStyleSelector', () => { + const state = { + styleeditor: { + loading: true + } + }; + const retval = loadingStyleSelector(state); + + expect(retval).toExist(); + expect(retval).toBe(true); + }); + it('test formatStyleSelector', () => { + const state = { + styleeditor: { + format: 'css' + } + }; + const retval = formatStyleSelector(state); + + expect(retval).toExist(); + expect(retval).toBe('css'); + }); + it('test codeStyleSelector', () => { + const state = { + styleeditor: { + code: '* { stroke: #333333 }' + } + }; + const retval = codeStyleSelector(state); + + expect(retval).toExist(); + expect(retval).toBe('* { stroke: #333333 }'); + }); + it('test initialCodeStyleSelector', () => { + const state = { + styleeditor: { + initialCode: '* { stroke: #333333; }' + } + }; + const retval = initialCodeStyleSelector(state); + + expect(retval).toExist(); + expect(retval).toBe('* { stroke: #333333; }'); + }); + it('test selectedStyleSelector', () => { + const state = { + layers: { + flat: [ + { + id: 'layerId', + name: 'layerName', + style: 'point' + } + ], + selected: [ + 'layerId' + ], + settings: { + expanded: true, + node: 'layerId', + nodeType: 'layers', + options: { + opacity: 1, + style: 'generic' + } + } + } + }; + const retval = selectedStyleSelector(state); + + expect(retval).toExist(); + expect(retval).toBe('generic'); + }); + it('test addStyleSelector', () => { + const state = { + styleeditor: { + addStyle: true + } + }; + const retval = addStyleSelector(state); + + expect(retval).toExist(); + expect(retval).toBe(true); + }); + it('test geometryTypeSelector', () => { + const state = { + layers: { + flat: [ + { + id: 'layerId', + name: 'layerName', + style: 'point', + describeLayer: { + owsType: 'WFS' + }, + describeFeatureType: { + complexType: [{ + complexContent: { + extension: { + sequence: { + element: [{ + TYPE_NAME: "XSD_1_0.LocalElement", + maxOccurs: "1", + minOccurs: 0, + name: "geom", + nillable: true, + otherAttributes: {}, + type: { + key: "{http://www.opengis.net/gml}PointPropertyType", + localPart: "PointPropertyType", + namespaceURI: "http://www.opengis.net/gml", + prefix: "gml", + string: "{http://www.opengis.net/gml}gml:PointPropertyType" + } + }] + } + } + } + }] + } + } + ], + selected: [ + 'layerId' + ], + settings: { + expanded: true, + node: 'layerId', + nodeType: 'layers', + options: { + opacity: 1, + style: 'generic' + } + } + } + }; + const retval = geometryTypeSelector(state); + + expect(retval).toExist(); + expect(retval).toBe('point'); + }); + + it('test layerPropertiesSelector', () => { + const state = { + layers: { + flat: [ + { + id: 'layerId', + name: 'layerName', + style: 'point', + describeLayer: { + owsType: 'WFS' + }, + describeFeatureType: { + complexType: [{ + complexContent: { + extension: { + sequence: { + element: [{ + TYPE_NAME: "XSD_1_0.LocalElement", + maxOccurs: "1", + minOccurs: 0, + name: "RANK", + nillable: true, + otherAttributes: {}, + type: { + key: "{http://www.w3.org/2001/XMLSchema}short", + localPart: "short", + namespaceURI: "http://www.w3.org/2001/XMLSchema", + prefix: "xsd", + string: "{http://www.w3.org/2001/XMLSchema}xsd:short" + } + }] + } + } + } + }] + } + } + ], + selected: [ + 'layerId' + ], + settings: { + expanded: true, + node: 'layerId', + nodeType: 'layers', + options: { + opacity: 1, + style: 'generic' + } + } + } + }; + const retval = layerPropertiesSelector(state); + + expect(retval).toExist(); + expect(retval).toEqual({ RANK: { localPart: 'short', prefix: 'xsd' } }); + }); + it('test enabledStyleEditorSelector', () => { + const state = { + styleeditor: { + enabled: true + } + }; + const retval = enabledStyleEditorSelector(state); + + expect(retval).toExist(); + expect(retval).toBe(true); + }); + it('test styleServiceSelector', () => { + const state = { + styleeditor: { + service: { + baseUrl: '/geoserver' + } + } + }; + const retval = styleServiceSelector(state); + + expect(retval).toExist(); + expect(retval).toEqual( + { + baseUrl: '/geoserver' + } + ); + }); + it('test canEditStyleSelector', () => { + const state = { + styleeditor: { + canEdit: true + } + }; + const retval = canEditStyleSelector(state); + + expect(retval).toExist(); + expect(retval).toBe(true); + }); + it('test getUpdatedLayer', () => { + const state = { + layers: { + flat: [ + { + id: 'layerId', + name: 'layerName', + style: 'point' + } + ], + selected: [ + 'layerId' + ], + settings: { + expanded: true, + node: 'layerId', + nodeType: 'layers', + options: { + opacity: 1, + style: 'generic' + } + } + } + }; + const retval = getUpdatedLayer(state); + + expect(retval).toExist(); + expect(retval).toEqual({ + id: 'layerId', + name: 'layerName', + opacity: 1, + style: 'generic' + }); + }); + it('test getAllStyles', () => { + const state = { + layers: { + flat: [ + { + id: 'layerId', + name: 'layerName', + style: 'point' + } + ], + selected: [ + 'layerId' + ], + settings: { + expanded: true, + node: 'layerId', + nodeType: 'layers', + options: { + opacity: 1, + style: 'square', + availableStyles: [ + { + TYPE_NAME: "WMS_1_3_0.Style", + filename: "default_point.sld", + format: "sld", + languageVersion: {version: "1.0.0"}, + legendURL: [], + name: 'point', + title: 'Title', + _abstract: '' + }, + { + TYPE_NAME: "WMS_1_3_0.Style", + filename: "square.css", + format: "css", + languageVersion: {version: "1.0.0"}, + legendURL: [], + name: 'square', + title: 'Title', + _abstract: '' + } + ] + } + } + } + }; + const retval = getAllStyles(state); + + expect(retval).toExist(); + expect(retval).toEqual({ + availableStyles: [ + { + TYPE_NAME: 'WMS_1_3_0.Style', + filename: 'default_point.sld', + format: 'sld', + languageVersion: { version: '1.0.0' }, + legendURL: [], + name: 'point', + title: 'Title', + _abstract: '', + label: 'Title' + }, + { + TYPE_NAME: 'WMS_1_3_0.Style', + filename: 'square.css', + format: 'css', + languageVersion: { version: '1.0.0' }, + legendURL: [], + name: 'square', + title: 'Title', + _abstract: '', + label: 'Title' + } + ], + defaultStyle: 'point', + enabledStyle: 'square' + }); + }); +}); diff --git a/web/client/selectors/additionallayers.js b/web/client/selectors/additionallayers.js new file mode 100644 index 0000000000..0172051600 --- /dev/null +++ b/web/client/selectors/additionallayers.js @@ -0,0 +1,28 @@ +/* +* Copyright 2018, GeoSolutions Sas. +* All rights reserved. +* +* This source code is licensed under the BSD-style license found in the +* LICENSE file in the root directory of this source tree. +*/ + +const {get} = require('lodash'); + +/** + * selects additionallayers state + * @name additionallayers + * @memberof selectors + * @static + */ + +/** + * Return all additional layers stored in the state + * @memberof selectors.additionallayers + * @param {object} state the state + * @return {array} array of layers items eg: [{ id: 'layerId', actionType: 'override', options: {}, owner: 'myplugin' }] + */ +const additionalLayersSelector = state => get(state, 'additionallayers') || []; + +module.exports = { + additionalLayersSelector +}; diff --git a/web/client/selectors/controls.js b/web/client/selectors/controls.js index 36e99c60ee..83a0f65cce 100644 --- a/web/client/selectors/controls.js +++ b/web/client/selectors/controls.js @@ -5,5 +5,8 @@ module.exports = { wfsDownloadAvailable: state => !!get(state, "controls.wfsdownload.available"), wfsDownloadSelector: state => !!get(state, "controls.wfsdownload.enabled"), widgetBuilderAvailable: state => get(state, "controls.widgetBuilder.available", false), - widgetBuilderSelector: (state) => get(state, "controls.widgetBuilder.enabled") + widgetBuilderSelector: (state) => get(state, "controls.widgetBuilder.enabled"), + initialSettingsSelector: state => get(state, "controls.layersettings.initialSettings") || {}, + originalSettingsSelector: state => get(state, "controls.layersettings.originalSettings") || {}, + activeTabSettingsSelector: state => get(state, "controls.layersettings.activeTab") || 'general' }; diff --git a/web/client/selectors/layers.js b/web/client/selectors/layers.js index e8ce3e02ce..f3b4fbb20b 100644 --- a/web/client/selectors/layers.js +++ b/web/client/selectors/layers.js @@ -24,6 +24,8 @@ const geoColderSelector = state => state.search && state.search; // to avoid this separate loading from the layer object const centerToMarkerSelector = (state) => get(state, "mapInfo.centerToMarker", ''); +const additionalLayersSelector = state => get(state, "additionallayers", []); + const defaultIconStyle = { iconUrl: "https://cdn.rawgit.com/pointhi/leaflet-color-markers/master/img/marker-icon-red.png", shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.3.1/images/marker-shadow.png', @@ -34,9 +36,16 @@ const defaultIconStyle = { }; const layerSelectorWithMarkers = createSelector( - [layersSelector, markerSelector, geoColderSelector, centerToMarkerSelector], - (layers = [], markerPosition, geocoder, centerToMarker) => { - let newLayers = [...layers]; + [layersSelector, markerSelector, geoColderSelector, centerToMarkerSelector, additionalLayersSelector], + (layers = [], markerPosition, geocoder, centerToMarker, additionalLayers) => { + + // Perform an override action on the layers using options retrieved from additional layers + const overrideLayers = additionalLayers.filter(({actionType}) => actionType === 'override'); + let newLayers = layers.map(layer => { + const { options } = head(overrideLayers.filter(overrideLayer => overrideLayer.id === layer.id)) || {}; + return options ? {...layer, ...options} : {...layer}; + }); + if ( markerPosition ) { const coords = centerToMarker === 'enabled' ? getNormalizedLatLon(markerPosition.latlng) : markerPosition.latlng; newLayers.push(MapInfoUtils.getMarkerLayer("GetFeatureInfo", coords)); diff --git a/web/client/selectors/styleeditor.js b/web/client/selectors/styleeditor.js new file mode 100644 index 0000000000..72c9e777b1 --- /dev/null +++ b/web/client/selectors/styleeditor.js @@ -0,0 +1,191 @@ +/* +* Copyright 2018, GeoSolutions Sas. +* All rights reserved. +* +* This source code is licensed under the BSD-style license found in the +* LICENSE file in the root directory of this source tree. +*/ + +const { get, head } = require('lodash'); +const { layerSettingSelector, getSelectedLayer } = require('./layers'); +const { STYLE_ID_SEPARATOR, extractFeatureProperties } = require('../utils/StyleEditorUtils'); + +/** + * selects styleeditor state + * @name styleeditor + * @memberof selectors + * @static + */ + +/** + * selects temporaryId from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {string} id/name of temporary style + */ +const temporaryIdSelector = state => get(state, 'styleeditor.temporaryId'); +/** + * selects templateId from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {string} id/name of template style + */ +const templateIdSelector = state => get(state, 'styleeditor.templateId'); +/** + * selects status of style editor from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {string} '', 'edit' or 'template' + */ +const statusStyleSelector = state => get(state, 'styleeditor.status'); +/** + * selects error of style editor from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {object} error object eg: { global: { status: 404, message: 'Error' } } + */ +const errorStyleSelector = state => get(state, 'styleeditor.error') || {}; +/** + * selects loading state of style editor from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {bool} + */ +const loadingStyleSelector = state => get(state, 'styleeditor.loading'); +/** + * selects current format of selected style from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {string} + */ +const formatStyleSelector = state => get(state, 'styleeditor.format') || 'css'; +/** + * selects code of style in editing from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {string} + */ +const codeStyleSelector = state => get(state, 'styleeditor.code'); +/** + * selects initial code of style in editing from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {string} + */ +const initialCodeStyleSelector = state => get(state, 'styleeditor.initialCode') || ''; +/** + * selects add boolean from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {bool} + */ +const addStyleSelector = state => get(state, 'styleeditor.addStyle') || ''; +/** + * selects enabled state of style editor from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {bool} + */ +const enabledStyleEditorSelector = state => get(state, 'styleeditor.enabled'); +/** + * selects style editor service from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {object} eg: styleService: {baseUrl: '/geoserver/', formats: ['css', 'sld'], availableUrls: ['http://localhost:8081/geoserver/']} + */ +const styleServiceSelector = state => get(state, 'styleeditor.service') || {}; +/** + * selects canEdit status of styleeditor service from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {bool} + */ +const canEditStyleSelector = state => get(state, 'styleeditor.canEdit'); +/** + * selects layer with current changes applied in settings session from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {object} layer object + */ +const getUpdatedLayer = state => { + const settings = layerSettingSelector(state); + const selectedLayer = getSelectedLayer(state) || {}; + return {...selectedLayer, ...(settings && settings.options || {})}; +}; +/** + * selects geometry type of selected layer from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {string} layer object + */ +const geometryTypeSelector = state => { + const updatedLayer = getUpdatedLayer(state); + const { geometryType = 'vector' } = extractFeatureProperties(updatedLayer); + return geometryType; +}; +/** + * selects feature properties of selected layer from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {object} + */ +const layerPropertiesSelector = state => { + const updatedLayer = getUpdatedLayer(state); + const { properties = {} } = extractFeatureProperties(updatedLayer); + return properties; +}; +/** + * selects selected style name from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {string} + */ +const selectedStyleSelector = state => { + const updatedLayer = getUpdatedLayer(state); + return updatedLayer.style + || updatedLayer.availableStyles && updatedLayer.availableStyles[0] && updatedLayer.availableStyles[0].name; +}; +/** + * selects all style values of selected layer (availableStyles, defaultStyle, enabledStyle) from state + * @memberof selectors.styleeditor + * @param {object} state the state + * @return {object} + */ +const getAllStyles = (state) => { + const updatedLayer = getUpdatedLayer(state); + const availableStyles = updatedLayer.availableStyles || []; + const { name: defaultStyle } = head(availableStyles) || {}; + const enabledStyle = updatedLayer.style || updatedLayer && !updatedLayer.style && defaultStyle; + return { + availableStyles: availableStyles.map(style => { + const splittedName = style.title && style.title.split(STYLE_ID_SEPARATOR); + const label = splittedName[0] || style.name; + return { + ...style, + label + }; + }), + defaultStyle, + enabledStyle + }; +}; + +module.exports = { + temporaryIdSelector, + templateIdSelector, + statusStyleSelector, + errorStyleSelector, + loadingStyleSelector, + formatStyleSelector, + codeStyleSelector, + initialCodeStyleSelector, + selectedStyleSelector, + addStyleSelector, + geometryTypeSelector, + layerPropertiesSelector, + enabledStyleEditorSelector, + styleServiceSelector, + canEditStyleSelector, + getUpdatedLayer, + getAllStyles +}; diff --git a/web/client/test-resources/geoserver/rest/layers/TEST_LAYER_2.json b/web/client/test-resources/geoserver/rest/layers/TEST_LAYER_2.json new file mode 100644 index 0000000000..ab8bebfa62 --- /dev/null +++ b/web/client/test-resources/geoserver/rest/layers/TEST_LAYER_2.json @@ -0,0 +1,30 @@ +{ + "layer": { + "name": "TEST_LAYER_1", + "type": "RASTER", + "defaultStyle": { + "name": "test_TEST_LAYER_1", + "href": "http:\/\/localhost:8080\/geoserver\/rest\/styles\/test_TEST_LAYER_1.json" + }, + "resource": { + "@class": "coverage", + "name": "TEST_LAYER_1", + "href": "http:\/\/localhost:8080\/geoserver\/rest\/workspaces\/test\/coveragestores\/TEST_LAYER_1\/coverages\/TEST_LAYER_1.json" + }, + "attribution": { + "logoWidth": 0, + "logoHeight": 0 + }, + "styles": { + "@class": "linked-hash-set", + "style": [ + { + "name": "point" + }, + { + "name": "generic" + } + ] + } + } +} \ No newline at end of file diff --git a/web/client/test-resources/geoserver/rest/layers/TEST_LAYER_3.json b/web/client/test-resources/geoserver/rest/layers/TEST_LAYER_3.json new file mode 100644 index 0000000000..bf1e573357 --- /dev/null +++ b/web/client/test-resources/geoserver/rest/layers/TEST_LAYER_3.json @@ -0,0 +1,33 @@ +{ + "layer": { + "name": "TEST_LAYER_3", + "type": "VECTOR", + "defaultStyle": { + "name": "test_TEST_LAYER_1", + "href": "http:\/\/localhost:8080\/geoserver\/rest\/styles\/test_TEST_LAYER_1.json" + }, + "resource": { + "@class": "coverage", + "name": "TEST_LAYER_1", + "href": "http:\/\/localhost:8080\/geoserver\/rest\/workspaces\/test\/coveragestores\/TEST_LAYER_1\/coverages\/TEST_LAYER_1.json" + }, + "attribution": { + "logoWidth": 0, + "logoHeight": 0 + }, + "styles": { + "@class": "linked-hash-set", + "style": [ + { + "name": "point" + }, + { + "name": "generic" + }, + { + "name": "test_style" + } + ] + } + } +} \ No newline at end of file diff --git a/web/client/test-resources/geoserver/rest/styles/test_TEST_LAYER_1.json b/web/client/test-resources/geoserver/rest/styles/test_TEST_LAYER_1.json new file mode 100644 index 0000000000..3ff3db1149 --- /dev/null +++ b/web/client/test-resources/geoserver/rest/styles/test_TEST_LAYER_1.json @@ -0,0 +1,10 @@ +{ + "style":{ + "name":"test_TEST_LAYER_1", + "format":"sld", + "languageVersion":{ + "version":"1.0.0" + }, + "filename":"test_TEST_LAYER_1.sld" + } +} diff --git a/web/client/test-resources/geoserver/rest/styles/test_style b/web/client/test-resources/geoserver/rest/styles/test_style new file mode 100644 index 0000000000..4a94fd4099 --- /dev/null +++ b/web/client/test-resources/geoserver/rest/styles/test_style @@ -0,0 +1,10 @@ +{ + "style": { + "name": "test_style", + "format": "css", + "languageVersion": { + "version": "1.0.0" + }, + "filename": "test_style.css" + } +} \ No newline at end of file diff --git a/web/client/test-resources/geoserver/rest/styles/test_style.css b/web/client/test-resources/geoserver/rest/styles/test_style.css new file mode 100644 index 0000000000..a46205a455 --- /dev/null +++ b/web/client/test-resources/geoserver/rest/styles/test_style.css @@ -0,0 +1 @@ +* { stroke: #ff0000; } \ No newline at end of file diff --git a/web/client/test-resources/geoserver/rest/styles/test_style.json b/web/client/test-resources/geoserver/rest/styles/test_style.json new file mode 100644 index 0000000000..4a94fd4099 --- /dev/null +++ b/web/client/test-resources/geoserver/rest/styles/test_style.json @@ -0,0 +1,10 @@ +{ + "style": { + "name": "test_style", + "format": "css", + "languageVersion": { + "version": "1.0.0" + }, + "filename": "test_style.css" + } +} \ No newline at end of file diff --git a/web/client/themes/default/less/common.less b/web/client/themes/default/less/common.less index bea493681d..17532feb8f 100644 --- a/web/client/themes/default/less/common.less +++ b/web/client/themes/default/less/common.less @@ -189,3 +189,27 @@ div#sync-popover.popover { } } + +.mapstore-filter .input-group { + height: 32px; + width: 100%; +} +.mapstore-filter .input-group .input-group-addon { + position: absolute; + z-index: 2; + right: 0; + border-color: transparent; + background-color: transparent; +} + +.ms-svg-glyph { + position: absolute; + width: 100%; + height: 100%; + display: flex; + font-size: 100px; + & > .glyphicon { + margin: auto; + display: block; + } +} diff --git a/web/client/themes/default/less/modal.less b/web/client/themes/default/less/modal.less index 2cbc45ca6e..591d19d65e 100644 --- a/web/client/themes/default/less/modal.less +++ b/web/client/themes/default/less/modal.less @@ -5,6 +5,7 @@ .ms-resizable-modal { position: absolute; + text-align: left; width: 100%; height: 100%; margin: 0; @@ -73,6 +74,21 @@ transform: translate(0, 0) !important; } + &.ms-fit-content { + max-height: 70%; + height: unset; + + &.ms-xs { + max-height: 30%; + height: unset; + } + + &.ms-sm { + max-height: 40%; + height: unset; + } + } + .modal-header { height: @square-btn-size; padding: (@square-btn-size - @font-size-h4) / 2; @@ -117,6 +133,9 @@ &.ms-no-scroll { overflow-y: hidden; } + & > form { + padding: 9px; + } } .ms-alert { diff --git a/web/client/themes/default/less/panels.less b/web/client/themes/default/less/panels.less index 32a3a9f490..52debeba24 100644 --- a/web/client/themes/default/less/panels.less +++ b/web/client/themes/default/less/panels.less @@ -27,6 +27,11 @@ .btn { .no-border; } + & > .row { + & > .col-xs-2 { + min-width: @square-btn-size; + } + } .ms-close { span { display: block; diff --git a/web/client/themes/default/less/rulesmanager.less b/web/client/themes/default/less/rulesmanager.less index d883f5a551..0129b06abd 100644 --- a/web/client/themes/default/less/rulesmanager.less +++ b/web/client/themes/default/less/rulesmanager.less @@ -40,21 +40,8 @@ .mapstore-side-card.ms-sm .mapstore-side-card-tool { width: auto !important; } - .mapstore-filter .input-group { - height: 32px; - width: 100%; - } - .mapstore-filter .input-group .input-group-addon { - position: absolute; - z-index: 2; - right: 0; - border-color: transparent; - background-color: transparent; - } - } - .rules-manager{ .loading-header { position: absolute; diff --git a/web/client/themes/default/less/sidegrid.less b/web/client/themes/default/less/sidegrid.less index ae465cf9fa..f16d62ab9b 100644 --- a/web/client/themes/default/less/sidegrid.less +++ b/web/client/themes/default/less/sidegrid.less @@ -176,3 +176,30 @@ .shadow-soft; } } + +.ms-square-card { + + width: 24%; + margin-top: 8px; + padding: 4px; + margin-right: 1%; + float: left; + background-color: @ms2-color-background; + color: @ms2-color-text; + text-align: center; + transition: all 0.3s; + .shadow-soft; + .ms-preview { + font-size: 0; + border: 1px solid @ms2-color-shade-lighter; + } + &:hover { + .shadow-far; + cursor: pointer; + } + &.ms-selected { + background-color: @ms2-color-primary; + color: @ms2-color-text-primary; + .shadow-far; + } +} diff --git a/web/client/themes/default/less/style-editor.less b/web/client/themes/default/less/style-editor.less new file mode 100644 index 0000000000..b9c6ddafdf --- /dev/null +++ b/web/client/themes/default/less/style-editor.less @@ -0,0 +1,218 @@ +.ms-style-editor-container { + .shadow-soft; + background-color: @ms2-color-background; + .ms2-border-layout-content { + .ms2-border-layout-content { + background-color: transparent; + } + } + .ms-style-editor-container-header { + padding: 8px; + } + .mapstore-filter { + margin: 8px; + } + .msSideGrid { + position: relative; + margin: 0 10px; + width: ~"calc(100% - 20px)"; + .items-list { + margin: 0; + } + .row { + margin: 0; + } + .col-xs-12 { + padding: 0; + } + .mapstore-side-card { + &:hover { + transform: unset; + } + } + } + .ms2-mask-container { + .ms2-mask { + background-color: @ms2-color-background; + color: @ms2-color-text; + } + } + .empty-state-container { + display: flex; + width: 100%; + height: 100%; + .empty-state-main-view { + width: 300px; + margin: auto; + } + } +} + +.ms-style-editor { + position: relative; + width: 100%; + height: 100%; + .ms2-border-layout-content { + position: relative; + } + + .alert { + margin-bottom: 0; + } + + .react-codemirror2 { + position: absolute; + width: 100%; + height: 100%; + & > .CodeMirror { + position: absolute; + width: 100%; + height: 100%; + } + } + + .ms-style-editor-inline-widget { + display: inline-block; + margin-top: -15px; + width: 10px; + height: 10px; + border: 1px solid @ms2-color-background; + z-index: 10; + &:hover { + cursor: pointer; + opacity: 0.8; + } + } + .ms-style-editor-error { + color: lighten(#dd2200, 10%); + text-decoration: underline wavy; + text-decoration-color: #dd2200; + } + + .ms-style-editor-head { + background: #262626; + height: 28px; + display: flex; + border-bottom: 1px solid #aaa; + .ms-style-editor-loader { + margin: auto; + margin-right: 5px; + } + .mapstore-info-popover { + margin: auto; + margin-right: 5px; + background-color: #eee; + border-radius: 50%; + width: 20px; + text-align: center; + .text-danger { + color: #dd2200 + } + } + } +} + +ul.CodeMirror-hints { + border: none; + border-radius: 0; + background-color: @ms2-color-text; + .shadow-soft; + li.CodeMirror-hint { + color: @ms2-color-background; + &.CodeMirror-hint-active { + background-color: @ms2-color-primary; + } + } +} + +.ms-style-template-title { + margin: 0 16px; + margin-top: 16px; + padding-top: 8px; + text-align: center; + font-style: italic; + border-top: 1px solid @ms2-color-shade-lighter; +} + +.ms-inline-widget-container { + position: absolute; + display: flex; + flex-direction: column; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 9999; + & > div:last-child { + flex: 1; + display: flex; + & > * { + margin: auto; + /* !important for color picker */ + border-radius: 0 !important; + } + } + + button.close { + right: 0; + } +} + +/* +http://lesscss.org/ dark theme +Ported to CodeMirror by Peter Kroon +*/ +.cm-s-lesser-dark { line-height: 1.3em;} + +.cm-s-lesser-dark.CodeMirror { background: #262626; color: #EBEFE7; text-shadow: 0 -1px 1px #262626; } +.cm-s-lesser-dark div.CodeMirror-selected { background: #45443B; } /* 33322B*/ +.cm-s-lesser-dark .CodeMirror-line::selection, .cm-s-lesser-dark .CodeMirror-line > span::selection, .cm-s-lesser-dark .CodeMirror-line > span > span::selection { background: rgba(69, 68, 59, .99); } +.cm-s-lesser-dark .CodeMirror-line::-moz-selection, .cm-s-lesser-dark .CodeMirror-line > span::-moz-selection, .cm-s-lesser-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(69, 68, 59, .99); } +.cm-s-lesser-dark .CodeMirror-cursor { border-left: 1px solid white; } +.cm-s-lesser-dark pre { padding: 0 8px; }/*editable code holder*/ + +.cm-s-lesser-dark.CodeMirror span.CodeMirror-matchingbracket { color: #7EFC7E; }/*65FC65*/ + +.cm-s-lesser-dark .CodeMirror-gutters { background: #262626; border-right:1px solid #aaa; } +.cm-s-lesser-dark .CodeMirror-guttermarker { color: #599eff; } +.cm-s-lesser-dark .CodeMirror-guttermarker-subtle { color: #777; } +.cm-s-lesser-dark .CodeMirror-linenumber { color: #777; } + +.cm-s-lesser-dark span.cm-header { color: #a0a; } +.cm-s-lesser-dark span.cm-quote { color: #090; } +.cm-s-lesser-dark span.cm-keyword { color: #599eff; } +.cm-s-lesser-dark span.cm-atom { color: #C2B470; } +.cm-s-lesser-dark span.cm-number { color: #B35E4D; } +.cm-s-lesser-dark span.cm-def { color: white; } +.cm-s-lesser-dark span.cm-variable { color:#D9BF8C; } +.cm-s-lesser-dark span.cm-variable-2 { color: #669199; } +.cm-s-lesser-dark span.cm-variable-3 { color: white; } +.cm-s-lesser-dark span.cm-property { color: #92A75C; } +.cm-s-lesser-dark span.cm-operator { color: #92A75C; } +.cm-s-lesser-dark span.cm-comment { color: #666; } +.cm-s-lesser-dark span.cm-string { color: #BCD279; } +.cm-s-lesser-dark span.cm-string-2 { color: #f50; } +.cm-s-lesser-dark span.cm-meta { color: #738C73; } +.cm-s-lesser-dark span.cm-qualifier { color: #555; } +.cm-s-lesser-dark span.cm-builtin { color: #ff9e59; } +.cm-s-lesser-dark span.cm-bracket { color: #EBEFE7; } +.cm-s-lesser-dark span.cm-tag { color: #669199; } +.cm-s-lesser-dark span.cm-attribute { color: #00c; } +.cm-s-lesser-dark span.cm-hr { color: #999; } +.cm-s-lesser-dark span.cm-link { color: #00c; } + + +.cm-s-lesser-dark .CodeMirror-activeline-background { background: #3C3A3A; } +.cm-s-lesser-dark .CodeMirror-matchingbracket { outline:1px solid grey; color:white !important; } + +/* NEW */ +.cm-s-lesser-dark span.cm-logic {color: #9a8fff;} +.cm-s-lesser-dark span.cm-filter {color: #72e285;} +.cm-desc {color: #ffaa00;} + +.cm-s-lesser-dark span.cm-error { + color: #ffdbd4; + text-decoration: underline wavy; + text-decoration-color: #dd2200; +} \ No newline at end of file diff --git a/web/client/themes/default/ms2-theme.less b/web/client/themes/default/ms2-theme.less index d1c98c5cbe..249901d89e 100644 --- a/web/client/themes/default/ms2-theme.less +++ b/web/client/themes/default/ms2-theme.less @@ -39,6 +39,7 @@ @import "./less/sidegrid.less"; @import "./less/shapefile-upload.less"; @import "./less/share.less"; +@import "./less/style-editor.less"; @import "./less/switch.less"; @import "./less/switchpanel.less"; @import "./less/square-button.less"; diff --git a/web/client/translations/data.de-DE b/web/client/translations/data.de-DE index a297978fb5..bc584f3d23 100644 --- a/web/client/translations/data.de-DE +++ b/web/client/translations/data.de-DE @@ -1444,6 +1444,49 @@ "paneltitle": "Gestalter", "layerlabel": "Ebene" }, + "styleeditor": { + "styleListfilterPlaceholder": "Filtern Sie Stile nach Name, Titel oder Abstract", + "templateFilterPlaceholder": "Filterstile Vorlagen nach Titel", + "createStyleFromTemplate": "Wählen Sie eine Vorlage aus, um einen neuen Stil zu erstellen", + "titleRequired": "
Titel ist Pflicht!
Titel und Abstract müssen alphanumerisch sein
", + "titleSettings": "Titel", + "titleSettingsplaceholder": "Titel eingeben (alphanumerisch)", + "abstractSettings": "Abstrakt", + "abstractSettingsplaceholder": "Abstrakt eingeben (alphanumerisch)", + "createStyleModalTitle": "Erstellen Sie einen neuen Stil", + "filterMatchNotFound": "Keine Stile entsprechen dem eingegebenen Textfilter", + "backToList": "Zurück zur Stilliste", + "createNewStyle": "Erstellen Sie einen neuen Stil", + "editSelectedStyle": "Bearbeiten Sie den ausgewählten Stil", + "saveCurrentStyle": "Aktuellen Stil speichern", + "addSelectedTemplate": "Fügen Sie die ausgewählte Vorlage zur Liste der Stile hinzu", + "deleteSelectedStyle": "Löschen Sie den ausgewählten Stil", + "closeWithoutSaveAlertTitle": "Der Stil hat sich geändert", + "closeWithoutSaveAlert": "Sie beenden den Style-Editor, ohne Ihre Änderungen zu speichern", + "deleteStyleAlertTitle": "Löschen Sie den Stil", + "deleteStyleAlert": "Der ausgewählte Stil wird endgültig gelöscht", + "delete": "Löschen", + "defaultStyle": "Standardstil", + "availableStyle": "Verfügbarer Stil", + "styleNotFound": "Stil nicht gefunden", + "noPermission": "Benutzer kann keine Stile bearbeiten", + "deletedStyleSuccessTitle": "Löschen Sie den Stil", + "deletedStyleSuccessMessage": "Der Stil wurde erfolgreich gelöscht", + "deletedStyleErrorTitle": "Stilfehler löschen", + "deletedStyleErrorMessage": "Der aktuelle Stil konnte nicht gelöscht werden", + "savedStyleTitle": "Stil gespeichert", + "savedStyleMessage": "Der Style wurde erfolgreich gespeichert", + "missingAvailableStyles": "Fehlende Stile", + "missingAvailableStylesMessage": "

    Mögliche Ursachen:

  • Die ausgewählte Ebene ist eine Ebenengruppe.
  • Die Ebene ist nicht serverseitig richtig konfiguriert.
", + "createTmpErrorTitle": "Neuer temporärer Stil", + "createTmpStyleErrorMessage": "Temporärer Stil konnte nicht erstellt werden. Dies könnte aufgrund eines nicht unterstützten Formatformats des Style-Service erfolgen", + "updateTmpErrorTitle": "Temporäre Stilaktualisierung", + "updateTmpStyleErrorMessage": "Temporärer Stil konnte nicht aktualisiert werden. Dies kann auf ein nicht unterstütztes Format- oder Verbindungsproblem zurückzuführen sein.", + "createStyleErrorTitle": "Neuer Stil", + "createStyleErrorMessage": "Stil konnte nicht im Stildienst gespeichert werden. Dies kann auf ein nicht unterstütztes Format- oder Verbindungsproblem zurückzuführen sein.", + "updateStyleErrorTitle": "Stil bearbeiten", + "updateStyleErrorMessage": "Der Stil konnte im Stildienst nicht aktualisiert werden. Dies kann auf ein nicht unterstütztes Format- oder Verbindungsproblem zurückzuführen sein." + }, "rulesmanager": { "apply": "Anwenden", "remove": "Entfernen Sie die Geometrie", diff --git a/web/client/translations/data.en-US b/web/client/translations/data.en-US index 8a962e716d..37b8fbcdb6 100644 --- a/web/client/translations/data.en-US +++ b/web/client/translations/data.en-US @@ -1445,6 +1445,49 @@ "paneltitle": "Styler", "layerlabel": "Layer" }, + "styleeditor": { + "styleListfilterPlaceholder": "Filter styles by name, title or abstract", + "templateFilterPlaceholder": "Filter styles templates by title", + "createStyleFromTemplate": "Select a template to create a new style", + "titleRequired": "
Title is required!
Title and abstract must be alphanumeric
", + "titleSettings": "Title", + "titleSettingsplaceholder": "Enter title (alphanumeric)", + "abstractSettings": "Abstract", + "abstractSettingsplaceholder": "Enter abstract (alphanumeric)", + "createStyleModalTitle": "Create new style", + "filterMatchNotFound": "No styles match entered text filter", + "backToList": "Back to style list", + "createNewStyle": "Create new style", + "editSelectedStyle": "Edit selected style", + "saveCurrentStyle": "Save current style", + "addSelectedTemplate": "Add selected template to list of styles", + "deleteSelectedStyle": "Delete selected style", + "closeWithoutSaveAlertTitle": "Style has changed", + "closeWithoutSaveAlert": "You are quitting the style editor without save your changes", + "deleteStyleAlertTitle": "Delete style", + "deleteStyleAlert": "Selected style will be permanently delete", + "delete": "Delete", + "defaultStyle": "Default style", + "availableStyle": "Available style", + "styleNotFound": "Style not found", + "noPermission": "User cannot edit styles", + "deletedStyleSuccessTitle": "Delete style", + "deletedStyleSuccessMessage": "Style has been successfully deleted", + "deletedStyleErrorTitle": "Delete style error", + "deletedStyleErrorMessage": "Could not delete current style", + "savedStyleTitle": "Style saved", + "savedStyleMessage": "Style has been successfully saved", + "missingAvailableStyles": "Missing styles", + "missingAvailableStylesMessage": "

    Possible causes:

  • Selected layer is a layer group
  • Layer is not correctly configured server side
", + "createTmpErrorTitle": "New Temporary Style", + "createTmpStyleErrorMessage": "Temporary style could not be created. This could due an unsupported style format on the style service", + "updateTmpErrorTitle": "Temporary Style Update", + "updateTmpStyleErrorMessage": "Temporary style could not be updated. This could be on unsupported style format or connection issue.", + "createStyleErrorTitle": "New Style", + "createStyleErrorMessage": "Style could not be saved on the style service. This could be on unsupported style format or connection issue.", + "updateStyleErrorTitle": "Edit Style", + "updateStyleErrorMessage": "Style could not be updated on the style service. This could be on unsupported style format or connection issue." + }, "rulesmanager": { "apply": "Apply", "remove": "Remove geometry", diff --git a/web/client/translations/data.es-ES b/web/client/translations/data.es-ES index 94d6d234e9..c3be9da36d 100644 --- a/web/client/translations/data.es-ES +++ b/web/client/translations/data.es-ES @@ -1444,6 +1444,49 @@ "paneltitle": "Estilo de la capa", "layerlabel": "Capa" }, + "styleeditor": { + "styleListfilterPlaceholder": "Filtrar estilos por nombre, título o resumen.", + "templateFilterPlaceholder": "Filtrar plantillas de estilos por título", + "createStyleFromTemplate": "Seleccione una plantilla para crear un nuevo estilo", + "titleRequired": "
¡Se requiere título!
El título y el resumen deben ser alfanuméricos
", + "titleSettings": "Título", + "titleSettingsplaceholder": "Introduzca el título (alfanumérico)", + "abstractSettings": "Resumen", + "abstractSettingsplaceholder": "Introduzca resumen (alfanumérico)", + "createStyleModalTitle": "Crear nuevo estilo", + "filterMatchNotFound": "No hay estilos que coincidan con el filtro de texto introducido", + "backToList": "Volver a la lista de estilos", + "createNewStyle": "Crear nuevo estilo", + "editSelectedStyle": "Editar el estilo seleccionado", + "saveCurrentStyle": "Guardar el estilo actual", + "addSelectedTemplate": "Añadir plantilla seleccionada a la lista de estilos", + "deleteSelectedStyle": "Eliminar el estilo seleccionado", + "closeWithoutSaveAlertTitle": "El estilo ha cambiado", + "closeWithoutSaveAlert": "Está saliendo del editor de estilos sin guardar los cambios.", + "deleteStyleAlertTitle": "Eliminar estilo", + "deleteStyleAlert": "El estilo seleccionado será eliminado permanentemente", + "delete": "Borrar", + "defaultStyle": "Estilo por Defecto", + "availableStyle": "Estilo disponible", + "styleNotFound": "Estilo no encontrado", + "noPermission": "El usuario no puede editar estilos", + "deletedStyleSuccessTitle": "Eliminar estilo", + "deletedStyleSuccessMessage": "El estilo ha sido eliminado exitosamente.", + "deletedStyleErrorTitle": "Eliminar error de estilo", + "deletedStyleErrorMessage": "No se pudo eliminar el estilo actual", + "savedStyleTitle": "Estilo guardado", + "savedStyleMessage": "El estilo se ha guardado con éxito.", + "missingAvailableStyles": "Estilos que faltan", + "missingAvailableStylesMessage": "

    Causas posibles:

  • La capa seleccionada es un grupo de capas
  • La capa no está configurada correctamente en el lado del servidor
", + "createTmpErrorTitle": "Nuevo estilo temporal", + "createTmpStyleErrorMessage": "No se pudo crear el estilo temporal. Esto podría deberse a un formato de estilo no compatible en el servicio de estilo", + "updateTmpErrorTitle": "Actualización de estilo temporal", + "updateTmpStyleErrorMessage": "El estilo temporal no pudo ser actualizado. Esto podría estar en formato de estilo no compatible o problema de conexión.", + "createStyleErrorTitle": "Nuevo estilo", + "createStyleErrorMessage": "El estilo no se pudo guardar en el servicio de estilo. Esto podría estar en formato de estilo no compatible o problema de conexión.", + "updateStyleErrorTitle": "Editar estilo", + "updateStyleErrorMessage": "El estilo no se pudo actualizar en el servicio de estilo. Esto podría estar en formato de estilo no compatible o problema de conexión." + }, "rulesmanager": { "apply": "Aplicar", "remove": "Eliminar geometría", diff --git a/web/client/translations/data.fr-FR b/web/client/translations/data.fr-FR index ac6c6eb5ff..f3c73848f3 100644 --- a/web/client/translations/data.fr-FR +++ b/web/client/translations/data.fr-FR @@ -1445,6 +1445,49 @@ "paneltitle": "Style de couche", "layerlabel": "Couche" }, + "styleeditor": { + "styleListfilterPlaceholder": "Filtrer les styles par nom, titre ou résumé", + "templateFilterPlaceholder": "Filtrer les modèles de styles par titre", + "createStyleFromTemplate": "Sélectionnez un modèle pour créer un nouveau style", + "titleRequired": "
Un titre est requis!
Le titre et le résumé doivent être alphanumériques .", + "titleSettings": "Titre", + "titleSettingsplaceholder": "Entrez le titre (alphanumérique)", + "abstractSettings": "Abstrait", + "abstractSettingsplaceholder": "Entrez abstract (alphanumeric)", + "createStyleModalTitle": "Créer un nouveau style", + "filterMatchNotFound": "Aucun style ne correspond au filtre de texte saisi", + "backToList": "Retour à la liste de style", + "createNewStyle": "Créer un nouveau style", + "editSelectedStyle": "Modifier le style sélectionné", + "saveCurrentStyle": "Enregistrer le style actuel", + "addSelectedTemplate": "Ajouter le modèle sélectionné à la liste des styles", + "deleteSelectedStyle": "Supprimer le style sélectionné", + "closeWithoutSaveAlertTitle": "Le style a changé", + "closeWithoutSaveAlert": "Vous quittez l'éditeur de style sans enregistrer vos modifications", + "deleteStyleAlertTitle": "Supprimer le style", + "deleteStyleAlert": "Le style sélectionné sera définitivement supprimé", + "delete": "Effacer", + "defaultStyle": "Style par défaut", + "availableStyle": "Style disponible", + "styleNotFound": "Style non trouvé", + "noPermission": "L'utilisateur ne peut pas éditer les styles", + "deletedStyleSuccessTitle": "Supprimer le style", + "deletedStyleSuccessMessage": "Le style a été supprimé avec succès", + "deletedStyleErrorTitle": "Supprimer l'erreur de style", + "deletedStyleErrorMessage": "Impossible de supprimer le style actuel", + "savedStyleTitle": "Style sauvegardé", + "savedStyleMessage": "Le style a été enregistré avec succès", + "missingAvailableStyles": "Styles manquants", + "missingAvailableStylesMessage": "

    Causes possibles:

  • La couche sélectionnée est un groupe de couches
  • La couche n'est pas configurée correctement côté serveur
", + "createTmpErrorTitle": "Nouveau style temporaire", + "createTmpStyleErrorMessage": "Le style temporaire n'a pas pu être créé. Cela pourrait être dû à un format de style non pris en charge sur le service de styles.", + "updateTmpErrorTitle": "Mise à jour de style temporaire", + "updateTmpStyleErrorMessage": "Le style temporaire n'a pas pu être mis à jour. Cela pourrait être sur un format de style non pris en charge ou un problème de connexion.", + "createStyleErrorTitle": "Nouveau style", + "createStyleErrorMessage": "Le style n'a pas pu être enregistré sur le service de style. Cela pourrait être sur un format de style non pris en charge ou un problème de connexion.", + "updateStyleErrorTitle": "Modifier le style", + "updateStyleErrorMessage": "Le style n'a pas pu être mis à jour sur le service de style. Cela pourrait être sur un format de style non pris en charge ou un problème de connexion." + }, "rulesmanager": { "apply": "Appliquer", "remove": "Supprimer la géométrie", diff --git a/web/client/translations/data.hr-HR b/web/client/translations/data.hr-HR index 203c67ee14..7444bfb153 100644 --- a/web/client/translations/data.hr-HR +++ b/web/client/translations/data.hr-HR @@ -1445,6 +1445,49 @@ "paneltitle": "Stilizator", "layerlabel": "Sloj" }, + "styleeditor": { + "styleListfilterPlaceholder": "Filter styles by name, title or abstract", + "templateFilterPlaceholder": "Filter styles templates by title", + "createStyleFromTemplate": "Select a template to create a new style", + "titleRequired": "
Title is required!
Title and abstract must be alphanumeric
", + "titleSettings": "Title", + "titleSettingsplaceholder": "Enter title (alphanumeric)", + "abstractSettings": "Abstract", + "abstractSettingsplaceholder": "Enter abstract (alphanumeric)", + "createStyleModalTitle": "Create new style", + "filterMatchNotFound": "No styles match entered text filter", + "backToList": "Back to style list", + "createNewStyle": "Create new style", + "editSelectedStyle": "Edit selected style", + "saveCurrentStyle": "Save current style", + "addSelectedTemplate": "Add selected template to list of styles", + "deleteSelectedStyle": "Delete selected style", + "closeWithoutSaveAlertTitle": "Style has changed", + "closeWithoutSaveAlert": "You are quitting the style editor without save your changes", + "deleteStyleAlertTitle": "Delete style", + "deleteStyleAlert": "Selected style will be permanently delete", + "delete": "Delete", + "defaultStyle": "Default style", + "availableStyle": "Available style", + "styleNotFound": "Style not found", + "noPermission": "User cannot edit styles", + "deletedStyleSuccessTitle": "Delete style", + "deletedStyleSuccessMessage": "Style has been successfully deleted", + "deletedStyleErrorTitle": "Delete style error", + "deletedStyleErrorMessage": "Could not delete current style", + "savedStyleTitle": "Style saved", + "savedStyleMessage": "Style has been successfully saved", + "missingAvailableStyles": "Missing styles", + "missingAvailableStylesMessage": "

    Possible causes:

  • Selected layer is a layer group
  • Layer is not correctly configured server side
", + "createTmpErrorTitle": "New Temporary Style", + "createTmpStyleErrorMessage": "Temporary style could not be created. This could due an unsupported style format on the style service", + "updateTmpErrorTitle": "Temporary Style Update", + "updateTmpStyleErrorMessage": "Temporary style could not be updated. This could be on unsupported style format or connection issue.", + "createStyleErrorTitle": "New Style", + "createStyleErrorMessage": "Style could not be saved on the style service. This could be on unsupported style format or connection issue.", + "updateStyleErrorTitle": "Edit Style", + "updateStyleErrorMessage": "Style could not be updated on the style service. This could be on unsupported style format or connection issue." + }, "rulesmanager": { "apply": "Primijeni", "remove": "Ukloni geometriju", diff --git a/web/client/translations/data.it-IT b/web/client/translations/data.it-IT index 41c2353652..fc3a960ddf 100644 --- a/web/client/translations/data.it-IT +++ b/web/client/translations/data.it-IT @@ -1443,7 +1443,50 @@ "tooltip": "Crea e modifica lo stile dei layer", "paneltitle": "Styler", "layerlabel": "Layer" - }, + }, + "styleeditor": { + "styleListfilterPlaceholder": "Filtra gli stili per nome, titolo o abstract", + "templateFilterPlaceholder": "Filtra template per titolo", + "createStyleFromTemplate": "Seleziona un template per creare un nuovo stile", + "titleRequired": "
Il titolo è obbligatorio!
Il titolo e l'abstract devono essere alfanumerici
", + "titleSettings": "Titolo", + "titleSettingsplaceholder": "Inserisci il titolo (alfanumerico)", + "abstractSettings": "Abstract", + "abstractSettingsplaceholder": "Inserisci abstract (alfanumerico)", + "createStyleModalTitle": "Crea un nuovo stile", + "filterMatchNotFound": "Nessuno stile corrisponde al filtro inserito", + "backToList": "Torna all'elenco degli stili", + "createNewStyle": "Crea un nuovo stile", + "editSelectedStyle": "Modifica lo stile selezionato", + "saveCurrentStyle": "Salva lo stile attuale", + "addSelectedTemplate": "Aggiungi il modello selezionato all'elenco degli stili", + "deleteSelectedStyle": "Elimina lo stile selezionato", + "closeWithoutSaveAlertTitle": "Lo stile è stato modificato", + "closeWithoutSaveAlert": "Stai abbandonando l'editor di stile senza salvare le modifiche", + "deleteStyleAlertTitle": "Elimina stile", + "deleteStyleAlert": "Lo stile selezionato verrà eliminato in modo permanente", + "delete": "Elimina", + "defaultStyle": "Stile predefinito", + "availableStyle": "Stile disponibile", + "styleNotFound": "Stile non trovato", + "noPermission": "L'utente non può modificare gli stili", + "deletedStyleSuccessTitle": "Elimina stile", + "deletedStyleSuccessMessage": "Lo stile è stato cancellato con successo", + "deletedStyleErrorTitle": "Elimina stile", + "deletedStyleErrorMessage": "Impossibile cancellare lo stile", + "savedStyleTitle": "Stile salvato", + "savedStyleMessage": "Lo stile è stato salvato", + "missingAvailableStyles": "Stili mancanti", + "missingAvailableStylesMessage": "

    Possibili cause:

  • Il layer selezionato è un layer group
  • Il layer non è configurato correttamente lato server
", + "createTmpErrorTitle": "Nuovo stile temporaneo", + "createTmpStyleErrorMessage": "Non è stato possibile creare uno stile temporaneo. Questo potrebbe essere dovuto al formato di stile non supportato dal servizio", + "updateTmpErrorTitle": "Aggiornamento di stile temporaneo", + "updateTmpStyleErrorMessage": "Lo stile temporaneo non può essere aggiornato. Questo potrebbe essere dovuto al formato di stile non supportato o ad un problema di connessione.", + "createStyleErrorTitle": "Nuovo stile", + "createStyleErrorMessage": "Lo stile non può essere salvato. Questo potrebbe essere dovuto al formato di stile non supportato o ad un problema di connessione.", + "updateStyleErrorTitle": "Modifica stile", + "updateStyleErrorMessage": "Lo stile non può essere aggiornato . Questo potrebbe essere dovuto ad un formato di stile non supportato o ad un problema di connessione." + }, "rulesmanager": { "apply": "Applica", "remove": "Elimina la geometia", diff --git a/web/client/translations/data.nl-NL b/web/client/translations/data.nl-NL index 6776257a77..a3565d37e6 100644 --- a/web/client/translations/data.nl-NL +++ b/web/client/translations/data.nl-NL @@ -1133,6 +1133,49 @@ "paneltitle": "Laagstijl", "layerlabel": "Laag" }, + "styleeditor": { + "styleListfilterPlaceholder": "Filter styles by name, title or abstract", + "templateFilterPlaceholder": "Filter styles templates by title", + "createStyleFromTemplate": "Select a template to create a new style", + "titleRequired": "
Title is required!
Title and abstract must be alphanumeric
", + "titleSettings": "Title", + "titleSettingsplaceholder": "Enter title (alphanumeric)", + "abstractSettings": "Abstract", + "abstractSettingsplaceholder": "Enter abstract (alphanumeric)", + "createStyleModalTitle": "Create new style", + "filterMatchNotFound": "No styles match entered text filter", + "backToList": "Back to style list", + "createNewStyle": "Create new style", + "editSelectedStyle": "Edit selected style", + "saveCurrentStyle": "Save current style", + "addSelectedTemplate": "Add selected template to list of styles", + "deleteSelectedStyle": "Delete selected style", + "closeWithoutSaveAlertTitle": "Style has changed", + "closeWithoutSaveAlert": "You are quitting the style editor without save your changes", + "deleteStyleAlertTitle": "Delete style", + "deleteStyleAlert": "Selected style will be permanently delete", + "delete": "Delete", + "defaultStyle": "Default style", + "availableStyle": "Available style", + "styleNotFound": "Style not found", + "noPermission": "User cannot edit styles", + "deletedStyleSuccessTitle": "Delete style", + "deletedStyleSuccessMessage": "Style has been successfully deleted", + "deletedStyleErrorTitle": "Delete style error", + "deletedStyleErrorMessage": "Could not delete current style", + "savedStyleTitle": "Style saved", + "savedStyleMessage": "Style has been successfully saved", + "missingAvailableStyles": "Missing styles", + "missingAvailableStylesMessage": "

    Possible causes:

  • Selected layer is a layer group
  • Layer is not correctly configured server side
", + "createTmpErrorTitle": "New Temporary Style", + "createTmpStyleErrorMessage": "Temporary style could not be created. This could due an unsupported style format on the style service", + "updateTmpErrorTitle": "Temporary Style Update", + "updateTmpStyleErrorMessage": "Temporary style could not be updated. This could be on unsupported style format or connection issue.", + "createStyleErrorTitle": "New Style", + "createStyleErrorMessage": "Style could not be saved on the style service. This could be on unsupported style format or connection issue.", + "updateStyleErrorTitle": "Edit Style", + "updateStyleErrorMessage": "Style could not be updated on the style service. This could be on unsupported style format or connection issue." + }, "rulesmanager": { "placeholders": { "filter": "Zoeken..." diff --git a/web/client/translations/data.zh-ZH b/web/client/translations/data.zh-ZH index 8953e7cb9d..99ae923526 100644 --- a/web/client/translations/data.zh-ZH +++ b/web/client/translations/data.zh-ZH @@ -1196,6 +1196,49 @@ "paneltitle": "样式工具", "layerlabel": "图层" }, + "styleeditor": { + "styleListfilterPlaceholder": "Filter styles by name, title or abstract", + "templateFilterPlaceholder": "Filter styles templates by title", + "createStyleFromTemplate": "Select a template to create a new style", + "titleRequired": "
Title is required!
Title and abstract must be alphanumeric
", + "titleSettings": "Title", + "titleSettingsplaceholder": "Enter title (alphanumeric)", + "abstractSettings": "Abstract", + "abstractSettingsplaceholder": "Enter abstract (alphanumeric)", + "createStyleModalTitle": "Create new style", + "filterMatchNotFound": "No styles match entered text filter", + "backToList": "Back to style list", + "createNewStyle": "Create new style", + "editSelectedStyle": "Edit selected style", + "saveCurrentStyle": "Save current style", + "addSelectedTemplate": "Add selected template to list of styles", + "deleteSelectedStyle": "Delete selected style", + "closeWithoutSaveAlertTitle": "Style has changed", + "closeWithoutSaveAlert": "You are quitting the style editor without save your changes", + "deleteStyleAlertTitle": "Delete style", + "deleteStyleAlert": "Selected style will be permanently delete", + "delete": "Delete", + "defaultStyle": "Default style", + "availableStyle": "Available style", + "styleNotFound": "Style not found", + "noPermission": "User cannot edit styles", + "deletedStyleSuccessTitle": "Delete style", + "deletedStyleSuccessMessage": "Style has been successfully deleted", + "deletedStyleErrorTitle": "Delete style error", + "deletedStyleErrorMessage": "Could not delete current style", + "savedStyleTitle": "Style saved", + "savedStyleMessage": "Style has been successfully saved", + "missingAvailableStyles": "Missing styles", + "missingAvailableStylesMessage": "

    Possible causes:

  • Selected layer is a layer group
  • Layer is not correctly configured server side
", + "createTmpErrorTitle": "New Temporary Style", + "createTmpStyleErrorMessage": "Temporary style could not be created. This could due an unsupported style format on the style service", + "updateTmpErrorTitle": "Temporary Style Update", + "updateTmpStyleErrorMessage": "Temporary style could not be updated. This could be on unsupported style format or connection issue.", + "createStyleErrorTitle": "New Style", + "createStyleErrorMessage": "Style could not be saved on the style service. This could be on unsupported style format or connection issue.", + "updateStyleErrorTitle": "Edit Style", + "updateStyleErrorMessage": "Style could not be updated on the style service. This could be on unsupported style format or connection issue." + }, "rulesmanager": { "placeholders": { "filter": "Search..." diff --git a/web/client/utils/StyleEditorUtils.js b/web/client/utils/StyleEditorUtils.js new file mode 100644 index 0000000000..5543f9d391 --- /dev/null +++ b/web/client/utils/StyleEditorUtils.js @@ -0,0 +1,145 @@ +/* +* Copyright 2018, GeoSolutions Sas. +* All rights reserved. +* +* This source code is licensed under the BSD-style license found in the +* LICENSE file in the root directory of this source tree. +*/ + +const { head, get, isArray, isString } = require('lodash'); +const uuidv1 = require('uuid/v1'); +const url = require('url'); +const { baseTemplates, customTemplates } = require('./styleeditor/stylesTemplates'); + +const STYLE_ID_SEPARATOR = '___'; +const STYLE_OWNER_NAME = 'styleeditor'; + +const StyleEditorCustomUtils = {}; + +const EDITOR_MODES = { + css: 'geocss', + sld: 'xml' +}; + +const getGeometryType = (geomProperty = {}) => { + const localPart = geomProperty.type && geomProperty.type.localPart && geomProperty.type.localPart.toLowerCase() || ''; + if (localPart.indexOf('polygon') !== -1 + || localPart.indexOf('surface') !== -1) { + return 'polygon'; + } else if (localPart.indexOf('linestring') !== -1) { + return 'linestring'; + } else if (localPart.indexOf('point') !== -1) { + return 'point'; + } + return 'vector'; +}; + +/** + * Utility functions for Share tools. + * @memberof utils + */ +const StyleEditorUtils = { + STYLE_OWNER_NAME, + STYLE_ID_SEPARATOR, + /** + * generate a temporary id for style + * @return {string} id + */ + generateTemporaryStyleId: () => `${uuidv1()}_ms_${Date.now().toString()}`, + /** + * generate a style id with title included + * @param {object} properties eg: {title: 'My Title'} + * @return {string} id + */ + generateStyleId: ({title = ''}) => `${title.toLowerCase().replace(/\s/g, '_')}${STYLE_ID_SEPARATOR}${uuidv1()}`, + /** + * extract feature properties from a layer object + * @param {object} layer layer object + * @return {object} {geometryType, properties, owsType} + */ + extractFeatureProperties: ({describeLayer = {}, describeFeatureType = {}} = {}) => { + + const owsType = describeLayer && describeLayer.owsType || null; + const descProperties = get(describeFeatureType, 'complexType[0].complexContent.extension.sequence.element') || null; + const geomProperty = descProperties && head(descProperties.filter(({ type }) => type && type.prefix === 'gml')); + const geometryType = owsType === 'WCS' && 'raster' || geomProperty && owsType === 'WFS' && getGeometryType(geomProperty) || null; + const properties = descProperties && descProperties.reduce((props, { name, type = {} }) => ({ + ...props, + [name]: { + localPart: type.localPart, + prefix: type.prefix + } + }), {}); + return { + geometryType, + properties, + owsType + }; + }, + /** + * convert style format to codemirror mode + * @param {string} format style format css or sld + * @return {string} mode name for codemirror or format string + */ + getEditorMode: format => EDITOR_MODES[format] || format, + /** + * verify if layer url is valid for style editor service + * @param {object} layer layer object + * @param {object} service style service object + * @return {bool} + */ + isSameOrigin: (layer = {}, service = {}) => { + if (StyleEditorCustomUtils.isSameOrigin) return StyleEditorCustomUtils.isSameOrigin(layer, service); + if (!service.baseUrl || !layer.url) return false; + const availableUrls = [service.baseUrl, ...(service.availableUrls || [])]; + const parsedAvailableUrls = availableUrls.map(availableUrl => { + const urlObj = url.parse(availableUrl); + return `${urlObj.protocol}//${urlObj.host}`; + }); + const layerObj = url.parse(layer.url); + const layerUrl = `${layerObj.protocol}//${layerObj.host}`; + return parsedAvailableUrls.indexOf(layerUrl) !== -1; + }, + /** + * return a ist of style templates + * Can be overrided with setCustomUtils, must return an array of templates + * @return {array} list of templates + */ + getStyleTemplates: () => { + if (StyleEditorCustomUtils.getStyleTemplates) { + const generatedTemplates = StyleEditorCustomUtils.getStyleTemplates(); + return [...(isArray(generatedTemplates) ? generatedTemplates : [] ), ...baseTemplates]; + } + return [...customTemplates, ...baseTemplates]; + }, + /** + * Override function in StyleEditorUtils (only isSameOrigin, getStyleTemplates) + * @param {string} name function name + * @param {function} fun function to override + */ + setCustomUtils: (name, fun) => { + StyleEditorCustomUtils[name] = fun; + }, + /** + * Get name and workspace from a goeserver name + * @param {string} name function name + * @return {object} + */ + getNameParts(name) { + const layerPart = isString(name) && name.split(':') || []; + return { + workspace: layerPart[1] && layerPart[0], + name: layerPart[1] || layerPart[0] + }; + }, + /** + * Stringify name and workspace + * @param {object} styleObj style object + * @param {string} styleObj.name name of style without workspace + * @param {object} styleObj.workspace {name: 'name of workspace'} + * @return {string} combination of workspace and name, eg. 'workspace:stylename' + */ + stringifyNameParts: ({name, workspace}) => `${workspace && workspace.name && `${workspace.name}:` || ''}${name}` +}; + +module.exports = StyleEditorUtils; diff --git a/web/client/utils/__tests__/StyleEditorUtils-test.js b/web/client/utils/__tests__/StyleEditorUtils-test.js new file mode 100644 index 0000000000..111c2fdc9f --- /dev/null +++ b/web/client/utils/__tests__/StyleEditorUtils-test.js @@ -0,0 +1,131 @@ +/* +* Copyright 2018, GeoSolutions Sas. +* All rights reserved. +* +* This source code is licensed under the BSD-style license found in the +* LICENSE file in the root directory of this source tree. +*/ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const expect = require('expect'); + +const { + generateTemporaryStyleId, + STYLE_ID_SEPARATOR, + generateStyleId, + extractFeatureProperties, + getEditorMode, + isSameOrigin, + getStyleTemplates, + getNameParts, + stringifyNameParts +} = require('../StyleEditorUtils'); + +describe('StyleEditorUtils test', () => { + it('test generateTemporaryStyleId', () => { + const divider = '_ms_'; + const generateId = generateTemporaryStyleId(); + const idSplit = generateId.split(divider); + expect(idSplit.length).toBe(2); + }); + it('test generateStyleId', () => { + const generateId = generateStyleId({title: 'My style TiTle'}); + const idSplit = generateId.split(STYLE_ID_SEPARATOR); + expect(idSplit.length).toBe(2); + expect(idSplit[0]).toBe('my_style_title'); + }); + it('test extractFeatureProperties', () => { + const layer = { + id: 'layerId', + name: 'layerName', + style: 'point', + describeLayer: { + owsType: 'WFS' + }, + describeFeatureType: { + complexType: [{ + complexContent: { + extension: { + sequence: { + element: [{ + TYPE_NAME: "XSD_1_0.LocalElement", + maxOccurs: "1", + minOccurs: 0, + name: "RANK", + nillable: true, + otherAttributes: {}, + type: { + key: "{http://www.w3.org/2001/XMLSchema}short", + localPart: "short", + namespaceURI: "http://www.w3.org/2001/XMLSchema", + prefix: "xsd", + string: "{http://www.w3.org/2001/XMLSchema}xsd:short" + } + }, { + TYPE_NAME: "XSD_1_0.LocalElement", + maxOccurs: "1", + minOccurs: 0, + name: "geom", + nillable: true, + otherAttributes: {}, + type: { + key: "{http://www.opengis.net/gml}PointPropertyType", + localPart: "PointPropertyType", + namespaceURI: "http://www.opengis.net/gml", + prefix: "gml", + string: "{http://www.opengis.net/gml}gml:PointPropertyType" + } + }] + } + } + } + }] + } + }; + expect(extractFeatureProperties(layer)).toEqual({ + geometryType: 'point', + properties: { + RANK: { localPart: 'short', prefix: 'xsd' }, + geom: { localPart: 'PointPropertyType', prefix: 'gml' } + }, + owsType: 'WFS' + }); + + }); + it('test getEditorMode', () => { + expect(getEditorMode('css')).toBe('geocss'); + expect(getEditorMode('sld')).toBe('xml'); + expect(getEditorMode('yaml')).toBe('yaml'); + }); + it('test isSameOrigin', () => { + expect(isSameOrigin({url: '/geoserver'}, {baseUrl: '/geoserver'})).toBe(true); + expect(isSameOrigin({url: 'http://localhost:8080/geoserver'}, {baseUrl: '/geoserver'})).toBe(false); + expect(isSameOrigin({url: 'http://localhost:8080/geoserver'}, {baseUrl: '/geoserver', availableUrls: [ 'http://localhost:8080']})).toBe(true); + }); + it('test getStyleTemplates', () => { + expect(getStyleTemplates().length > 2).toBe(true); + }); + it('test getNameParts', () => { + expect(getNameParts('workspace:name')).toEqual({ + name: 'name', + workspace: 'workspace' + }); + + expect(getNameParts('name')).toEqual({ + name: 'name', + workspace: undefined + }); + }); + + it('test getNameParts', () => { + expect(stringifyNameParts({name: 'name', workspace: {name: 'workspace'}})).toBe('workspace:name'); + expect(stringifyNameParts({name: 'name'})).toBe('name'); + }); + +}); diff --git a/web/client/utils/styleeditor/stylesTemplates.js b/web/client/utils/styleeditor/stylesTemplates.js new file mode 100644 index 0000000000..1ea1d8bf2c --- /dev/null +++ b/web/client/utils/styleeditor/stylesTemplates.js @@ -0,0 +1,391 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const React = require('react'); +const uuidv1 = require('uuid/v1'); +const SVGPreview = require('../../components/styleeditor/SVGPreview'); + +/** + * Template object structure + * @prop {string} title title of style template + * @prop {array} types types that support current style template, eg: ['point', 'linestring', 'polygon', 'vector', 'raster'] + * @prop {string} format 'css' or 'sld' + * @prop {string} code style code + * @prop {node} preview preview node + * @prop {string} styleId identifier + */ + +const baseTemplates = [{ + types: ['point', 'linestring', 'polygon', 'vector'], + title: 'Base CSS', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n * {\n\tstroke: #999999;\n\tmark: symbol(square);\n\t:mark { fill: #ff0000; };\n}", + preview: +}, +{ + types: ['point', 'linestring', 'polygon', 'vector'], + title: 'Base SLD', + format: 'sld', + code: '\n\n\n\t\n\t\tDefault Style\n\t\t\n\t\t\t${styleTitle}\n\t\t\t${styleAbstract}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tRule Name\n\t\t\t\t\tRule Title\n\t\t\t\t\tRule Abstract\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t#0000FF\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tsquare\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t#FF0000\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n', + preview: +}, +{ + types: ['raster'], + title: 'Base CSS', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n * {\n\traster-channels: auto;\n}", + preview: +}, +{ + types: ['raster'], + title: 'Base SLD', + format: 'sld', + code: '\n\n\n\t\n\t\tDefault Style\n\t\t\n\t\t\t${styleTitle}\n\t\t\t${styleAbstract}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tRule Name\n\t\t\t\t\tRule Title\n\t\t\t\t\tRule Abstract\n\t\t\t\t\t\n\t\t\t\t\t\t1.0\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n', + preview: +}].map(style => ({ ...style, styleId: uuidv1() })); + +const customTemplates = [ + { + types: ['linestring', 'vector'], + title: 'Line', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n* {\n\tstroke: #999999;\n}", + preview: + }, + { + types: ['linestring', 'vector'], + title: 'Dashed line', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n* {\n\tstroke: #333333;\n\tstroke-width: 0.75;\n\tstroke-dasharray: 6 2;\n}", + preview: + }, + { + types: ['linestring', 'vector'], + title: 'Section line', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n* {\n\tstroke: #330033;\n\tstroke-width: 1;\n\tstroke-dasharray: 10 4 1 4;\n}", + preview: + }, + { + types: ['linestring', 'vector'], + title: 'Simple railway', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n* {\n\tstroke: #333333, #333333;\n\tstroke-width: 0.5, 7;\n\tstroke-dasharray: 1 0, 1 10;\n}", + preview: + }, + { + types: ['linestring', 'vector'], + title: 'Railway', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n* {\n\tstroke: #777777, #ffffff;\n\tstroke-width: 4, 2;\n\tstroke-dasharray: 1 0, 10 10;\n\tz-index: 0, 1;\n}", + preview: + }, + { + types: ['linestring', 'vector'], + title: 'Waterway', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n* {\n\tstroke: #8bbceb, #bbddff;\n\tstroke-width: 10, 8;\n\tstroke-linejoin: round;\n\tz-index: 0, 1;\n}", + preview: + }, + { + types: ['linestring', 'vector'], + title: 'Red road', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n* {\n\tstroke: #ff5539, #ffffff;\n\tstroke-width: 8, 5;\n\tz-index: 0, 1;\n}", + preview: + }, + { + types: ['polygon', 'vector'], + title: 'Solid fill', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n* {\n\tfill: #aaaaaa;\n}", + preview: + }, + { + types: ['polygon', 'vector'], + title: 'Forest fill', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n* {\n\tfill: #c1ffb3, symbol(triangle);\n\t:fill {\n\t\tfill: #98c390;\n\t\tstroke: #e9ffde;\n\t\tsize: 15;\n\t};\n}", + preview: + }, + { + types: ['point', 'vector'], + title: 'Square', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n * {\n\tmark: symbol(square);\n\t:mark {\n\t\tstroke: #ff338f;\n\t\tfill: #bcedff;\n\t};\n}", + preview: + }, + { + types: ['point', 'vector'], + title: 'Circle', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n * {\n\tmark: symbol(circle);\n\t:mark {\n\t\tstroke: #ff338f;\n\t\tfill: #bcedff;\n\t};\n}", + preview: + }, + { + types: ['point', 'vector'], + title: 'Triangle', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n * {\n\tmark: symbol(triangle);\n\t:mark {\n\t\tstroke: #ff338f;\n\t\tfill: #bcedff;\n\t};\n}", + preview: + }, + { + types: ['point', 'vector'], + title: 'Star', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n * {\n\tmark: symbol(star);\n\t:mark {\n\t\tstroke: #ff338f;\n\t\tfill: #bcedff;\n\t};\n}", + preview: + }, + { + types: ['point', 'vector'], + title: 'Cross', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n * {\n\tmark: symbol(cross);\n\t:mark {\n\t\tstroke: #ff338f;\n\t\tfill: #bcedff;\n\t};\n}", + preview: + }, + { + types: ['point', 'vector'], + title: 'X', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n * {\n\tmark: symbol(x);\n\t:mark {\n\t\tstroke: #ff338f;\n\t\tfill: #bcedff;\n\t};\n}", + preview: + }, + { + types: ['point', 'vector'], + title: 'Line', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n * {\n\tmark: symbol('shape://vertline');\n\t:mark { stroke: #ff338f; };\n}", + preview: + }, + { + types: ['point', 'vector'], + title: 'Plus', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n * {\n\tmark: symbol('shape://plus');\n\t:mark { stroke: #ff338f; };\n}", + preview: + }, + { + types: ['point', 'vector'], + title: 'Times', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n * {\n\tmark: symbol('shape://times');\n\t:mark { stroke: #ff338f; };\n}", + preview: + }, + { + types: ['point', 'vector'], + title: 'Open arrow', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n * {\n\tmark: symbol('shape://oarrow');\n\t:mark { stroke: #ff338f; };\n}", + preview: + }, + { + types: ['point', 'vector'], + title: 'Closed arrow', + format: 'css', + code: "@styleTitle '${styleTitle}';\n@styleAbstract '${styleAbstract}';\n\n * {\n\tmark: symbol('shape://carrow');\n\t:mark { stroke: #ff338f; };\n}", + preview: + } +].map(style => ({ ...style, styleId: uuidv1() })); + +module.exports = { + baseTemplates, + customTemplates +};