diff --git a/web/client/epics/__tests__/config-test.js b/web/client/epics/__tests__/config-test.js index b7d083476d..92c1fe121a 100644 --- a/web/client/epics/__tests__/config-test.js +++ b/web/client/epics/__tests__/config-test.js @@ -8,7 +8,7 @@ import expect from 'expect'; import {head} from 'lodash'; -import {loadMapConfigAndConfigureMap, loadMapInfoEpic} from '../config'; +import {loadMapConfigAndConfigureMap, loadMapInfoEpic, storeDetailsInfoEpic} from '../config'; import {LOAD_USER_SESSION} from '../../actions/usersession'; import { loadMapConfig, @@ -17,16 +17,19 @@ import { LOAD_MAP_INFO, MAP_INFO_LOADED, MAP_INFO_LOAD_START, - loadMapInfo + loadMapInfo, + mapInfoLoaded } from '../../actions/config'; -import { testEpic } from './epicTestUtils'; +import { TEST_TIMEOUT, addTimeoutEpic, testEpic } from './epicTestUtils'; import Persistence from '../../api/persistence'; import MockAdapter from 'axios-mock-adapter'; import axios from '../../libs/ajax'; import configBroken from "raw-loader!../../test-resources/testConfig.broken.json.txt"; import testConfigEPSG31468 from "raw-loader!../../test-resources/testConfigEPSG31468.json.txt"; import ConfigUtils from "../../utils/ConfigUtils"; +import { DETAILS_LOADED } from '../../actions/details'; +import { EMPTY_RESOURCE_VALUE } from '../../utils/MapInfoUtils'; const api = { getResource: () => Promise.resolve({mapId: 1234}) @@ -280,5 +283,101 @@ describe('config epics', () => { ); }); }); + describe("storeDetailsInfoEpic", () => { + beforeEach(done => { + mockAxios = new MockAdapter(axios); + setTimeout(done); + }); + + afterEach(done => { + mockAxios.restore(); + setTimeout(done); + }); + const mapId = 1; + const map = { + id: mapId, + name: "name" + }; + const mapAttributesEmptyDetails = { + "AttributeList": { + "Attribute": [ + { + "name": "details", + "type": "STRING", + "value": EMPTY_RESOURCE_VALUE + } + ] + } + }; + + const mapAttributesWithoutDetails = { + "AttributeList": { + "Attribute": [] + } + }; + + const mapAttributesWithDetails = { + AttributeList: { + Attribute: [ + { + name: 'details', + type: 'STRING', + value: 'rest\/geostore\/data\/1\/raw?decode=datauri' + }, + { + name: "thumbnail", + type: "STRING", + value: 'rest\/geostore\/data\/1\/raw?decode=datauri' + }, + { + name: 'owner', + type: 'STRING', + value: 'admin' + } + ] + } + }; + it('test storeDetailsInfoEpic', (done) => { + mockAxios.onGet().reply(200, mapAttributesWithDetails); + testEpic(addTimeoutEpic(storeDetailsInfoEpic), 1, mapInfoLoaded(map, mapId), actions => { + expect(actions.length).toBe(1); + actions.map((action) => { + + switch (action.type) { + case DETAILS_LOADED: + expect(action.mapId).toBe(mapId); + expect(action.detailsUri).toBe("rest/geostore/data/1/raw?decode=datauri"); + break; + default: + expect(true).toBe(false); + } + }); + done(); + }, {mapInitialConfig: { + "mapId": mapId + }}); + }); + it('test storeDetailsInfoEpic when api returns NODATA value', (done) => { + // const mock = new MockAdapter(axios); + mockAxios.onGet().reply(200, mapAttributesEmptyDetails); + testEpic(addTimeoutEpic(storeDetailsInfoEpic), 1, mapInfoLoaded(map, mapId), actions => { + expect(actions.length).toBe(1); + actions.map((action) => expect(action.type).toBe(TEST_TIMEOUT)); + done(); + }, {mapInitialConfig: { + "mapId": mapId + }}); + }); + it('test storeDetailsInfoEpic when api doesnt return details', (done) => { + mockAxios.onGet().reply(200, mapAttributesWithoutDetails); + testEpic(addTimeoutEpic(storeDetailsInfoEpic), 1, mapInfoLoaded(map, mapId), actions => { + expect(actions.length).toBe(1); + actions.map((action) => expect(action.type).toBe(TEST_TIMEOUT)); + done(); + }, {mapInitialConfig: { + "mapId": mapId + }}); + }); + }); }); diff --git a/web/client/epics/__tests__/details-test.js b/web/client/epics/__tests__/details-test.js index a0908642cb..7c6ff90a85 100644 --- a/web/client/epics/__tests__/details-test.js +++ b/web/client/epics/__tests__/details-test.js @@ -7,14 +7,11 @@ */ import expect from 'expect'; -import axios from '../../libs/ajax'; -import MockAdapter from 'axios-mock-adapter'; import configureMockStore from 'redux-mock-store'; import { createEpicMiddleware, combineEpics } from 'redux-observable'; import { closeDetailsPanelEpic, - storeDetailsInfoEpic, fetchDataForDetailsPanel } from '../details'; @@ -22,30 +19,22 @@ import { CLOSE_DETAILS_PANEL, closeDetailsPanel, openDetailsPanel, - UPDATE_DETAILS, - DETAILS_LOADED + UPDATE_DETAILS } from '../../actions/details'; -import { mapInfoLoaded } from '../../actions/config'; -import { testEpic, addTimeoutEpic, TEST_TIMEOUT } from './epicTestUtils'; +import { testEpic, addTimeoutEpic } from './epicTestUtils'; import ConfigUtils from '../../utils/ConfigUtils'; -import { EMPTY_RESOURCE_VALUE } from '../../utils/MapInfoUtils'; import { SHOW_NOTIFICATION } from '../../actions/notifications'; import { TOGGLE_CONTROL, SET_CONTROL_PROPERTY } from '../../actions/controls'; const baseUrl = "base/web/client/test-resources/geostore/"; const mapId = 1; -const mapId2 = 2; const detailsText = "

details of this map

"; const detailsUri = "data/2"; let map1 = { id: mapId, name: "name" }; -let map2 = { - id: mapId2, - name: "name2" -}; const testState = { mapInitialConfig: { mapId @@ -63,24 +52,6 @@ const rootEpic = combineEpics(closeDetailsPanelEpic); const epicMiddleware = createEpicMiddleware(rootEpic); const mockStore = configureMockStore([epicMiddleware]); -const mapAttributesEmptyDetails = { - "AttributeList": { - "Attribute": [ - { - "name": "details", - "type": "STRING", - "value": EMPTY_RESOURCE_VALUE - } - ] - } -}; - -const mapAttributesWithoutDetails = { - "AttributeList": { - "Attribute": [] - } -}; - describe('details epics tests', () => { const oldGetDefaults = ConfigUtils.getDefaults; let store; @@ -171,46 +142,4 @@ describe('details epics tests', () => { } }); }); - 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 - }}); - }); - it('test storeDetailsInfoEpic when api returns NODATA value', (done) => { - const mock = new MockAdapter(axios); - mock.onGet().reply(200, mapAttributesEmptyDetails); - testEpic(addTimeoutEpic(storeDetailsInfoEpic), 1, mapInfoLoaded(map2, mapId2), actions => { - expect(actions.length).toBe(1); - actions.map((action) => expect(action.type).toBe(TEST_TIMEOUT)); - mock.restore(); - done(); - }, {mapInitialConfig: { - "mapId": mapId2 - }}); - }); - it('test storeDetailsInfoEpic when api doesnt return details', (done) => { - const mock = new MockAdapter(axios); - mock.onGet().reply(200, mapAttributesWithoutDetails); - testEpic(addTimeoutEpic(storeDetailsInfoEpic), 1, mapInfoLoaded(map2, mapId2), actions => { - expect(actions.length).toBe(1); - actions.map((action) => expect(action.type).toBe(TEST_TIMEOUT)); - mock.restore(); - done(); - }, {mapInitialConfig: { - "mapId": mapId2 - }}); - }); }); diff --git a/web/client/epics/__tests__/maptemplates-test.js b/web/client/epics/__tests__/maptemplates-test.js index 22ee257c22..3c14b06780 100644 --- a/web/client/epics/__tests__/maptemplates-test.js +++ b/web/client/epics/__tests__/maptemplates-test.js @@ -13,7 +13,9 @@ import MockAdapter from 'axios-mock-adapter'; import { testEpic } from './epicTestUtils'; import { + mergeTemplateEpic, openMapTemplatesPanelEpic, + replaceTemplateEpic, setAllowedTemplatesEpic } from '../maptemplates'; @@ -21,12 +23,17 @@ import { openMapTemplatesPanel, setAllowedTemplates, SET_TEMPLATES, - SET_MAP_TEMPLATES_LOADED + SET_MAP_TEMPLATES_LOADED, + mergeTemplate, + SET_TEMPLATE_LOADING, + SET_TEMPLATE_DATA, + replaceTemplate } from '../../actions/maptemplates'; import { SET_CONTROL_PROPERTY } from '../../actions/controls'; +import { MAP_CONFIG_LOADED, MAP_INFO_LOADED } from '../../actions/config'; let mockAxios; @@ -123,4 +130,136 @@ describe('maptemplates epics', () => { maptemplates: {} }, done); }); + it('mergeTemplateEpic with map data', (done) => { + mockAxios.onGet().reply(200, { + map: {} + }); + testEpic(mergeTemplateEpic, 5, mergeTemplate("1"), actions => { + expect(actions.length).toBe(5); + expect(actions[0].type).toBe(SET_TEMPLATE_LOADING); + expect(actions[0].id).toBe("1"); + expect(actions[0].loadingValue).toBeTruthy(); + expect(actions[1].type).toBe(SET_TEMPLATE_DATA); + expect(actions[1].id).toBe("1"); + expect(actions[1].data).toBeTruthy(); + expect(actions[2].type).toBe(MAP_CONFIG_LOADED); + expect(actions[2].config).toBeTruthy(); + expect(actions[2].legacy).toBeTruthy(); + expect(actions[2].mapId).toBe("map1"); + expect(actions[3].type).toBe(MAP_INFO_LOADED); + expect(actions[3].info).toEqual({id: "map1"}); + expect(actions[3].mapId).toBe("map1"); + expect(actions[4].type).toBe(SET_TEMPLATE_LOADING); + expect(actions[4].id).toBe("1"); + expect(actions[4].loadingValue).toBeFalsy(); + }, { + map: { + present: { + zoom: 1, + info: { + id: "map1" + } + } + }, + layers: { + flat: [{ + id: "1", + visibility: true, + name: "test" + }], + groups: [{ + id: "Default", + name: "Default", + title: "Default", + nodes: ["1"] + }] + }, + maptemplates: {templates: [{id: "1"}]} + }, done); + }); + it('mergeTemplateEpic - set setTemplateData when no map data in response', (done) => { + mockAxios.onGet().reply(200, {}); + testEpic(mergeTemplateEpic, 3, mergeTemplate("1"), actions => { + expect(actions.length).toBe(3); + expect(actions[0].type).toBe(SET_TEMPLATE_LOADING); + expect(actions[0].id).toBe("1"); + expect(actions[0].loadingValue).toBeTruthy(); + expect(actions[1].type).toBe(SET_TEMPLATE_DATA); + expect(actions[1].id).toBe("1"); + expect(actions[1].data).toBeTruthy(); + expect(actions[2].type).toBe(SET_TEMPLATE_LOADING); + expect(actions[2].id).toBe("1"); + expect(actions[2].loadingValue).toBeFalsy(); + }, { + map: { + present: { + zoom: 1, + info: { + id: "map1" + } + } + }, + layers: { + flat: [{ + id: "1", + visibility: true, + name: "test" + }], + groups: [{ + id: "Default", + name: "Default", + title: "Default", + nodes: ["1"] + }] + }, + maptemplates: {templates: [{id: "1"}]} + }, done); + }); + it('replaceTemplateEpic', (done) => { + mockAxios.onGet().reply(200, { + map: {} + }); + testEpic(replaceTemplateEpic, 5, replaceTemplate("1"), actions => { + expect(actions.length).toBe(5); + expect(actions[0].type).toBe(SET_TEMPLATE_LOADING); + expect(actions[0].id).toBe("1"); + expect(actions[0].loadingValue).toBeTruthy(); + expect(actions[1].type).toBe(SET_TEMPLATE_DATA); + expect(actions[1].id).toBe("1"); + expect(actions[1].data).toBeTruthy(); + expect(actions[2].type).toBe(MAP_CONFIG_LOADED); + expect(actions[2].config).toBeTruthy(); + expect(actions[2].legacy).toBeTruthy(); + expect(actions[2].mapId).toBe("map1"); + expect(actions[3].type).toBe(MAP_INFO_LOADED); + expect(actions[3].info).toEqual({id: "map1"}); + expect(actions[3].mapId).toBe("map1"); + expect(actions[4].type).toBe(SET_TEMPLATE_LOADING); + expect(actions[4].id).toBe("1"); + expect(actions[4].loadingValue).toBeFalsy(); + }, { + map: { + present: { + zoom: 1, + info: { + id: "map1" + } + } + }, + layers: { + flat: [{ + id: "1", + visibility: true, + name: "test" + }], + groups: [{ + id: "Default", + name: "Default", + title: "Default", + nodes: ["1"] + }] + }, + maptemplates: {templates: [{id: "1"}]} + }, done); + }); }); diff --git a/web/client/epics/config.js b/web/client/epics/config.js index 380e478600..a9fb45ad8a 100644 --- a/web/client/epics/config.js +++ b/web/client/epics/config.js @@ -7,12 +7,13 @@ */ import { Observable } from 'rxjs'; import axios from '../libs/ajax'; -import { get, merge, isNaN } from 'lodash'; +import { get, merge, isNaN, find } from 'lodash'; import { LOAD_NEW_MAP, LOAD_MAP_CONFIG, MAP_CONFIG_LOADED, LOAD_MAP_INFO, + MAP_INFO_LOADED, configureMap, configureError, mapInfoLoadStart, @@ -23,12 +24,14 @@ import { } from '../actions/config'; import {zoomToExtent} from '../actions/map'; import Persistence from '../api/persistence'; +import GeoStoreApi from '../api/GeoStoreDAO'; import { isLoggedIn, userSelector } from '../selectors/security'; -import { projectionDefsSelector } from '../selectors/map'; +import { mapIdSelector, projectionDefsSelector } from '../selectors/map'; import {loadUserSession, USER_SESSION_LOADED, userSessionStartSaving, saveMapConfig} from '../actions/usersession'; +import { detailsLoaded, openDetailsPanel } from '../actions/details'; import {userSessionEnabledSelector, buildSessionName} from "../selectors/usersession"; import {getRequestParameterValue} from "../utils/QueryParamsUtils"; - +import { EMPTY_RESOURCE_VALUE } from '../utils/MapInfoUtils'; const prepareMapConfiguration = (data, override, state) => { const queryParamsMap = getRequestParameterValue('map', state); @@ -169,3 +172,43 @@ export const loadMapInfoEpic = action$ => .catch((e) => Observable.of(mapInfoLoadError(mapId, e))) .startWith(mapInfoLoadStart(mapId)) ); + +/** + * Incerpt MAP_INFO_LOADED and load detail resource linked to the map + * Epic is placed here to better intercept and load details info, + * when loading context with map that has a linked resource + * and to avoid race condition when loading plugins and map configuration + * @memberof epics.config + * @param {Observable} action$ stream of actions + * @param {object} store redux store + * @return {external:Observable} + */ +export const storeDetailsInfoEpic = (action$, store) => + action$.ofType(MAP_INFO_LOADED) + .switchMap(() => { + const mapId = mapIdSelector(store.getState()); + const isTutorialRunning = store.getState()?.tutorial?.run; + return !mapId + ? Observable.empty() + : Observable.fromPromise( + GeoStoreApi.getResourceAttributes(mapId) + ).switchMap((attributes) => { + let details = find(attributes, {name: 'details'}); + const detailsSettingsAttribute = find(attributes, {name: 'detailsSettings'}); + let detailsSettings = {}; + if (!details || details.value === EMPTY_RESOURCE_VALUE) { + return Observable.empty(); + } + + try { + detailsSettings = JSON.parse(detailsSettingsAttribute.value); + } catch (e) { + detailsSettings = {}; + } + + return Observable.of( + detailsLoaded(mapId, details.value, detailsSettings), + ...(detailsSettings.showAtStartup && !isTutorialRunning ? [openDetailsPanel()] : []) + ); + }); + }); diff --git a/web/client/epics/details.js b/web/client/epics/details.js index 26b4322883..2e9e246f12 100644 --- a/web/client/epics/details.js +++ b/web/client/epics/details.js @@ -7,27 +7,22 @@ */ import Rx from 'rxjs'; -import { find } from 'lodash'; import { OPEN_DETAILS_PANEL, CLOSE_DETAILS_PANEL, NO_DETAILS_AVAILABLE, updateDetails, - detailsLoaded, - openDetailsPanel, closeDetailsPanel } from '../actions/details'; -import { MAP_INFO_LOADED } from '../actions/config'; import { toggleControl, setControlProperty } from '../actions/controls'; import { - mapIdSelector, mapInfoDetailsUriFromIdSelector + mapInfoDetailsUriFromIdSelector } from '../selectors/map'; import GeoStoreApi from '../api/GeoStoreDAO'; -import { EMPTY_RESOURCE_VALUE } from '../utils/MapInfoUtils'; import { getIdFromUri } from '../utils/MapUtils'; import { basicError } from '../utils/NotificationUtils'; import { VISUALIZATION_MODE_CHANGED } from '../actions/maptype'; @@ -60,38 +55,6 @@ export const closeDetailsPanelEpic = (action$) => ]) ); -export const storeDetailsInfoEpic = (action$, store) => - action$.ofType(MAP_INFO_LOADED) - .switchMap(() => { - const mapId = mapIdSelector(store.getState()); - const isTutorialRunning = store.getState()?.tutorial?.run; - return !mapId ? - Rx.Observable.empty() : - Rx.Observable.fromPromise( - GeoStoreApi.getResourceAttributes(mapId) - ) - .switchMap((attributes) => { - let details = find(attributes, {name: 'details'}); - const detailsSettingsAttribute = find(attributes, {name: 'detailsSettings'}); - let detailsSettings = {}; - - if (!details || details.value === EMPTY_RESOURCE_VALUE) { - return Rx.Observable.empty(); - } - - try { - detailsSettings = JSON.parse(detailsSettingsAttribute.value); - } catch (e) { - detailsSettings = {}; - } - - return Rx.Observable.of( - detailsLoaded(mapId, details.value, detailsSettings), - ...(detailsSettings.showAtStartup && !isTutorialRunning ? [openDetailsPanel()] : []) - ); - }); - }); - export const closeDetailsPanelOn3DToggle = (action$) => action$.ofType(VISUALIZATION_MODE_CHANGED) .switchMap(() => { diff --git a/web/client/epics/maptemplates.js b/web/client/epics/maptemplates.js index 249e22fd86..30e1cec367 100644 --- a/web/client/epics/maptemplates.js +++ b/web/client/epics/maptemplates.js @@ -16,13 +16,13 @@ import { isLoggedIn } from '../selectors/security'; import { setTemplates, setMapTemplatesLoaded, setTemplateData, setTemplateLoading, CLEAR_MAP_TEMPLATES, OPEN_MAP_TEMPLATES_PANEL, MERGE_TEMPLATE, REPLACE_TEMPLATE, SET_ALLOWED_TEMPLATES } from '../actions/maptemplates'; import {templatesSelector, allTemplatesSelector, isActiveSelector} from '../selectors/maptemplates'; -import { mapSelector } from '../selectors/map'; +import { mapInfoSelector, mapSelector } from '../selectors/map'; import { layersSelector, groupsSelector } from '../selectors/layers'; import { backgroundListSelector } from '../selectors/backgroundselector'; import { textSearchConfigSelector, bookmarkSearchConfigSelector } from '../selectors/searchconfig'; import { mapOptionsToSaveSelector } from '../selectors/mapsave'; import {SET_CONTROL_PROPERTY, setControlProperty, TOGGLE_CONTROL} from '../actions/controls'; -import { configureMap } from '../actions/config'; +import { configureMap, mapInfoLoaded } from '../actions/config'; import { wrapStartStop } from '../observables/epics'; import { toMapConfig } from '../utils/ogc/WMC'; import {hideMapinfoMarker, purgeMapInfoResults} from "../actions/mapInfo"; @@ -99,6 +99,11 @@ export const mergeTemplateEpic = (action$, store) => action$ const templates = templatesSelector(state); const template = find(templates, t => t.id === id); + // Preserve original map id when "merging" map template + // to load linked resources related to the original resource(map) + const originalMapInfo = mapInfoSelector(state); + const mapId = originalMapInfo?.id ?? null; + return (template.dataLoaded ? Observable.of(template.data) : Observable.defer(() => Api.getData(id))).switchMap(data => { if (isObject(data) && data.map !== undefined || isString(data)) { @@ -115,7 +120,8 @@ export const mergeTemplateEpic = (action$, store) => action$ return (isString(data) ? Observable.defer(() => toMapConfig(data, false)) : Observable.of(data)) .switchMap(config => Observable.of( ...(!template.dataLoaded ? [setTemplateData(id, data)] : []), - configureMap(cloneDeep(omit(MapUtils.mergeMapConfigs(currentConfig, MapUtils.addRootParentGroup(config, template.name)), 'widgetsConfig')), null) + configureMap(cloneDeep(omit(MapUtils.mergeMapConfigs(currentConfig, MapUtils.addRootParentGroup(config, template.name)), 'widgetsConfig')), mapId), + ...(originalMapInfo ? [mapInfoLoaded({...originalMapInfo}, mapId)] : []) )); } @@ -143,6 +149,11 @@ export const replaceTemplateEpic = (action$, store) => action$ const template = find(templates, t => t.id === id); const {zoom, center} = mapSelector(state); + // Preserve original map id when "replacing" map template + // to load linked resources related to the original resource(map) + const originalMapInfo = mapInfoSelector(state); + const mapId = originalMapInfo?.id ?? null; + return (template.dataLoaded ? Observable.of(template.data) : Observable.defer(() => Api.getData(id))) .switchMap(data => (isString(data) ? Observable.defer(() => toMapConfig(data)) : @@ -159,8 +170,10 @@ export const replaceTemplateEpic = (action$, store) => action$ zoom: config.map.zoom || zoom, center: config.map.center || center } - }), null, !config.map.zoom && (config.map.bbox || config.map.maxExtent)) - ] : [])))) + }), mapId, !config.map.zoom && (config.map.bbox || config.map.maxExtent)) + ] : []), + ...(originalMapInfo ? [mapInfoLoaded({...originalMapInfo}, mapId)] : []) + ))) .let(wrapStartStop( setTemplateLoading(id, true), setTemplateLoading(id, false),