diff --git a/docs/developer-guide/local-config.md b/docs/developer-guide/local-config.md index 24d5f792c0..cf704a2ea6 100644 --- a/docs/developer-guide/local-config.md +++ b/docs/developer-guide/local-config.md @@ -180,6 +180,21 @@ Set `selectedService` value to one of the ID of the services object ("Demo CSW S "format": "image/png8", // image format needs to be configured also inside layerOptions "tileSize": 512 // determine the default tile size for the catalog, valid for WMS and CSW catalogs }, + "filter": { // applicable only for CSW service + "staticFilter": "filter is always applied, even when search text is NOT PRESENT", + "dynamicFilter": "filter is used when search text is PRESENT and is applied in `AND` with staticFilter. The template is used with ${searchText} placeholder to append search string" + } +} +``` +CSW service +
`filter` - For both static and dynamic filter, input only xml element contained within (i.e. Do not enclose the filter value in )
+
Example:
+```javascript +{ + "filter": { // Default filter values + "staticFilter": "dc:typedatasetdc:typehttp://purl.org/dc/dcmitype/Dataset", + "dynamicFilter": "csw:AnyText%${searchText}%" + } } ``` diff --git a/web/client/api/CSW.js b/web/client/api/CSW.js index e282e3454a..79d15e4a20 100644 --- a/web/client/api/CSW.js +++ b/web/client/api/CSW.js @@ -8,7 +8,7 @@ import urlUtil from 'url'; -import { head, last } from 'lodash'; +import { head, last, template } from 'lodash'; import assign from 'object-assign'; import axios from '../libs/ajax'; @@ -25,22 +25,8 @@ const parseUrl = (url) => { })); }; -export const constructXMLBody = (startPosition, maxRecords, searchText) => { - if (!searchText) { - return ` - - full - - - +const defaultStaticFilter = + ` dc:type dataset @@ -49,45 +35,50 @@ export const constructXMLBody = (startPosition, maxRecords, searchText) => { dc:type http://purl.org/dc/dcmitype/Dataset - - - - - `; - } - return ` - - full - - - - - csw:AnyText - %${searchText}% - - - - dc:type - dataset - - - dc:type - http://purl.org/dc/dcmitype/Dataset - - - - - - -`; + `; + +const defaultDynamicFilter = "" + + "csw:AnyText " + + "%${searchText}% " + + " "; + +export const cswGetRecordsXml = ' ' + + ' ' + + 'full ' + + ' ' + + ' ' + + '${filterXml} ' + + ' ' + + ' ' + + ' ' + + ''; + +/** + * Construct XML body to get records from the CSW service + * @param {object} [options] the options to pass to withIntersectionObserver enhancer. + * @param {number} startPosition + * @param {number} maxRecords + * @param {string} searchText + * @param {object} filter object holds static and dynamic filter configured for the CSW service + * @param {object} filter.staticFilter filter to fetch all record applied always i.e even when no search text is present + * @param {object} filter.dynamicFilter filter when search text is present and is applied in conjunction with static filter + * @return {string} constructed xml string + */ +export const constructXMLBody = (startPosition, maxRecords, searchText, {filter} = {}) => { + const staticFilter = filter?.staticFilter || defaultStaticFilter; + const dynamicFilter = ` + ${template(filter?.dynamicFilter || defaultDynamicFilter)({searchText})} + ${staticFilter} + `; + return template(cswGetRecordsXml)({filterXml: !searchText ? staticFilter : dynamicFilter, startPosition, maxRecords}); }; /** @@ -148,7 +139,7 @@ var Api = { }); }); }, - getRecords: function(url, startPosition, maxRecords, filter) { + getRecords: function(url, startPosition, maxRecords, filter, options) { return new Promise((resolve) => { require.ensure(['../utils/ogc/CSW', '../utils/ogc/Filter'], () => { const {CSW, marshaller, unmarshaller } = require('../utils/ogc/CSW'); @@ -157,7 +148,7 @@ var Api = { value: CSW.getRecords(startPosition, maxRecords, typeof filter !== "string" && filter) }); if (!filter || typeof filter === "string") { - body = constructXMLBody(startPosition, maxRecords, filter); + body = constructXMLBody(startPosition, maxRecords, filter, options); } resolve(axios.post(parseUrl(url), body, { headers: { 'Content-Type': 'application/xml' @@ -270,9 +261,9 @@ var Api = { }); }); }, - textSearch: function(url, startPosition, maxRecords, text) { + textSearch: function(url, startPosition, maxRecords, text, options) { return new Promise((resolve) => { - resolve(Api.getRecords(url, startPosition, maxRecords, text)); + resolve(Api.getRecords(url, startPosition, maxRecords, text, options)); }); }, workspaceSearch: function(url, startPosition, maxRecords, text, workspace) { diff --git a/web/client/api/__tests__/CSW-test.js b/web/client/api/__tests__/CSW-test.js index 25381a4273..7a454d18cb 100644 --- a/web/client/api/__tests__/CSW-test.js +++ b/web/client/api/__tests__/CSW-test.js @@ -156,4 +156,19 @@ describe("constructXMLBody", () => { const body2 = constructXMLBody(1, 5, null); expect(body2.indexOf("PropertyIsEqualTo")).toNotBe(-1); }); + it("construct body with custom filter", () => { + const filter = { + staticFilter: "dc:typedataset", + dynamicFilter: "csw:AnyText${searchText}*" + }; + // With search text + let body = constructXMLBody(1, 5, "text", {filter}); + + expect(body.indexOf("text*")).toNotBe(-1); // Dynamic filter + + // Empty search + body = constructXMLBody(1, 5, null); + expect(body.indexOf("dc:type")).toNotBe(-1); // Static filter + expect(body.indexOf("text*")).toBe(-1); // Dynamic filter + }); }); diff --git a/web/client/components/catalog/CompactCatalog.jsx b/web/client/components/catalog/CompactCatalog.jsx index 95d3435e24..ca3cdd4da8 100644 --- a/web/client/components/catalog/CompactCatalog.jsx +++ b/web/client/components/catalog/CompactCatalog.jsx @@ -74,10 +74,20 @@ const PAGE_SIZE = 10; /* * retrieves data from a catalog service and converts to props */ -const loadPage = ({text, catalog = {}}, page = 0) => Rx.Observable - .fromPromise(API[catalog.type].textSearch(catalog.url, page * PAGE_SIZE + (catalog.type === "csw" ? 1 : 0), PAGE_SIZE, text, catalog.type === 'tms' ? {options: {service: catalog}} : {})) - .map((result) => ({ result, records: getCatalogRecords(catalog.type, result || [], { url: catalog && catalog.url, service: catalog })})) - .map(({records, result}) => resToProps({records, result, catalog})); +const loadPage = ({text, catalog = {}}, page = 0) => { + const type = catalog.type; + const _tempOption = {options: {service: catalog}}; + let options = {}; + if (type === 'csw') { + options = {..._tempOption, filter: catalog.filter}; + } else if (type === 'tms') { + options = _tempOption; + } + return Rx.Observable + .fromPromise(API[type].textSearch(catalog.url, page * PAGE_SIZE + (type === "csw" ? 1 : 0), PAGE_SIZE, text, options)) + .map((result) => ({ result, records: getCatalogRecords(type, result || [], { url: catalog && catalog.url, service: catalog })})) + .map(({records, result}) => resToProps({records, result, catalog})); +}; const scrollSpyOptions = {querySelector: ".ms2-border-layout-body .ms2-border-layout-content", pageSize: PAGE_SIZE}; /** * Compat catalog : Reusable catalog component, with infinite scroll. diff --git a/web/client/components/catalog/editor/AdvancedSettings/CSWFilters.jsx b/web/client/components/catalog/editor/AdvancedSettings/CSWFilters.jsx new file mode 100644 index 0000000000..7aaf5de9d8 --- /dev/null +++ b/web/client/components/catalog/editor/AdvancedSettings/CSWFilters.jsx @@ -0,0 +1,129 @@ +import React, { useState, useEffect } from "react"; +import { + Col, + ControlLabel, + FormGroup, + Glyphicon, + Tooltip, + HelpBlock +} from "react-bootstrap"; +import { Controlled as CodeMirror } from "react-codemirror2"; +import "codemirror/lib/codemirror.css"; +import "codemirror/theme/material.css"; +import "codemirror/mode/xml/xml"; +import "codemirror/addon/lint/lint"; +import "codemirror/addon/display/autorefresh"; +import template from "lodash/template"; +import isEqual from "lodash/isEqual"; +import { cswGetRecordsXml } from "../../../../api/CSW"; +import OverlayTrigger from "../../../misc/OverlayTrigger"; +import Message from "../../../I18N/Message"; + +const options = { + mode: "xml", + theme: "material", + lineNumbers: true, + lineWrapping: true, + autoRefresh: true, + indentUnit: 2, + tabSize: 2 +}; +const FilterInfo = ({ glyph = "info-sign", className = "", tooltip }) => ( + + + +); + +const tooltip = _type => { + let msgId = `catalog.filter.${_type}.info`; + if (_type === "error") msgId = `catalog.filter.error`; + return ( + + + + ); +}; +const renderError = ( + +); +const renderHelpText = ( + + + +); + +const FilterCode = ({ type, code, setCode, error }) => { + const filterProp = `${type}Filter`; + return ( + + + + + + + {error[type] && renderError} + + + { + setCode({ ...code, [filterProp]: value }); + }} + /> + {type === 'dynamic' && renderHelpText} + + + ); +}; + +export default ({ + onChangeServiceProperty = () => {}, + filter: { staticFilter, dynamicFilter } = {} +} = {}) => { + const [error, setError] = useState({}); + const [code, setCode] = useState({ staticFilter, dynamicFilter }); + + const cmProps = { code, setCode, error }; + const isValid = value => { + const _filter = template(cswGetRecordsXml)({ + filterXml: value, + startPosition: 1, + maxRecords: 4 + }); + return !new DOMParser() + .parseFromString(_filter, "application/xml") + ?.getElementsByTagName("parsererror")?.length; + }; + + useEffect(() => { + const validFt = isValid(code.staticFilter); + validFt && + !isEqual(code.staticFilter, staticFilter) && + onChangeServiceProperty("filter.staticFilter", code.staticFilter); + setError({ ...error, "static": !validFt }); + }, [code.staticFilter]); + + useEffect(() => { + const validFt = isValid(code.dynamicFilter); + validFt && + !isEqual(code.dynamicFilter, dynamicFilter) && + onChangeServiceProperty("filter.dynamicFilter", code.dynamicFilter); + setError({ ...error, dynamic: !validFt }); + }, [code.dynamicFilter]); + + return ( +
+ + +
+ ); +}; diff --git a/web/client/components/catalog/editor/AdvancedSettings/CommonAdvancedSettings.jsx b/web/client/components/catalog/editor/AdvancedSettings/CommonAdvancedSettings.jsx index a13b1aa83a..18f9ee1041 100644 --- a/web/client/components/catalog/editor/AdvancedSettings/CommonAdvancedSettings.jsx +++ b/web/client/components/catalog/editor/AdvancedSettings/CommonAdvancedSettings.jsx @@ -12,6 +12,7 @@ import { FormGroup, Checkbox, Col } from "react-bootstrap"; import Message from "../../../I18N/Message"; import InfoPopover from '../../../widgets/widget/InfoPopover'; +import CSWFilters from "./CSWFilters"; /** * Common Advanced settings form, used by WMS/CSW/WMTS @@ -96,5 +97,8 @@ export default ({ )} {children} + {!isNil(service.type) && service.type === "csw" && + + } ); diff --git a/web/client/components/catalog/editor/AdvancedSettings/__tests__/CSWFilters-test.js b/web/client/components/catalog/editor/AdvancedSettings/__tests__/CSWFilters-test.js new file mode 100644 index 0000000000..555327f606 --- /dev/null +++ b/web/client/components/catalog/editor/AdvancedSettings/__tests__/CSWFilters-test.js @@ -0,0 +1,70 @@ +/** + * Copyright 2021, 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. + */ +import React from 'react'; + +import ReactDOM from 'react-dom'; +import expect from 'expect'; +import CSWFilters from '../CSWFilters'; + +describe('Test CSWFilters', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + it('creates the component with defaults', () => { + ReactDOM.render(, document.getElementById("container")); + const [catalog] = document.getElementsByClassName("catalog-csw-filters"); + expect(catalog).toBeTruthy(); + }); + it('render component with filter fields', () => { + const filterProp = {staticFilter: "

test

", dynamicFilter: "

Test

"}; + ReactDOM.render(, document.getElementById("container")); + const [catalog] = document.getElementsByClassName("catalog-csw-filters"); + expect(catalog).toBeTruthy(); + const filters = document.getElementsByClassName('react-codemirror2'); + expect(filters.length).toBe(2); + + const labels = document.getElementsByClassName('control-label'); + expect(labels.length).toBe(2); + expect(labels[0].textContent).toBe('catalog.filter.static.label'); + expect(labels[1].textContent).toBe('catalog.filter.dynamic.label'); + + const codes = document.getElementsByClassName('CodeMirror-line'); + expect(codes.length).toBe(2); + expect(codes[0].textContent).toBe(filterProp.staticFilter); + expect(codes[1].textContent).toBe(filterProp.dynamicFilter); + + }); + it('render component with invalid filter', () => { + const filterProp = {dynamicFilter: '

test

'}; + ReactDOM.render(, document.getElementById("container")); + const [catalog] = document.getElementsByClassName("catalog-csw-filters"); + expect(catalog).toBeTruthy(); + const filters = document.getElementsByClassName('react-codemirror2'); + expect(filters.length).toBe(2); + const error = document.querySelector('.glyphicon-exclamation-mark'); + expect(error).toBeTruthy(); + }); + it('test component onChangeServiceProperty', () => { + const action = { + onChangeServiceProperty: () => {} + }; + const spyOn = expect.spyOn(action, 'onChangeServiceProperty'); + ReactDOM.render(test'}}/>, document.getElementById("container")); + const [catalog] = document.getElementsByClassName("catalog-csw-filters"); + expect(catalog).toBeTruthy(); + const filters = document.getElementsByClassName('react-codemirror2'); + expect(filters.length).toBe(2); + expect(spyOn).toNotHaveBeenCalled(); + }); +}); diff --git a/web/client/epics/__tests__/catalog-test.js b/web/client/epics/__tests__/catalog-test.js index 6449fd2a83..9899225afb 100644 --- a/web/client/epics/__tests__/catalog-test.js +++ b/web/client/epics/__tests__/catalog-test.js @@ -68,6 +68,7 @@ describe('catalog Epics', () => { testEpic(autoSearchEpic, NUM_ACTIONS, changeText(""), (actions) => { expect(actions.length).toBe(NUM_ACTIONS); expect(actions[0].type).toBe(TEXT_SEARCH); + expect(actions[0].options).toEqual({filter: "test"}); done(); }, { catalog: { @@ -76,7 +77,8 @@ describe('catalog Epics', () => { services: { "cswCatalog": { type: "csw", - url: "url" + url: "url", + filter: "test" } }, pageSize: 2 diff --git a/web/client/epics/catalog.js b/web/client/epics/catalog.js index 3f59658685..516aeb2f2a 100644 --- a/web/client/epics/catalog.js +++ b/web/client/epics/catalog.js @@ -84,8 +84,9 @@ export default (API) => ({ recordSearchEpic: (action$, store) => action$.ofType(TEXT_SEARCH) .switchMap(({ format, url, startPosition, maxRecords, text, options }) => { + const filter = get(options, 'service.filter') || get(options, 'filter'); return Rx.Observable.defer(() => - API[format].textSearch(url, startPosition, maxRecords, text, { options, ...catalogSearchInfoSelector(store.getState()) }) + API[format].textSearch(url, startPosition, maxRecords, text, { options, filter, ...catalogSearchInfoSelector(store.getState()) }) ) .switchMap((result) => { if (result.error) { @@ -121,10 +122,10 @@ export default (API) => ({ const actions = layers .filter((l, i) => !!services[sources[i]]) // ignore wrong catalog name .map((l, i) => { - const { type: format, url } = services[sources[i]]; + const { type: format, url, ...service } = services[sources[i]]; const text = layers[i]; return Rx.Observable.defer(() => - API[format].textSearch(url, startPosition, maxRecords, text, addLayerOptions).catch(() => ({ results: [] })) + API[format].textSearch(url, startPosition, maxRecords, text, {...addLayerOptions, ...service}).catch(() => ({ results: [] })) ).map(r => ({ ...r, format, url, text })); }); return Rx.Observable.forkJoin(actions) @@ -377,8 +378,8 @@ export default (API) => ({ .switchMap(({ text }) => { const state = getState(); const pageSize = pageSizeSelector(state); - const { type, url } = selectedCatalogSelector(state); - return Rx.Observable.of(textSearch({ format: type, url, startPosition: 1, maxRecords: pageSize, text })); + const { type, url, filter } = selectedCatalogSelector(state); + return Rx.Observable.of(textSearch({ format: type, url, startPosition: 1, maxRecords: pageSize, text, options: {filter}})); }), catalogCloseEpic: (action$, store) => diff --git a/web/client/plugins/widgetbuilder/CatalogServiceEditor.jsx b/web/client/plugins/widgetbuilder/CatalogServiceEditor.jsx index 8c9c0c93c6..d8fe4c789e 100644 --- a/web/client/plugins/widgetbuilder/CatalogServiceEditor.jsx +++ b/web/client/plugins/widgetbuilder/CatalogServiceEditor.jsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { isEmpty } from 'lodash'; import uuid from 'uuid'; +import {set} from "../../utils/ImmutableUtils"; import CatalogServiceEditorComponent from '../../components/catalog/CatalogServiceEditor'; import { DEFAULT_ALLOWED_PROVIDERS } from '../MetadataExplorer'; @@ -48,7 +49,7 @@ export default ({service: defaultService, catalogServices, if (currentData[property]) { currentData[property] = typeof value === 'boolean' ? !(currentData[property]) : value; } else { - currentData = {...currentData, [property]: value}; + currentData = set(`${property}`, value, currentData); } setService(currentData); }; diff --git a/web/client/reducers/__tests__/catalog-test.js b/web/client/reducers/__tests__/catalog-test.js index b94ec0a21e..ac0da650cc 100644 --- a/web/client/reducers/__tests__/catalog-test.js +++ b/web/client/reducers/__tests__/catalog-test.js @@ -135,8 +135,12 @@ describe('Test the catalog reducer', () => { }); it('CHANGE_SERVICE_PROPERTY', () => { let autoload = true; - const state = catalog({newService: {}}, {type: CHANGE_SERVICE_PROPERTY, property: "autoload", value: true}); + let state = catalog({newService: {}}, {type: CHANGE_SERVICE_PROPERTY, property: "autoload", value: true}); expect(state.newService.autoload).toBe(autoload); + + // Path as property value + state = catalog({newService: {}}, {type: CHANGE_SERVICE_PROPERTY, property: "filter.staticFilter", value: "test"}); + expect(state.newService.filter.staticFilter).toBe("test"); }); it('SAVING_SERVICE', () => { let saving = true; diff --git a/web/client/reducers/catalog.js b/web/client/reducers/catalog.js index 7d159ecc1b..c7b5082730 100644 --- a/web/client/reducers/catalog.js +++ b/web/client/reducers/catalog.js @@ -127,7 +127,7 @@ function catalog(state = { case CHANGE_TEXT: return set("searchOptions.text", action.text, state); case CHANGE_SERVICE_PROPERTY: { - return set(`newService["${action.property}"]`, action.value, state); + return set(`newService.${action.property}`, action.value, state); } case CHANGE_TITLE: return set("newService.title", action.title, state); diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index ae1f370686..454863da98 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -1459,6 +1459,18 @@ "format": { "noOption": "Keine Option", "loading": "Wird geladen..." + }, + "filter": { + "static": { + "label": "Statischer Filter", + "info": "Filter wird immer angewendet, auch bei leerer Suche" + }, + "dynamic": { + "label": "Dynamischer Filter", + "info": "Filter wird verwendet, wenn Suchtext vorhanden ist und wird in 'UND' mit statischem Filter angewendet", + "helpText": "Verwenden Sie eine Vorlage mit dem Platzhalter ${searchText}, um die Suchzeichenfolge zu erfassen" + }, + "error": "Ungültige XML-Syntax" } }, "uploader": { diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index 2eb2903d1f..e4e5fdc58d 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -1421,6 +1421,18 @@ "format": { "noOption": "No option", "loading": "Loading..." + }, + "filter": { + "static": { + "label": "Static Filter", + "info": "Filter is applied always, even in empty search" + }, + "dynamic": { + "label": "Dynamic Filter", + "info": "Filter is used when search text is present and is applied in 'AND' with static filter", + "helpText": "Use template with ${searchText} placeholder to capture search string" + }, + "error": "Invalid xml syntax" } }, "uploader": { diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index bf20e98519..00f62d7612 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -1421,6 +1421,18 @@ "format": { "noOption": "Sin opcion", "loading": "Cargando..." + }, + "filter": { + "static": { + "label": "Filtro estático", + "info": "El filtro se aplica siempre, incluso en la búsqueda vacía" + }, + "dynamic": { + "label": "Filtro dinámico", + "info": "El filtro se usa cuando el texto de búsqueda está presente y se aplica en 'AND' con un filtro estático", + "helpText": "Utilice la plantilla con el marcador de posición $ {searchText} para capturar la cadena de búsqueda" + }, + "error": "Sintaxis XML no válida" } }, "uploader": { diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index 7459a2716c..62caf14fb9 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -1422,6 +1422,18 @@ "format": { "noOption": "Pas d'option", "loading": "Chargement..." + }, + "filter": { + "static": { + "label": "Filtre statique", + "info": "Le filtre est toujours appliqué, même dans une recherche vide" + }, + "dynamic": { + "label": "Filtre dynamique", + "info": "Le filtre est utilisé lorsque le texte de recherche est présent et est appliqué dans 'AND' avec un filtre statique", + "helpText": "Utilisez un modèle avec un espace réservé ${searchText} pour capturer la chaîne de recherche" + }, + "error": "Syntaxe xml non valide" } }, "uploader": { diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index f67b3a582e..14d37f852e 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -1422,6 +1422,18 @@ "format": { "noOption": "Nessuna opzione", "loading": "Caricamento in corso..." + }, + "filter": { + "static": { + "label": "Filtro statico", + "info": "Il filtro viene applicato sempre, anche nella ricerca vuota" + }, + "dynamic": { + "label": "Filtro dinamico", + "info": "Il filtro viene utilizzato quando è presente il testo di ricerca e viene applicato in 'AND' con filtro statico", + "helpText": "Usa il segnaposto ${searchText} nel modello per inserire la stringa di ricerca" + }, + "error": "Sintassi xml non valida" } }, "uploader": {