details of this map
"; const detailsUri = "data/2"; @@ -56,6 +58,10 @@ let map1 = { id: mapId, name: "name" }; +let map2 = { + id: mapId2, + name: "name2" +}; let map8 = { id: mapId8, name: "name" @@ -433,5 +439,24 @@ describe('maps Epics', () => { } }); }); + it('test storeDetailsInfoEpic', (done) => { + testEpic(addTimeoutEpic(storeDetailsInfoEpic), 1, mapInfoLoaded(map2, mapId2), actions => { + expect(actions.length).toBe(1); + actions.map((action) => { + switch (action.type) { + case DETAILS_LOADED: + expect(action.mapId).toBe(mapId2); + expect(action.detailsUri).toBe("rest%2Fgeostore%2Fdata%2F3983%2Fraw%3Fdecode%3Ddatauri"); + break; + default: + expect(true).toBe(false); + } + }); + done(); + }, {mapInitialConfig: { + "mapId": mapId2 + }}); + }); + }); diff --git a/web/client/epics/__tests__/search-test.js b/web/client/epics/__tests__/search-test.js index 874d7ecfab..b3d2f48fd0 100644 --- a/web/client/epics/__tests__/search-test.js +++ b/web/client/epics/__tests__/search-test.js @@ -11,7 +11,7 @@ var expect = require('expect'); const configureMockStore = require('redux-mock-store').default; const { createEpicMiddleware, combineEpics } = require('redux-observable'); -const { textSearch, selectSearchItem, TEXT_SEARCH_RESULTS_LOADED, TEXT_SEARCH_LOADING, TEXT_SEARCH_ADD_MARKER, TEXT_SEARCH_RESULTS_PURGE, TEXT_SEARCH_NESTED_SERVICES_SELECTED, TEXT_SEARCH_TEXT_CHANGE } = require('../../actions/search'); +const { textSearch, selectSearchItem, TEXT_SEARCH_RESULTS_LOADED, TEXT_SEARCH_LOADING, TEXT_SEARCH_ADD_MARKER, TEXT_SEARCH_RESULTS_PURGE, TEXT_SEARCH_NESTED_SERVICES_SELECTED, TEXT_SEARCH_TEXT_CHANGE, UPDATE_RESULTS_STYLE } = require('../../actions/search'); const {CHANGE_MAP_VIEW} = require('../../actions/map'); const {searchEpic, searchItemSelected } = require('../search'); const rootEpic = combineEpics(searchEpic, searchItemSelected); @@ -106,10 +106,11 @@ describe('search Epics', () => { store.dispatch( action ); let actions = store.getActions(); - expect(actions.length).toBe(4); + expect(actions.length).toBe(5); expect(actions[1].type).toBe(TEXT_SEARCH_RESULTS_PURGE); - expect(actions[2].type).toBe(CHANGE_MAP_VIEW); - expect(actions[3].type).toBe(TEXT_SEARCH_ADD_MARKER); + expect(actions[2].type).toBe(UPDATE_RESULTS_STYLE); + expect(actions[3].type).toBe(CHANGE_MAP_VIEW); + expect(actions[4].type).toBe(TEXT_SEARCH_ADD_MARKER); }); it('searchItemSelected epic with nested services', () => { @@ -124,8 +125,8 @@ describe('search Epics', () => { store.dispatch( action ); let actions = store.getActions(); - expect(actions.length).toBe(6); - let expectedActions = [CHANGE_MAP_VIEW, TEXT_SEARCH_ADD_MARKER, TEXT_SEARCH_RESULTS_PURGE, TEXT_SEARCH_NESTED_SERVICES_SELECTED, TEXT_SEARCH_TEXT_CHANGE ]; + expect(actions.length).toBe(7); + let expectedActions = [CHANGE_MAP_VIEW, TEXT_SEARCH_ADD_MARKER, TEXT_SEARCH_RESULTS_PURGE, UPDATE_RESULTS_STYLE, TEXT_SEARCH_NESTED_SERVICES_SELECTED, TEXT_SEARCH_TEXT_CHANGE ]; let actionsType = actions.map(a => a.type); expectedActions.forEach((a) => { diff --git a/web/client/epics/__tests__/widgets-test.js b/web/client/epics/__tests__/widgets-test.js index 00cdb9d81d..3199b8d80c 100644 --- a/web/client/epics/__tests__/widgets-test.js +++ b/web/client/epics/__tests__/widgets-test.js @@ -9,10 +9,19 @@ var expect = require('expect'); const { testEpic, addTimeoutEpic, TEST_TIMEOUT } = require('./epicTestUtils'); const { - clearWidgetsOnLocationChange + clearWidgetsOnLocationChange, + alignDependenciesToWidgets, + toggleWidgetConnectFlow } = require('../widgets'); const { - CLEAR_WIDGETS + CLEAR_WIDGETS, + insertWidget, + toggleConnection, + selectWidget, + EDITOR_CHANGE, + EDITOR_SETTING_CHANGE, + LOAD_DEPENDENCIES, + DEPENDENCY_SELECTOR_KEY } = require('../../actions/widgets'); const { savingMap, @@ -126,4 +135,141 @@ describe('widgets Epics', () => { }; }); }); + it('alignDependenciesToWidgets triggered on insertWidget', (done) => { + const checkActions = actions => { + expect(actions.length).toBe(1); + const action = actions[0]; + expect(action.type).toBe(LOAD_DEPENDENCIES); + expect(action.dependencies).toExist(); + expect(action.dependencies.center).toBe("map.center"); + expect(action.dependencies.viewport).toBe("map.bbox"); + expect(action.dependencies.zoom).toBe("map.zoom"); + done(); + }; + testEpic(alignDependenciesToWidgets, + 1, + [insertWidget({id: 'test'})], + checkActions, + {}); + }); + + it('toggleWidgetConnectFlow with only map', (done) => { + const checkActions = actions => { + expect(actions.length).toBe(2); + expect(actions[0].type).toBe(EDITOR_CHANGE); + expect(actions[0].key).toBe("mapSync"); + expect(actions[0].value).toBe(true); + const action = actions[1]; + expect(action.type).toBe(EDITOR_CHANGE); + expect(action.key).toExist(); + expect(action.key).toBe("dependenciesMap"); + expect(action.value.center).toBe("center"); + expect(action.value.zoom).toBe("zoom"); + done(); + }; + testEpic(toggleWidgetConnectFlow, + 2, + [toggleConnection( + true, + ["map"], + { mappings: { zoom: "zoom", center: "center" } } + )], + checkActions, + {}); + }); + it('toggleWidgetConnectFlow for widgets', (done) => { + const checkActions = actions => { + expect(actions.length).toBe(2); + expect(actions[0].type).toBe(EDITOR_CHANGE); + expect(actions[0].key).toBe("mapSync"); + expect(actions[0].value).toBe(true); + const action = actions[1]; + expect(action.type).toBe(EDITOR_CHANGE); + expect(action.key).toExist(); + expect(action.key).toBe("dependenciesMap"); + expect(action.value.center).toBe("widgets[a].map.center"); + expect(action.value.zoom).toBe("widgets[a].map.zoom"); + done(); + }; + testEpic(toggleWidgetConnectFlow, + 2, + [toggleConnection( + true, + ["widgets[a].map"], + { mappings: { "center": "center", "zoom": "zoom" } } + )], + checkActions, + {}); + }); + it('toggleWidgetConnectFlow for multiple widgets', (done) => { + const checkActions = actions => { + expect(actions.length).toBe(4); + expect(actions[0].type).toBe(EDITOR_SETTING_CHANGE); + expect(actions[0].key).toBe(DEPENDENCY_SELECTOR_KEY); + expect(actions[0].value.active).toBe(true); + expect(actions[0].value.availableDependencies.length).toBe(2); + expect(actions[1].type).toBe(EDITOR_CHANGE); + expect(actions[1].key).toBe("mapSync"); + expect(actions[1].value).toBe(true); + const action = actions[2]; + expect(action.type).toBe(EDITOR_CHANGE); + expect(action.key).toExist(); + expect(action.key).toBe("dependenciesMap"); + expect(action.value.center).toBe("widgets[w1].map.center"); + expect(action.value.zoom).toBe("widgets[w1].map.zoom"); + expect(actions[3].type).toBe(EDITOR_SETTING_CHANGE); + expect(actions[3].key).toBe(DEPENDENCY_SELECTOR_KEY); + expect(actions[3].value.active).toBe(false); + done(); + }; + testEpic(toggleWidgetConnectFlow, + 4, + [toggleConnection( + true, + ["w1", "w2"], + { mappings: { "center": "center", "zoom": "zoom" } } + ), selectWidget({ + id: "w1", + widgetType: "map" + })], + checkActions, + { + widgets: { + builder: { + settings: { + [DEPENDENCY_SELECTOR_KEY]: { active: true, + availableDependencies: [ + "map.zoom", + "widgets[w1].map", + "widgets[w2].map" + ] } + } + } + } + }); + }); + it('toggleWidgetConnectFlow deactivate widgets', (done) => { + const checkActions = actions => { + expect(actions.length).toBe(2); + expect(actions[0].type).toBe(EDITOR_CHANGE); + expect(actions[0].key).toBe("mapSync"); + expect(actions[0].value).toBe(false); + const action = actions[1]; + expect(action.type).toBe(EDITOR_CHANGE); + expect(action.key).toExist(); + expect(action.key).toBe("dependenciesMap"); + expect(action.value.center).toNotExist(); + expect(action.value.zoom).toNotExist(); + done(); + }; + testEpic(toggleWidgetConnectFlow, + 2, + [toggleConnection( + false, + ["map"], + { mappings: { "center": "widgets[a].map.center", "zoom": "widgets[a].map.zoom" } } + )], + checkActions, + {}); + }); }); diff --git a/web/client/epics/__tests__/widgetsbuilder-test.js b/web/client/epics/__tests__/widgetsbuilder-test.js index 0e7c508915..50d9725219 100644 --- a/web/client/epics/__tests__/widgetsbuilder-test.js +++ b/web/client/epics/__tests__/widgetsbuilder-test.js @@ -11,14 +11,18 @@ const { openWidgetEditor, initEditorOnNew, closeWidgetEditorOnFinish, - handleWidgetsFilterPanel + handleWidgetsFilterPanel, + initEditorOnNewChart } = require('../widgetsbuilder'); const { createWidget, editWidget, insertWidget, - openFilterEditor, + openFilterEditor, createChart, EDIT_NEW, EDITOR_CHANGE } = require('../../actions/widgets'); +const { + CLOSE_FEATURE_GRID +} = require('../../actions/featuregrid'); const {FEATURE_TYPE_SELECTED} = require('../../actions/wfsquery'); const {LOAD_FILTER, search} = require('../../actions/queryform'); @@ -170,6 +174,33 @@ describe('widgetsbuilder epic', () => { } }); }); + it('initEditorOnNewChart', (done) => { + const startActions = [createChart()]; + testEpic(initEditorOnNewChart, 2, startActions, actions => { + expect(actions.length).toBe(2); + actions.map((action) => { + switch (action.type) { + case EDIT_NEW: + expect(action.widget).toExist(); + // verify default mapSync + expect(action.widget.mapSync).toBe(true); + break; + case CLOSE_FEATURE_GRID: + expect(action.type).toBe(CLOSE_FEATURE_GRID); + break; + default: + done(new Error("Action not recognized")); + } + }, ); + done(); + }, { + controls: { + widgetBuilder: { + available: true + } + } + }); + }); it('handleWidgetsFilterPanel', (done) => { const startActions = [openFilterEditor()]; testEpic(handleWidgetsFilterPanel, 4, startActions, actions => { diff --git a/web/client/epics/dashboard.js b/web/client/epics/dashboard.js index 30e3dc841c..e49ad82372 100644 --- a/web/client/epics/dashboard.js +++ b/web/client/epics/dashboard.js @@ -11,7 +11,15 @@ const { editNewWidget, onEditorChange } = require('../actions/widgets'); const { - setEditing + setEditing, + dashboardSaved, + dashboardLoaded, + dashboardLoading, + triggerSave, + loadDashboard, + dashboardSaveError, + SAVE_DASHBOARD, + LOAD_DASHBOARD } = require('../actions/dashboard'); const { setControlProperty, @@ -20,20 +28,40 @@ const { const { featureTypeSelected } = require('../actions/wfsquery'); +const { + show, + error +} = require('../actions/notifications'); const { loadFilter, QUERY_FORM_SEARCH } = require('../actions/queryform'); +const { + LOGIN_SUCCESS, + LOGOUT +} = require('../actions/security'); const { isDashboardEditing, isDashboardAvailable = () => true } = require('../selectors/dashboard'); + +const { + isLoggedIn +} = require('../selectors/security'); const { getEditingWidgetLayer, getEditingWidgetFilter } = require('../selectors/widgets'); -const {LOCATION_CHANGE} = require('react-router-redux'); +const { + createResource, + updateResource, + getResource +} = require('../observables/geostore'); +const { + wrapStartStop +} = require('../observables/epics'); +const { LOCATION_CHANGE, push} = require('react-router-redux'); const getFTSelectedArgs = (state) => { let layer = getEditingWidgetLayer(state); let url = layer.search && layer.search.url; @@ -42,23 +70,29 @@ const getFTSelectedArgs = (state) => { }; module.exports = { + + // Basic interactions with dashboard editor openDashboardWidgetEditor: (action$, {getState = () => {}} = {}) => action$.ofType(NEW, EDIT) .filter( () => isDashboardAvailable(getState())) .switchMap(() => Rx.Observable.of( setEditing(true) )), + // Basic interactions with dashboard editor closeDashboardWidgetEditorOnFinish: (action$, {getState = () => {}} = {}) => action$.ofType(INSERT) .filter( () => isDashboardAvailable(getState())) .switchMap(() => Rx.Observable.of(setEditing(false))), + + // Basic interactions with dashboard editor initDashboardEditorOnNew: (action$, {getState = () => {}} = {}) => action$.ofType(NEW) .filter( () => isDashboardAvailable(getState())) .switchMap((w) => Rx.Observable.of(editNewWidget({ legend: false, - mapSync: true, + mapSync: false, ...w, // override action's type type: undefined }, {step: 0}))), + // Basic interactions with dashboard editor closeDashboardEditorOnExit: (action$, {getState = () => {}} = {}) => action$.ofType(LOCATION_CHANGE) .filter( () => isDashboardAvailable(getState())) .filter( () => isDashboardEditing(getState()) ) @@ -99,6 +133,73 @@ module.exports = { setControlProperty('queryPanel', "enabled", false) ) ) - ) - + ), + // dashboard loading from resource ID. + loadDashboardStream: (action$, {getState = () => {}}) => action$ + .ofType(LOAD_DASHBOARD) + .switchMap( ({id}) => + getResource(id) + .map(({ data, ...resource }) => dashboardLoaded(resource, data)) + .let(wrapStartStop( + dashboardLoading(true, "loading"), + dashboardLoading(false, "loading"), + e => { + if (e.status === 403 ) { + if ( isLoggedIn(getState())) { + return Rx.Observable.of(error({ + title: "dashboard.errors.loading.title", + message: "dashboard.errors.loading.dashboardNotAccessible" + })); + } + return Rx.Observable.of(error({ + title: "dashboard.errors.loading.title", + message: "dashboard.errors.loading.pleaseLogin" + })); + } if (e.status === 404) { + return Rx.Observable.of(error({ + title: "dashboard.errors.loading.title", + message: "dashboard.errors.loading.dashboardDoesNotExist" + })); + } + return Rx.Observable.of(error({ + title: "dashboard.errors.loading.title", + message: "dashboard.errors.loading.unknownError" + })); + } + )) + ), + reloadDashboardOnLoginLogout: (action$) => + action$.ofType(LOAD_DASHBOARD).switchMap( + ({ id }) => action$ + .ofType(LOGIN_SUCCESS, LOGOUT) + .switchMap(() => Rx.Observable.of(loadDashboard(id)).delay(1000)) + .takeUntil(action$.ofType(LOCATION_CHANGE)) + ), + // saving dashboard flow (both creation and update) + saveDashboard: action$ => action$ + .ofType(SAVE_DASHBOARD) + .exhaustMap(({resource} = {}) => + (!resource.id ? createResource(resource) : updateResource(resource)) + .switchMap(rid => Rx.Observable.of( + dashboardSaved(rid), + triggerSave(false), + !resource.id + ? push(`/dashboard/${rid}`) + : loadDashboard(rid), + ).merge( + Rx.Observable.of(show({ + id: "DASHBOARD_SAVE_SUCCESS", + title: "dashboard.saveDialog.saveSuccessTitle", + message: "dashboard.saveDialog.saveSuccessMessage" + })).delay(!resource.id ? 1000 : 0) // delay to allow loading + ) + ) + .let(wrapStartStop( + dashboardLoading(true, "saving"), + dashboardLoading(false, "saving") + )) + .catch( + ({ status, statusText, data, message, ...other } = {}) => Rx.Observable.of(dashboardSaveError(status ? { status, statusText, data } : message || other), dashboardLoading(false, "saving")) + ) + ) }; diff --git a/web/client/epics/dashboards.js b/web/client/epics/dashboards.js new file mode 100644 index 0000000000..2509d93330 --- /dev/null +++ b/web/client/epics/dashboards.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 Rx = require('rxjs'); +const { MAPS_LIST_LOADING, ATTRIBUTE_UPDATED} = require('../actions/maps'); +const { SEARCH_DASHBOARDS, DELETE_DASHBOARD, DASHBOARD_DELETED, RELOAD, searchDashboards, dashboardListLoaded, dashboardDeleted, dashboardsLoading } = require('../actions/dashboards'); +const { searchParamsSelector, searchTextSelector, totalCountSelector} = require('../selectors/dashboards'); +const GeoStoreApi = require('../api/GeoStoreDAO'); +const { wrapStartStop } = require('../observables/epics'); +const {error} = require('../actions/notifications'); + +const {deleteResource} = require('../observables/geostore'); + +const calculateNewParams = state => { + const totalCount = totalCountSelector(state); + const {start, limit, ...params} = searchParamsSelector(state); + if (start === totalCount - 1) { + return { + start: Math.max(0, start - limit), + limit + }; + } + return { + start, limit, ...params + }; +}; + +module.exports = { + searchDashboardsOnMapSearch: action$ => + action$.ofType(MAPS_LIST_LOADING) + .switchMap(({ searchText }) => Rx.Observable.of(searchDashboards(searchText))), + searchDashboards: (action$, { getState = () => { } }) => + action$.ofType(SEARCH_DASHBOARDS) + .map( ({params, searchText, geoStoreUrl}) => ({ + searchText, + options: { + params: params || searchParamsSelector(getState()) || {start: 0, limit: 12}, + ...(geoStoreUrl ? { baseURL: geoStoreUrl } : {}) + } + })) + .switchMap( + ({ searchText, options }) => + Rx.Observable.defer(() => GeoStoreApi.getResourcesByCategory("DASHBOARD", searchText, options)) + .map(results => dashboardListLoaded(results, {searchText, options})) + .let(wrapStartStop( + dashboardsLoading(true, "loading"), + dashboardsLoading(false, "loading"), + () => Rx.Observable.of(error({ + title: "notification.error", + message: "resources.dashboards.errorLoadingDashboards", + autoDismiss: 6, + position: "tc" + })) + )) + ), + deleteDashboard: action$ => action$ + .ofType(DELETE_DASHBOARD) + .switchMap(id => deleteResource(id).map(() => dashboardDeleted(id))) + .let(wrapStartStop( + dashboardsLoading(true, "loading"), + dashboardsLoading(false, "loading"), + () => Rx.Observable.of(error({ + title: "notification.error", + message: "resources.dashboards.deleteError", + autoDismiss: 6, + position: "tc" + })) + )), + reloadOnDashboards: (action$, { getState = () => { } }) => + action$.ofType(DASHBOARD_DELETED, RELOAD, ATTRIBUTE_UPDATED) + .delay(1000) // delay as a workaround for geostore issue #178 + .switchMap( () => Rx.Observable.of(searchDashboards( + searchTextSelector(getState()), + calculateNewParams(getState()) + ))) + }; diff --git a/web/client/epics/globeswitcher.js b/web/client/epics/globeswitcher.js index 492a2308fc..a32ef6e79f 100644 --- a/web/client/epics/globeswitcher.js +++ b/web/client/epics/globeswitcher.js @@ -6,17 +6,22 @@ * LICENSE file in the root directory of this source tree. */ const {TOGGLE_3D, updateLast2dMapType} = require('../actions/globeswitcher'); +const {MAP_TYPE_CHANGED} = require('../actions/maptype'); +const { mapTypeSelector } = require('../selectors/maptype'); +const {LOCAL_CONFIG_LOADED} = require('../actions/localConfig'); const Rx = require('rxjs'); const {get} = require('lodash'); -const defaultRegex = /\/(viewer)\/(\w+)\/(\w+)/; +const defaultRegexes = [/\/viewer\/\w+\/(\w+)/, /\/viewer\/(\w+)/]; const { push } = require('react-router-redux'); const replaceMapType = (path, newMapType) => { - let match = path.match(defaultRegex); + const match = defaultRegexes.reduce((previous, regex) => { + return previous || path.match(regex); + }, null); if (match) { - return `/viewer/${newMapType}/${match[3]}`; + return `/viewer/${newMapType}/${match[1]}`; } }; /** @@ -27,18 +32,23 @@ const replaceMapType = (path, newMapType) => { */ const updateRouteOn3dSwitch = (action$, store) => action$.ofType(TOGGLE_3D) - .switchMap( action => { - const newPath = replaceMapType(action.hash || location.hash, action.enable ? "cesium" : get(store.getState(), "globeswitcher.last2dMapType") || "leaflet"); + .switchMap( (action) => { + const newPath = replaceMapType(action.hash || location.hash, action.enable ? "cesium" : get(store.getState(), "globeswitcher.last2dMapType") || 'leaflet'); if (newPath) { - return Rx.Observable.from([push(newPath), updateLast2dMapType(action.originalMapType)]); + return Rx.Observable.from([push(newPath)]); } - Rx.Observable.of(updateLast2dMapType(action.mapType)); + Rx.Observable.empty(); }); +const updateLast2dMapTypeOnChangeEvents = (action$, store) => action$ + .ofType(LOCAL_CONFIG_LOADED).map(() => mapTypeSelector(store.getState())) + .merge(action$.ofType(MAP_TYPE_CHANGED, TOGGLE_3D).pluck('mapType').filter((mapType) => mapType && mapType !== "cesium")) + .switchMap(type => Rx.Observable.of(updateLast2dMapType(type))); /** * Epics for 3d switcher functionality * @name epics.globeswitcher * @type {Object} */ module.exports = { - updateRouteOn3dSwitch + updateRouteOn3dSwitch, + updateLast2dMapTypeOnChangeEvents }; diff --git a/web/client/epics/identify.js b/web/client/epics/identify.js index c6568c1004..6aca270c26 100644 --- a/web/client/epics/identify.js +++ b/web/client/epics/identify.js @@ -7,11 +7,22 @@ */ const Rx = require('rxjs'); -const {LOAD_FEATURE_INFO, GET_VECTOR_INFO, FEATURE_INFO_CLICK} = require('../actions/mapInfo'); +const {LOAD_FEATURE_INFO, ERROR_FEATURE_INFO, GET_VECTOR_INFO, FEATURE_INFO_CLICK, updateCenterToMarker} = require('../actions/mapInfo'); const {closeFeatureGrid} = require('../actions/featuregrid'); -const {CHANGE_MOUSE_POINTER, CLICK_ON_MAP} = require('../actions/map'); +const {CHANGE_MOUSE_POINTER, CLICK_ON_MAP, zoomToPoint} = require('../actions/map'); const {MAP_CONFIG_LOADED} = require('../actions/config'); const {stopGetFeatureInfoSelector} = require('../selectors/mapinfo'); +const {centerToMarkerSelector} = require('../selectors/layers'); +const {mapSelector} = require('../selectors/map'); +const {boundingMapRectSelector} = require('../selectors/maplayout'); +const {centerToVisibleArea, isInsideVisibleArea} = require('../utils/CoordinatesUtils'); +const {getCurrentResolution, parseLayoutValue} = require('../utils/MapUtils'); + +/** + * Epics for Identify and map info + * @name epics.identify + * @type {Object} + */ module.exports = { closeFeatureGridFromIdentifyEpic: (action$) => @@ -28,5 +39,36 @@ module.exports = { const {disableAlwaysOn = false} = (store.getState()).mapInfo; return disableAlwaysOn || !stopGetFeatureInfoSelector(store.getState() || {}); }) - .map(({point, layer}) => ({type: FEATURE_INFO_CLICK, point, layer})) + .map(({point, layer}) => ({type: FEATURE_INFO_CLICK, point, layer})), + /** + * Centers marker on visible map if it's hidden by layout + * @param {external:Observable} action$ manages `FEATURE_INFO_CLICK` and `LOAD_FEATURE_INFO`. + * @memberof epics.identify + * @return {external:Observable} + */ + zoomToVisibleAreaEpic: (action$, store) => + action$.ofType(FEATURE_INFO_CLICK) + .filter(() => centerToMarkerSelector(store.getState())) + .switchMap((action) => + action$.ofType(LOAD_FEATURE_INFO, ERROR_FEATURE_INFO) + .switchMap(() => { + const state = store.getState(); + const map = mapSelector(state); + const boundingMapRect = boundingMapRectSelector(state); + const coords = action.point && action.point && action.point.latlng; + const resolution = getCurrentResolution(Math.round(map.zoom), 0, 21, 96); + const layoutBounds = boundingMapRect && map && map.size && { + left: parseLayoutValue(boundingMapRect.left, map.size.width), + bottom: parseLayoutValue(boundingMapRect.bottom, map.size.height), + right: parseLayoutValue(boundingMapRect.right, map.size.width), + top: parseLayoutValue(boundingMapRect.top, map.size.height) + }; + // exclude cesium with cartographic options + if (!map || !layoutBounds || !coords || action.point.cartographic || isInsideVisibleArea(coords, map, layoutBounds, resolution)) { + return Rx.Observable.of(updateCenterToMarker('disabled')); + } + const center = centerToVisibleArea(coords, map, layoutBounds, resolution); + return Rx.Observable.of(updateCenterToMarker('enabled'), zoomToPoint(center.pos, center.zoom, center.crs)); + }) + ) }; diff --git a/web/client/epics/maplayout.js b/web/client/epics/maplayout.js index 41346ef78c..d51f4c4df5 100644 --- a/web/client/epics/maplayout.js +++ b/web/client/epics/maplayout.js @@ -10,7 +10,7 @@ const {updateMapLayout} = require('../actions/maplayout'); const {TOGGLE_CONTROL, SET_CONTROL_PROPERTY} = require('../actions/controls'); const {MAP_CONFIG_LOADED} = require('../actions/config'); const {SIZE_CHANGE, CLOSE_FEATURE_GRID, OPEN_FEATURE_GRID} = require('../actions/featuregrid'); -const {PURGE_MAPINFO_RESULTS} = require('../actions/mapInfo'); +const {PURGE_MAPINFO_RESULTS, ERROR_FEATURE_INFO} = require('../actions/mapInfo'); const {SHOW_SETTINGS, HIDE_SETTINGS} = require('../actions/layers'); const {mapInfoRequestsSelector} = require('../selectors/mapinfo'); @@ -32,19 +32,30 @@ const {isFeatureGridOpen, getDockSize} = require('../selectors/featuregrid'); */ const updateMapLayoutEpic = (action$, store) => - action$.ofType(MAP_CONFIG_LOADED, SIZE_CHANGE, CLOSE_FEATURE_GRID, OPEN_FEATURE_GRID, PURGE_MAPINFO_RESULTS, TOGGLE_CONTROL, SET_CONTROL_PROPERTY, SHOW_SETTINGS, HIDE_SETTINGS) + action$.ofType(MAP_CONFIG_LOADED, SIZE_CHANGE, CLOSE_FEATURE_GRID, OPEN_FEATURE_GRID, PURGE_MAPINFO_RESULTS, TOGGLE_CONTROL, SET_CONTROL_PROPERTY, SHOW_SETTINGS, HIDE_SETTINGS, ERROR_FEATURE_INFO) .switchMap(() => { if (get(store.getState(), "browser.mobile")) { - return Rx.Observable.empty(); + const bottom = mapInfoRequestsSelector(store.getState()).length > 0 ? {bottom: '50%'} : {bottom: undefined}; + const boundingMapRect = { + ...bottom + }; + return Rx.Observable.of(updateMapLayout({ + boundingMapRect + })); } const mapLayout = {left: {sm: 300, md: 500, lg: 600}, right: {md: 658}, bottom: {sm: 30}}; if (get(store.getState(), "mode") === 'embedded') { const height = {height: 'calc(100% - ' + mapLayout.bottom.sm + 'px)'}; + const bottom = mapInfoRequestsSelector(store.getState()).length > 0 ? {bottom: '50%'} : {bottom: undefined}; + const boundingMapRect = { + ...bottom + }; return Rx.Observable.of(updateMapLayout({ - ...height + ...height, + boundingMapRect })); } @@ -62,17 +73,25 @@ const updateMapLayoutEpic = (action$, store) => mapInfoRequestsSelector(store.getState()).length > 0 && {right: mapLayout.right.md} || null ].filter(panel => panel)) || {right: 0}; - const footer = isFeatureGridOpen(store.getState()) && {bottom: getDockSize(store.getState()) * 100 + '%'} || {bottom: mapLayout.bottom.sm}; + const dockSize = getDockSize(store.getState()) * 100; + const bottom = isFeatureGridOpen(store.getState()) && {bottom: dockSize + '%', dockSize} || {bottom: mapLayout.bottom.sm}; const transform = isFeatureGridOpen(store.getState()) && {transform: 'translate(0, -' + mapLayout.bottom.sm + 'px)'} || {transform: 'none'}; const height = {height: 'calc(100% - ' + mapLayout.bottom.sm + 'px)'}; + const boundingMapRect = { + ...bottom, + ...leftPanels, + ...rightPanels + }; + return Rx.Observable.of(updateMapLayout({ ...leftPanels, ...rightPanels, - ...footer, + ...bottom, ...transform, - ...height + ...height, + boundingMapRect })); }); diff --git a/web/client/epics/maps.js b/web/client/epics/maps.js index 85c1010e03..a76d4ea46d 100644 --- a/web/client/epics/maps.js +++ b/web/client/epics/maps.js @@ -11,7 +11,7 @@ const uuidv1 = require('uuid/v1'); const {basicError, basicSuccess} = require('../utils/NotificationUtils'); const GeoStoreApi = require('../api/GeoStoreDAO'); const { MAP_INFO_LOADED } = require('../actions/config'); -const {isNil} = require('lodash'); +const {isNil, find} = require('lodash'); const { SAVE_DETAILS, SAVE_RESOURCE_DETAILS, DELETE_MAP, OPEN_DETAILS_PANEL, @@ -255,17 +255,15 @@ const storeDetailsInfoEpic = (action$, store) => return !mapId ? Rx.Observable.empty() : Rx.Observable.fromPromise( - GeoStoreApi.getResourceAttribute(mapId, "details") - .then(res => res.data).catch(() => { - return null; - }) + GeoStoreApi.getResourceAttributes(mapId) ) - .switchMap((details) => { + .switchMap((attributes) => { + let details = find(attributes, {name: 'details'}); if (!details) { return Rx.Observable.empty(); } return Rx.Observable.of( - detailsLoaded(mapId, details) + detailsLoaded(mapId, details.value) ); }); }); diff --git a/web/client/epics/rulesmanager.js b/web/client/epics/rulesmanager.js new file mode 100644 index 0000000000..60a2d48aa2 --- /dev/null +++ b/web/client/epics/rulesmanager.js @@ -0,0 +1,37 @@ +const Rx = require("rxjs"); + +const {SAVE_RULE, setLoading, RULE_SAVED, DELETE_RULES, CACHE_CLEAN} = require("../actions/rulesmanager"); +const {error, success} = require("../actions/notifications"); +const {drawSupportReset} = require("../actions/draw"); +const {updateRule, createRule, deleteRule, cleanCache } = require("../observables/rulesmanager"); +// To do add Error management +const {get} = require("lodash"); +const saveRule = stream$ => stream$ + .mapTo({type: RULE_SAVED}) + .concat(Rx.Observable.of(drawSupportReset())) + .catch(({data}) => { + const isDuplicate = data.indexOf("Duplicat") === 0; + return Rx.Observable.of(error({title: "rulesmanager.errorTitle", message: isDuplicate ? "rulesmanager.errorDuplicateRule" : "rulesmanager.errorUpdatingRule"})); + }) + .startWith(setLoading(true)) + .concat(Rx.Observable.of(setLoading(false))); +module.exports = { + onSave: (action$, {getState}) => action$.ofType(SAVE_RULE) + .exhaustMap(({rule}) => + rule.id ? updateRule(rule, get(getState(), "rulesmanager.activeRule", {})).let(saveRule) : createRule(rule).let(saveRule) + ), + onDelete: (action$, {getState}) => action$.ofType(DELETE_RULES) + .switchMap(({ids = get(getState(), "rulesmanager.selectedRules", []).map(row => row.id)}) => { + return Rx.Observable.combineLatest(ids.map(id => deleteRule(id))).let(saveRule); + }), + onCacheClean: action$ => action$.ofType(CACHE_CLEAN) + .exhaustMap( () => + cleanCache() + .mapTo(success({title: "rulesmanager.errorTitle", message: "rulesmanager.cacheCleaned"})) + .startWith(setLoading(true)) + .catch(() => { + return Rx.Observable.of(error({title: "rulesmanager.errorTitle", message: "rulesmanager.errorCleaningCache"})); + }) + .concat(Rx.Observable.of(setLoading(false)))) +}; + diff --git a/web/client/epics/search.js b/web/client/epics/search.js index 62d20d7ec9..a5ba77d9cd 100644 --- a/web/client/epics/search.js +++ b/web/client/epics/search.js @@ -27,6 +27,7 @@ const {changeMapView} = require('../actions/map'); const toBbox = require('turf-bbox'); const {generateTemplateString} = require('../utils/TemplateUtils'); const assign = require('object-assign'); +const {updateResultsStyle} = require('../actions/search'); const {get} = require('lodash'); @@ -107,6 +108,7 @@ const searchItemSelected = action$ => // center by the max. extent defined in the map's config let newCenter = mapUtils.getCenterForExtent(bbox, "EPSG:4326"); let actions = [ + updateResultsStyle(action.resultsStyle || null), changeMapView(newCenter, newZoom, { bounds: { minx: bbox[0], diff --git a/web/client/epics/widgets.js b/web/client/epics/widgets.js index 767bf6e1e9..21d143e874 100644 --- a/web/client/epics/widgets.js +++ b/web/client/epics/widgets.js @@ -1,15 +1,27 @@ const Rx = require('rxjs'); -const {get} = require('lodash'); -const {EXPORT_CSV, EXPORT_IMAGE, clearWidgets} = require('../actions/widgets'); +const { get, isEqual, omit } = require('lodash'); +const { EXPORT_CSV, EXPORT_IMAGE, INSERT, TOGGLE_CONNECTION, WIDGET_SELECTED, EDITOR_SETTING_CHANGE, + onEditorChange, clearWidgets, loadDependencies, toggleDependencySelector, DEPENDENCY_SELECTOR_KEY, WIDGETS_REGEX} = require('../actions/widgets'); const { MAP_CONFIG_LOADED } = require('../actions/config'); +const { availableDependenciesSelector, isWidgetSelectionActive, getDependencySelectorConfig } = require('../selectors/widgets'); const { MAP_CREATED, SAVING_MAP, MAP_ERROR } = require('../actions/maps'); +const { DASHBOARD_LOADED } = require('../actions/dashboard'); const {LOCATION_CHANGE} = require('react-router-redux'); const {saveAs} = require('file-saver'); const FileUtils = require('../utils/FileUtils'); const converter = require('json-2-csv'); const canvg = require('canvg-browser'); +const updateDependencyMap = (active, id, { dependenciesMap, mappings}) => { + const overrides = Object.keys(mappings).filter(k => mappings[k] !== undefined).reduce( (ov, k) => ({ + ...ov, + [k]: id === "map" ? mappings[k] : `${id}.${mappings[k]}` + }), {}); + return active + ? { ...dependenciesMap, ...overrides} + : omit(dependenciesMap, [Object.keys(mappings)]); +}; const outerHTML = (node) => { const parent = document.createElement('div'); @@ -25,7 +37,28 @@ const getValidLocationChange = action$ => .startWith({type: MAP_CONFIG_LOADED}) // just dummy action to trigger the first switchMap .switchMap(action => action.type === SAVING_MAP ? Rx.Observable.never() : action$) .filter(({type} = {}) => type === LOCATION_CHANGE); - +/** + * Action flow to add/Removes dependencies for a widgets. + * Trigger `mapSync` property of a widget and sets `dependenciesMap` object to map `dependency` prop onto widget props. + * For instance if + * - `active = true` + * - `mappings` option is `{a: "b"} + * - `dependency = "x"` + * then you will have dependencyMap set to : {a: "x.b"}. + * It manages also special dependency "map" where mappings are applied directly (center...) . + * If active = false the dependencies will be removed from dependencyMap. + * + * @param {boolean} active true if the connection must be activated + * @param {string} dependency the dependency element id to add + * @param {object} options dependency mapping options. Must contain `mappings` object + */ +const configureDependency = (active, dependency, options) => + Rx.Observable.of( + onEditorChange("mapSync", active), + onEditorChange('dependenciesMap', + updateDependencyMap(active, dependency, options) + ) + ); module.exports = { exportWidgetData: action$ => action$.ofType(EXPORT_CSV) @@ -34,6 +67,59 @@ module.exports = { csv ], {type: "text/csv"}), title + ".csv"))) .filter( () => false), + /** + * Intercepts changes to widgets to catch widgets that can share some dependencies. + * Then re-configures the dependencies to it. + */ + alignDependenciesToWidgets: (action$, { getState = () => { } } = {}) => + action$.ofType(MAP_CONFIG_LOADED, DASHBOARD_LOADED, INSERT) + .map(() => availableDependenciesSelector(getState())) + .pluck('availableDependencies') + .distinctUntilChanged( (oldMaps = [], newMaps = []) => isEqual([...oldMaps], [...newMaps])) + // add dependencies for all map widgets (for the moment the only ones that shares dependencies) + // and for main "map" dependency, the "viewport" and "center" + .map((maps=[]) => loadDependencies(maps.reduce( (deps, m) => ({ + ...deps, + [m === "map" ? "viewport" : `${m}.viewport`]: `${m}.bbox`, // {viewport: "map.bbox"} or {"widgets[ID_W].viewport": "widgets[ID_W].bbox"} + [m === "map" ? "center" : `${m}.center`]: `${m}.center`, // {center: "map.center"} or {"widgets[ID_W].center": "widgets[ID_W].center"} + [m === "map" ? "zoom" : `${m}.zoom`]: `${m}.zoom`, + [m === "map" ? "layers" : `${m}.layers`]: `${m}.layers` + }), {})) + ), + /** + * Toggles the dependencies setup and widget selection for dependencies + * (if more than one widget is available for connection) + */ + toggleWidgetConnectFlow: (action$, {getState = () => {}} = {}) => + action$.ofType(TOGGLE_CONNECTION).switchMap(({ active, availableDependencies = [], options}) => + (active && availableDependencies.length > 0) + // activate flow + ? availableDependencies.length === 1 + // case singleMap + // In future may be necessary to pass active prop, if different from mapSync, in options object + // also if connection is triggered for a different target (widget not in editing) we should change actions to trigger (onChange instead of onEditorChange) + ? configureDependency(active, availableDependencies[0], options) + // case of multiple map + : Rx.Observable.of(toggleDependencySelector(active, { + availableDependencies + }) + ).merge( + action$.ofType(WIDGET_SELECTED) + .filter(() => isWidgetSelectionActive(getState())) + .switchMap(({ widget }) => { + const ad = get(getDependencySelectorConfig(getState()), 'availableDependencies'); + const deps = ad.filter(d => (WIDGETS_REGEX.exec(d) || [])[1] === widget.id); + return configureDependency(active, deps[0], options).concat(Rx.Observable.of(toggleDependencySelector(false, {}))); + }).takeUntil( + action$.ofType(LOCATION_CHANGE) + .merge(action$.filter(({ type, key } = {}) => type === EDITOR_SETTING_CHANGE && key === DEPENDENCY_SELECTOR_KEY)) + ) + ) + + // deactivate flow + : configureDependency(active, availableDependencies[0], options) + ), + clearWidgetsOnLocationChange: (action$, {getState = () => {}} = {}) => action$.ofType(MAP_CONFIG_LOADED).switchMap( () => { const location = get(getState(), "routing.location"); diff --git a/web/client/epics/widgetsbuilder.js b/web/client/epics/widgetsbuilder.js index 6919a39322..a2aae98e25 100644 --- a/web/client/epics/widgetsbuilder.js +++ b/web/client/epics/widgetsbuilder.js @@ -7,10 +7,11 @@ */ const Rx = require('rxjs'); const { - NEW, INSERT, EDIT, OPEN_FILTER_EDITOR, + NEW, INSERT, EDIT, OPEN_FILTER_EDITOR, NEW_CHART, editNewWidget, onEditorChange } = require('../actions/widgets'); +const {closeFeatureGrid} = require('../actions/featuregrid'); const { drawSupportReset @@ -22,6 +23,7 @@ const {LOCATION_CHANGE} = require('react-router-redux'); const {featureTypeSelected} = require('../actions/wfsquery'); const {getWidgetLayer, getEditingWidgetFilter} = require('../selectors/widgets'); +const {wfsFilter} = require('../selectors/query'); const {widgetBuilderAvailable} = require('../selectors/controls'); const getFTSelectedArgs = (state) => { let layer = getWidgetLayer(state); @@ -30,7 +32,7 @@ const getFTSelectedArgs = (state) => { return [url, typeName]; }; module.exports = { - openWidgetEditor: (action$, {getState = () => {}} = {}) => action$.ofType(NEW, EDIT) + openWidgetEditor: (action$, {getState = () => {}} = {}) => action$.ofType(NEW, EDIT, NEW_CHART) .filter(() => widgetBuilderAvailable(getState())) .switchMap(() => Rx.Observable.of( setControlProperty("widgetBuilder", "enabled", true), @@ -48,6 +50,17 @@ module.exports = { // override action's type type: undefined }, {step: 0}))), + initEditorOnNewChart: (action$, {getState = () => {}} = {}) => action$.ofType(NEW_CHART) + .filter(() => widgetBuilderAvailable(getState())) + .switchMap((w) => Rx.Observable.of(closeFeatureGrid(), editNewWidget({ + legend: false, + mapSync: true, + widgetType: "chart", + filter: wfsFilter(getState()), + ...w, + // override action's type + type: undefined + }, {step: 0}), onEditorChange("returnToFeatureGrid", true))), /** * Manages interaction with QueryPanel and widgetBuilder */ diff --git a/web/client/examples/3dviewer/containers/Viewer.jsx b/web/client/examples/3dviewer/containers/Viewer.jsx index 5460735277..befd2ff970 100644 --- a/web/client/examples/3dviewer/containers/Viewer.jsx +++ b/web/client/examples/3dviewer/containers/Viewer.jsx @@ -111,7 +111,7 @@ class Viewer extends React.Component { }}>