From c6e014a7c071c977e20b1385a25ad26de8256c73 Mon Sep 17 00:00:00 2001 From: Matteo V Date: Tue, 20 Aug 2019 12:30:23 +0200 Subject: [PATCH] Fix 4009 add_layers from catalog (#4017) * Fix 4009 add layers from catalog * update dev guide * removed unused thunk * added more comments * changed names of action creator and its constants * Update web/client/actions/catalog.js * changed layerSearch to textSearch * removed references in test resource * change name of the epic that searches records in catalog * fix failing test --- docs/developer-guide/map-query-parameters.md | 20 ++ web/client/actions/__tests__/catalog-test.js | 33 ++- web/client/actions/catalog.js | 77 ++++--- web/client/epics/__tests__/catalog-test.js | 189 +++++++++++++++++- web/client/epics/catalog.js | 161 +++++++++++++-- web/client/epics/queryparams.js | 4 +- .../csw/getRecordsResponse-gs-us_states.xml | 18 ++ 7 files changed, 450 insertions(+), 52 deletions(-) create mode 100644 web/client/test-resources/csw/getRecordsResponse-gs-us_states.xml diff --git a/docs/developer-guide/map-query-parameters.md b/docs/developer-guide/map-query-parameters.md index fd3e349e3b..08ced0e044 100644 --- a/docs/developer-guide/map-query-parameters.md +++ b/docs/developer-guide/map-query-parameters.md @@ -74,3 +74,23 @@ The MapStore invocation URL above executes the following operations: - Execution of a map zoom to the provided extent For more details check out the [searchLayerWithFilter](https://mapstore2.geo-solutions.it/mapstore/docs/#actions.search.exports.searchLayerWithFilter) in the framework documentation + + +#### Add Layers + +This action allows to add layers from catalog present in the map + +Requirements: +- the number of values must be even +- catalog name must be present in the map + + +Example: +``` +{ + "type": "CATALOG:ADD_LAYERS_FROM_CATALOGS", + "layers": ["layer1", "layer2"], + "sources": ["catalog1", "catalog2"] +} +?actions=[{"type":"CATALOG:ADD_LAYERS_FROM_CATALOGS","layers":["layer1", "layer2"],"sources":["catalog1", "catalog2"]}] +``` diff --git a/web/client/actions/__tests__/catalog-test.js b/web/client/actions/__tests__/catalog-test.js index b9abddb1bf..62e8ac1b1c 100644 --- a/web/client/actions/__tests__/catalog-test.js +++ b/web/client/actions/__tests__/catalog-test.js @@ -15,8 +15,9 @@ const service = { }; const expect = require('expect'); const LayersUtils = require('../../utils/LayersUtils'); -const {getRecords, addLayerError, addLayer, ADD_LAYER_ERROR, changeCatalogFormat, CHANGE_CATALOG_FORMAT, changeSelectedService, CHANGE_SELECTED_SERVICE, - focusServicesList, FOCUS_SERVICES_LIST, changeCatalogMode, CHANGE_CATALOG_MODE, changeTitle, CHANGE_TITLE, +const { + addLayersMapViewerUrl, ADD_LAYERS_FROM_CATALOGS, textSearch, TEXT_SEARCH, getRecords, addLayerError, addLayer, ADD_LAYER_ERROR, changeCatalogFormat, CHANGE_CATALOG_FORMAT, changeSelectedService, CHANGE_SELECTED_SERVICE, + focusServicesList, FOCUS_SERVICES_LIST, changeCatalogMode, CHANGE_CATALOG_MODE, changeTitle, CHANGE_TITLE, changeUrl, CHANGE_URL, changeType, CHANGE_TYPE, addService, ADD_SERVICE, addCatalogService, ADD_CATALOG_SERVICE, resetCatalog, RESET_CATALOG, changeAutoload, CHANGE_AUTOLOAD, deleteCatalogService, DELETE_CATALOG_SERVICE, deleteService, DELETE_SERVICE, savingService, SAVING_SERVICE, DESCRIBE_ERROR, initCatalog, CATALOG_INITED, changeText, CHANGE_TEXT, @@ -25,6 +26,34 @@ const {getRecords, addLayerError, addLayer, ADD_LAYER_ERROR, changeCatalogFormat const {CHANGE_LAYER_PROPERTIES, ADD_LAYER} = require('../layers'); describe('Test correctness of the catalog actions', () => { + it('addLayersMapViewerUrl', () => { + const layers = ["layer name"]; + const sources = ["catalog name"]; + const retval = addLayersMapViewerUrl(layers, sources); + + expect(retval).toExist(); + expect(retval.type).toBe(ADD_LAYERS_FROM_CATALOGS); + expect(retval.layers).toEqual(layers); + expect(retval.sources).toEqual(sources); + }); + it('textSearch', () => { + const format = "csw"; + const urlValue = "url"; + const startPosition = 1; + const maxRecords = 1; + const text = "text"; + const options = {}; + const retval = textSearch({format, url: urlValue, startPosition, maxRecords, text, options}); + + expect(retval).toExist(); + expect(retval.type).toBe(TEXT_SEARCH); + expect(retval.format).toBe(format); + expect(retval.url).toBe(urlValue); + expect(retval.startPosition).toBe(startPosition); + expect(retval.maxRecords).toBe(maxRecords); + expect(retval.text).toBe(text); + expect(retval.options).toEqual(options); + }); it('deleteCatalogService', () => { var retval = deleteCatalogService(service); diff --git a/web/client/actions/catalog.js b/web/client/actions/catalog.js index 2840ac0040..1a426c8f9a 100644 --- a/web/client/actions/catalog.js +++ b/web/client/actions/catalog.js @@ -4,21 +4,26 @@ * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. - */ +*/ +import csw from '../api/CSW'; +import wms from '../api/WMS'; +import wmts from '../api/WMTS'; var API = { - csw: require('../api/CSW'), - wms: require('../api/WMS'), - wmts: require('../api/WMTS') + csw, + wms, + wmts }; -const {addLayer: addNewLayer, changeLayerProperties} = require('./layers'); +import {addLayer as addNewLayer, changeLayerProperties} from './layers'; -const LayersUtils = require('../utils/LayersUtils'); -const ConfigUtils = require('../utils/ConfigUtils'); -const {find} = require('lodash'); -const {authkeyParamNameSelector} = require('../selectors/catalog'); +import * as LayersUtils from '../utils/LayersUtils'; +import * as ConfigUtils from '../utils/ConfigUtils'; +import {find} from 'lodash'; +import {authkeyParamNameSelector} from '../selectors/catalog'; +export const ADD_LAYERS_FROM_CATALOGS = 'CATALOG:ADD_LAYERS_FROM_CATALOGS'; +export const TEXT_SEARCH = 'CATALOG:TEXT_SEARCH'; export const RECORD_LIST_LOADED = 'CATALOG:RECORD_LIST_LOADED'; export const RESET_CATALOG = 'CATALOG:RESET_CATALOG'; export const RECORD_LIST_LOAD_ERROR = 'CATALOG:RECORD_LIST_LOAD_ERROR'; @@ -46,6 +51,39 @@ export const TOGGLE_TEMPLATE = 'CATALOG:TOGGLE_TEMPLATE'; export const TOGGLE_THUMBNAIL = 'CATALOG:TOGGLE_THUMBNAIL'; export const TOGGLE_ADVANCED_SETTINGS = 'CATALOG:TOGGLE_ADVANCED_SETTINGS'; +/** + * Adds a list of layers from the given catalogs to the map + * @param {string[]} layers list with workspace to be added in the map + * @param {string[]} sources catalog names related to each layer + */ +export function addLayersMapViewerUrl(layers = [], sources = []) { + return { + type: ADD_LAYERS_FROM_CATALOGS, + layers, + sources + }; +} +/** + * Searches for a layer in the related catalog + * @param {object} params + * @param {string} params.format format of the catalog + * @param {string} params.url catalog url + * @param {number} params.startPosition initial position to start search, default 1 + * @param {number} params.maxRecords max number of records returned + * @param {string} params.text layer name + * @param {object} params.options layer name + */ +export function textSearch({format, url, startPosition, maxRecords, text, options} = {}) { + return { + type: TEXT_SEARCH, + format, + url, + startPosition, + maxRecords, + text, + options + }; +} export function recordsLoaded(options, result) { return { type: RECORD_LIST_LOADED, @@ -191,26 +229,7 @@ export function getRecords(format, url, startPosition = 1, maxRecords, filter, o }); }; } -export function textSearch(format, url, startPosition, maxRecords, text, options) { - return (dispatch /* , getState */) => { - // TODO auth (like) let opts = GeoStoreApi.getAuthOptionsFromState(getState(), {params: {start: 0, limit: 20}, baseURL: geoStoreUrl }); - dispatch(setLoading(true)); - API[format].textSearch(url, startPosition, maxRecords, text, options).then((result) => { - if (result.error) { - dispatch(recordsLoadError(result)); - } else { - dispatch(recordsLoaded({ - url, - startPosition, - maxRecords, - text - }, result)); - } - }).catch((e) => { - dispatch(recordsLoadError(e)); - }); - }; -} + export function describeError(layer, error) { return { type: DESCRIBE_ERROR, diff --git a/web/client/epics/__tests__/catalog-test.js b/web/client/epics/__tests__/catalog-test.js index 5ae0bcd8b6..5a029d64c2 100644 --- a/web/client/epics/__tests__/catalog-test.js +++ b/web/client/epics/__tests__/catalog-test.js @@ -8,14 +8,34 @@ import expect from 'expect'; import csw from '../../api/CSW'; +import wmts from '../../api/WMTS'; const API = { - csw + csw, + wmts }; import catalog from '../catalog'; -const {getMetadataRecordById} = catalog(API); +const { + addLayersFromCatalogsEpic, + getMetadataRecordById, + autoSearchEpic, + openCatalogEpic, + recordSearchEpic +} = catalog(API); import {SHOW_NOTIFICATION} from '../../actions/notifications'; -import {testEpic} from './epicTestUtils'; -import {getMetadataRecordById as initAction} from '../../actions/catalog'; +import {CLOSE_FEATURE_GRID} from '../../actions/featuregrid'; +import {setControlProperty} from '../../actions/controls'; +import {ADD_LAYER} from '../../actions/layers'; +import {PURGE_MAPINFO_RESULTS, HIDE_MAPINFO_MARKER} from '../../actions/mapInfo'; +import {testEpic, addTimeoutEpic, TEST_TIMEOUT} from './epicTestUtils'; +import { + addLayersMapViewerUrl, + getMetadataRecordById as initAction, + changeText, + textSearch, TEXT_SEARCH, + RECORD_LIST_LOADED, + RECORD_LIST_LOAD_ERROR, + SET_LOADING +} from '../../actions/catalog'; describe('catalog Epics', () => { it('getMetadataRecordById', (done) => { @@ -34,7 +54,168 @@ describe('catalog Epics', () => { }] } }); + }); + + it('autoSearchEpic', (done) => { + const NUM_ACTIONS = 1; + testEpic(autoSearchEpic, NUM_ACTIONS, changeText(""), (actions) => { + expect(actions.length).toBe(NUM_ACTIONS); + expect(actions[0].type).toBe(TEXT_SEARCH); + done(); + }, { + catalog: { + delayAutoSearch: 50, + selectedService: "cswCatalog", + services: { + "cswCatalog": { + type: "csw", + url: "url" + } + }, + pageSize: 2 + }, + layers: { + selected: ["TEST"], + flat: [{ + id: "TEST", + catalogURL: "base/web/client/test-resources/csw/getRecordsResponseException.xml" + }] + } + }); + }); + it('openCatalogEpic', (done) => { + const NUM_ACTIONS = 3; + testEpic(openCatalogEpic, NUM_ACTIONS, setControlProperty("metadataexplorer", "enabled", true), (actions) => { + expect(actions.length).toBe(NUM_ACTIONS); + expect(actions[0].type).toBe(CLOSE_FEATURE_GRID); + expect(actions[1].type).toBe(PURGE_MAPINFO_RESULTS); + expect(actions[2].type).toBe(HIDE_MAPINFO_MARKER); + done(); + }, { }); + }); + it('recordSearchEpic with two layers', (done) => { + const NUM_ACTIONS = 2; + testEpic(addTimeoutEpic(recordSearchEpic), NUM_ACTIONS, textSearch({ + format: "csw", + url: "base/web/client/test-resources/csw/getRecordsResponseDC.xml", + startPosition: 1, + maxRecords: 1, + text: "a", + options: {} + }), (actions) => { + expect(actions.length).toBe(NUM_ACTIONS); + actions.map((action) => { + switch (action.type) { + case SET_LOADING: + expect(action.loading).toBe(true); + break; + case RECORD_LIST_LOADED: + expect(action.result.records.length).toBe(2); + break; + case TEST_TIMEOUT: + break; + default: + expect(true).toBe(false); + } + }); + done(); + }, { }); + }); + it('recordSearchEpic with exception', (done) => { + const NUM_ACTIONS = 2; + testEpic(addTimeoutEpic(recordSearchEpic), NUM_ACTIONS, textSearch({ + format: "csw", + url: "base/web/client/test-resources/csw/getRecordsResponseEsxception.xml", + startPosition: 1, + maxRecords: 1, + text: "a", + options: {} + }), (actions) => { + expect(actions.length).toBe(NUM_ACTIONS); + actions.map((action) => { + switch (action.type) { + case SET_LOADING: + expect(action.loading).toBe(true); + break; + case RECORD_LIST_LOAD_ERROR: + expect(action.error.status).toBe(404); + expect(action.error.statusText).toBe("Not Found"); + break; + case TEST_TIMEOUT: + break; + default: + expect(true).toBe(false); + } + }); + done(); + }, { }); }); + it('addLayersFromCatalogsEpic csw', (done) => { + const NUM_ACTIONS = 2; + testEpic(addTimeoutEpic(addLayersFromCatalogsEpic, 0), NUM_ACTIONS, addLayersMapViewerUrl(["gs:us_states"], ["cswCatalog"]), (actions) => { + expect(actions.length).toBe(NUM_ACTIONS); + actions.map((action) => { + switch (action.type) { + case ADD_LAYER: + expect(action.layer.name).toBe("gs:us_states"); + expect(action.layer.title).toBe("States of US"); + expect(action.layer.type).toBe("wms"); + expect(action.layer.url).toBe("https://sample.server/geoserver/wms"); + break; + case TEST_TIMEOUT: + break; + default: + expect(true).toBe(false); + } + }); + done(); + }, { + catalog: { + delayAutoSearch: 50, + selectedService: "cswCatalog", + services: { + "cswCatalog": { + type: "csw", + url: "base/web/client/test-resources/csw/getRecordsResponse-gs-us_states.xml" + } + }, + pageSize: 2 + } + }); + }); + it('addLayersFromCatalogsEpic wmts', (done) => { + const NUM_ACTIONS = 2; + testEpic(addTimeoutEpic(addLayersFromCatalogsEpic, 0), NUM_ACTIONS, addLayersMapViewerUrl(["topp:tasmania_cities_hidden"], ["cswCatalog"]), (actions) => { + expect(actions.length).toBe(NUM_ACTIONS); + actions.map((action) => { + switch (action.type) { + case ADD_LAYER: + expect(action.layer.name).toBe("topp:tasmania_cities_hidden"); + expect(action.layer.title).toBe("tasmania_cities"); + expect(action.layer.type).toBe("wmts"); + expect(action.layer.url).toBe("http://sample.server/geoserver/gwc/service/wmts"); + break; + case TEST_TIMEOUT: + break; + default: + expect(true).toBe(false); + } + }); + done(); + }, { + catalog: { + delayAutoSearch: 50, + selectedService: "cswCatalog", + services: { + "cswCatalog": { + type: "wmts", + url: "base/web/client/test-resources/wmts/GetCapabilities-1.0.0.xml" + } + }, + pageSize: 2 + } + }); + }); }); diff --git a/web/client/epics/catalog.js b/web/client/epics/catalog.js index e35523dec5..3bece0ad7b 100644 --- a/web/client/epics/catalog.js +++ b/web/client/epics/catalog.js @@ -6,26 +6,50 @@ * LICENSE file in the root directory of this source tree. */ -const Rx = require('rxjs'); -const { - ADD_SERVICE, GET_METADATA_RECORD_BY_ID, DELETE_SERVICE, deleteCatalogService, addCatalogService, savingService, - CHANGE_TEXT, textSearch -} = require('../actions/catalog'); -const {showLayerMetadata} = require('../actions/layers'); -const {error, success} = require('../actions/notifications'); -const {SET_CONTROL_PROPERTY} = require('../actions/controls'); -const {closeFeatureGrid} = require('../actions/featuregrid'); -const {purgeMapInfoResults, hideMapinfoMarker} = require('../actions/mapInfo'); -const { +import * as Rx from 'rxjs'; +import {head, isArray} from 'lodash'; +import { + ADD_SERVICE, + ADD_LAYERS_FROM_CATALOGS, + CHANGE_TEXT, + DELETE_SERVICE, + GET_METADATA_RECORD_BY_ID, + TEXT_SEARCH, + addCatalogService, + setLoading, + deleteCatalogService, + recordsLoaded, + recordsLoadError, + savingService, + textSearch +} from '../actions/catalog'; +import {showLayerMetadata, addLayer} from '../actions/layers'; +import {error, success} from '../actions/notifications'; +import {SET_CONTROL_PROPERTY} from '../actions/controls'; +import {closeFeatureGrid} from '../actions/featuregrid'; +import {purgeMapInfoResults, hideMapinfoMarker} from '../actions/mapInfo'; +import { + authkeyParamNameSelector, delayAutoSearchSelector, newServiceSelector, pageSizeSelector, selectedServiceSelector, servicesSelector, - selectedCatalogSelector -} = require('../selectors/catalog'); -const {getSelectedLayer} = require('../selectors/layers'); -const axios = require('../libs/ajax'); + selectedCatalogSelector, + searchOptionsSelector +} from '../selectors/catalog'; +import {currentMessagesSelector} from "../selectors/locale"; +import {getSelectedLayer} from '../selectors/layers'; +import axios from '../libs/ajax'; +import { + buildSRSMap, + esriToLayer, + extractEsriReferences, + extractOGCServicesReferences, + getCatalogRecords, + recordToLayer +} from '../utils/CatalogUtils'; +import CoordinatesUtils from '../utils/CoordinatesUtils'; /** * Epics for CATALOG @@ -34,6 +58,107 @@ const axios = require('../libs/ajax'); */ module.exports = (API) => ({ + /** + * search a layer given catalog service url, format, startPosition, maxRecords and text + * text is the name of the layer to search + * it also start with a loading action used to trigger loading state in catalog ui + */ + recordSearchEpic: action$ => + action$.ofType(TEXT_SEARCH) + .switchMap(({format, url, startPosition, maxRecords, text, options}) => { + return Rx.Observable.defer( () => + API[format].textSearch(url, startPosition, maxRecords, text, options) + ) + .switchMap((result) => { + if (result.error) { + return Rx.Observable.of(recordsLoadError(result)); + } + return Rx.Observable.of(recordsLoaded({ + url, + startPosition, + maxRecords, + text + }, result)); + }) + .startWith(setLoading(true)) + .catch((e) => { + return Rx.Observable.of(recordsLoadError(e)); + }); + }), + + /** + * layers specified in the mapviewer query params are added + * it will perform the getRecords requests to fetch records that are transformed into layer + * and added to the map + */ + addLayersFromCatalogsEpic: (action$, store) => + action$.ofType(ADD_LAYERS_FROM_CATALOGS) + .filter(({layers, sources}) => isArray(layers) && isArray(sources) && layers.length && layers.length === sources.length) + .switchMap(({layers, sources, options, startPosition = 1, maxRecords = 1 }) => { + const state = store.getState(); + const addLayerOptions = options || searchOptionsSelector(state); + const services = servicesSelector(state); + const actions = layers + .filter((l, i) => !!services[sources[i]]) // ignore wrong catalog name + .map((l, i) => { + const {type: format, url} = services[sources[i]]; + const text = layers[i]; + return Rx.Observable.defer( () => + API[format].textSearch(url, startPosition, maxRecords, text, addLayerOptions) + ).map(r => ({...r, format, url})); + }); + return Rx.Observable.forkJoin(actions) + .switchMap((results) => { + if (isArray(results) && results.length) { + return Rx.Observable.from(results.filter(r => { + // filter results with no records + const {format, url, ...result} = r; + const locales = currentMessagesSelector(state); + const records = getCatalogRecords(format, result, addLayerOptions, locales) || []; + return records && records.length >= 1; + }).map(r => { + const {format, url, ...result} = r; + const locales = currentMessagesSelector(state); + const records = getCatalogRecords(format, result, addLayerOptions, locales) || []; + const record = head(records); + const {wms, wmts} = extractOGCServicesReferences(record); + let layer = {}; + const layerBaseConfig = {}; // DO WE NEED TO FETCH IT FROM STATE??? + const authkeyParamName = authkeyParamNameSelector(state); + if (wms) { + const allowedSRS = buildSRSMap(wms.SRS); + if (wms.SRS.length > 0 && !CoordinatesUtils.isAllowedSRS("EPSG:3857", allowedSRS)) { + return Rx.Observable.empty(); // TODO CHANGE THIS + // onError('catalog.srs_not_allowed'); + } + layer = recordToLayer(record, "wms", { + removeParams: authkeyParamName, + catalogURL: format === 'csw' && url ? url + "?request=GetRecordById&service=CSW&version=2.0.2&elementSetName=full&id=" + record.identifier : null + }, layerBaseConfig); + } else if (wmts) { + layer = {}; + const allowedSRS = buildSRSMap(wmts.SRS); + if (wmts.SRS.length > 0 && !CoordinatesUtils.isAllowedSRS("EPSG:3857", allowedSRS)) { + return Rx.Observable.empty(); // TODO CHANGE THIS + // onError('catalog.srs_not_allowed'); + } + layer = recordToLayer(record, "wmts", { + removeParams: authkeyParamName + }, layerBaseConfig); + } else { + const {esri} = extractEsriReferences(record); + if (esri) { + layer = esriToLayer(record, layerBaseConfig); + } + } + return addLayer(layer); + })); + } + return Rx.Observable.empty(); + }); + }).catch( () => { + return Rx.Observable.empty(); + }), /** * Gets every `ADD_SERVICE` event. * It performs a head request in order to check if the server is up. (a better validation should be handled when research is performed). @@ -173,6 +298,10 @@ module.exports = (API) => ({ }), showLayerMetadata({}, false)); }); }), + /** + * it trigger search automatically after a delay, default is 1s + * it uses layersSearch in favor of + */ autoSearchEpic: (action$, {getState = () => {}} = {}) => action$.ofType(CHANGE_TEXT) .debounce(() => { @@ -184,6 +313,6 @@ module.exports = (API) => ({ const state = getState(); const pageSize = pageSizeSelector(state); const {type, url} = selectedCatalogSelector(state); - return Rx.Observable.of(textSearch(type, url, 1, pageSize, text)); + return Rx.Observable.of(textSearch({format: type, url, startPosition: 1, maxRecords: pageSize, text})); }) }); diff --git a/web/client/epics/queryparams.js b/web/client/epics/queryparams.js index afb34b3dc7..0563eb585d 100644 --- a/web/client/epics/queryparams.js +++ b/web/client/epics/queryparams.js @@ -12,6 +12,7 @@ import { get, head, isNaN, isString, includes } from 'lodash'; import url from 'url'; import { CHANGE_MAP_VIEW, zoomToExtent, ZOOM_TO_EXTENT } from '../actions/map'; +import { ADD_LAYERS_FROM_CATALOGS } from '../actions/catalog'; import { SEARCH_LAYER_WITH_FILTER } from '../actions/search'; import { warning } from '../actions/notifications'; @@ -46,7 +47,8 @@ const paramActions = { actions: ({value = ''}) => { const whiteList = (getConfigProp("initialActionsWhiteList") || []).concat([ SEARCH_LAYER_WITH_FILTER, - ZOOM_TO_EXTENT + ZOOM_TO_EXTENT, + ADD_LAYERS_FROM_CATALOGS ]); if (isString(value)) { const actions = JSON.parse(value); diff --git a/web/client/test-resources/csw/getRecordsResponse-gs-us_states.xml b/web/client/test-resources/csw/getRecordsResponse-gs-us_states.xml new file mode 100644 index 0000000000..3c22db5907 --- /dev/null +++ b/web/client/test-resources/csw/getRecordsResponse-gs-us_states.xml @@ -0,0 +1,18 @@ + + + + + gs:us_states + GeoServer Catalog + features + states + https://sample.server/geoserver/wms?service=WMS&request=GetMap&layers=gs:us_states + States of US + http://purl.org/dc/dcmitype/Dataset + + 24.955967 -124.73142200000001 + 49.371735 -66.969849 + + + +