diff --git a/docs/developer-guide/maps-configuration.md b/docs/developer-guide/maps-configuration.md index 184798b86d3..3c0fa11d19f 100644 --- a/docs/developer-guide/maps-configuration.md +++ b/docs/developer-guide/maps-configuration.md @@ -244,7 +244,7 @@ Details: - `format`: the format of the WMS requests to use - `params`: an object with additional parameters to add to the WMS request - `layerFilter`: an object to filter the layer. See [LayerFilter](LayerFilter.md) for details. -- `search`: an object to configure the search features service. It is used to link a WFS service, typically with this shape: `{url: 'http://some.wfs.service', type: 'wfs'}`. +- `search`: an object to configure the search features service. It is used to link a WFS service, typically with this shape: `{url: 'http://some.wfs.service', type: 'wfs', typeName: 'workspace:featureType'}`. The optional `typeName` identifies the linked WFS feature type; when omitted, MapStore uses the WMS layer `name` for backward compatibility. - `fields`: if the layer has a wfs service configured, this can contain the fields (attributes) of the features, with custom configuration (e.g. aliases, types, etc.). See [Fields](#fields) for details. - `credits`: includes the information to show in attribution.(`imageUrl`, `link`, `title`). - `singleTile`: By default, WMS is invoked using tiled requests. If you want to use a single tile request, you can set this property to `true`. diff --git a/web/client/actions/__tests__/layers-test.js b/web/client/actions/__tests__/layers-test.js index 792567f032e..cc1c8e5f0e1 100644 --- a/web/client/actions/__tests__/layers-test.js +++ b/web/client/actions/__tests__/layers-test.js @@ -60,9 +60,12 @@ import { showLayerMetadata, hideLayerMetadata, updateSettingsParams, + layerNameChangeError, addGroup } from '../layers'; +import { SHOW_NOTIFICATION } from '../notifications'; + import { getLayerCapabilities } from '../layerCapabilities'; describe('Test correctness of the layers actions', () => { @@ -330,6 +333,16 @@ describe('Test correctness of the layers actions', () => { expect(action.update).toBe(update); }); + it('creates the layer name change error notification', () => { + const action = layerNameChangeError(); + expect(action.type).toBe(SHOW_NOTIFICATION); + expect(action.level).toBe('error'); + expect(action.title).toBe('layerNameChangeError.title'); + expect(action.message).toBe('layerNameChangeError.message'); + expect(action.autoDismiss).toBe(5); + expect(action.position).toBe('tc'); + }); + it('add root group', () => { const action = addGroup('newgroup'); expect(action.type).toBe(ADD_GROUP); diff --git a/web/client/actions/layerCapabilities.js b/web/client/actions/layerCapabilities.js index 513c3f30a81..f90bf660e1c 100644 --- a/web/client/actions/layerCapabilities.js +++ b/web/client/actions/layerCapabilities.js @@ -12,7 +12,7 @@ import WMS from '../api/WMS'; import { getLayerOptions } from '../utils/WMSUtils'; import * as WFS from '../api/WFS'; import WCS from '../api/WCS'; -import {getCapabilitiesUrl} from '../utils/LayersUtils'; +import {getCapabilitiesUrl, getSearchUrl, getWFSLayerName} from '../utils/LayersUtils'; import { get } from 'lodash'; import { extractGeometryType } from '../utils/WFSLayerUtils'; @@ -20,7 +20,16 @@ export function getDescribeLayer(url, layer, options) { return (dispatch /* , getState */) => { return WMS.describeLayer(url, layer.name, options).then((describeLayer) => { if (describeLayer && describeLayer.owsType === "WFS") { - return WFS.describeFeatureType(url, describeLayer.name) + const wfsLayer = { + ...layer, + search: { + type: 'wfs', + url: describeLayer.owsURL || url, + ...(describeLayer.query?.[0]?.typeName && {typeName: describeLayer.query[0].typeName}), + ...(layer.search || {}) + } + }; + return WFS.describeFeatureType(getSearchUrl(wfsLayer), getWFSLayerName(wfsLayer)) .then( (describeFeatureType) => { describeLayer.geometryType = extractGeometryType(describeFeatureType); return dispatch(updateNode(layer.id, "id", { describeLayer, describeFeatureType })); diff --git a/web/client/actions/layers.js b/web/client/actions/layers.js index cfd382ee874..56cc8ee8faa 100644 --- a/web/client/actions/layers.js +++ b/web/client/actions/layers.js @@ -6,6 +6,8 @@ * LICENSE file in the root directory of this source tree. */ +import { error as errorNotification } from './notifications'; + export const CHANGE_LAYER_PROPERTIES = 'CHANGE_LAYER_PROPERTIES'; export const CHANGE_LAYER_PARAMS = 'LAYERS:CHANGE_LAYER_PARAMS'; export const CHANGE_GROUP_PROPERTIES = 'CHANGE_GROUP_PROPERTIES'; @@ -314,3 +316,12 @@ export function updateSettingsParams(newParams, update) { update }; } + +export function layerNameChangeError() { + return errorNotification({ + title: 'layerNameChangeError.title', + message: 'layerNameChangeError.message', + autoDismiss: 5, + position: 'tc' + }); +} diff --git a/web/client/api/WFS.js b/web/client/api/WFS.js index a7606dd6619..f7f0b82b1c2 100644 --- a/web/client/api/WFS.js +++ b/web/client/api/WFS.js @@ -14,6 +14,7 @@ import {toOGCFilterParts} from '../utils/FilterUtils'; import { getDefaultUrl } from '../utils/URLUtils'; import { castArray } from 'lodash'; import { isValidGetFeatureInfoFormat } from '../utils/WMSUtils'; +import { getSearchUrl, getWFSLayerName } from '../utils/LayersUtils'; const capabilitiesCache = {}; @@ -89,7 +90,9 @@ export const getFeatureURL = (url, typeName, { version = "1.1.0", ...params } = * @param {object} config axios request config (headers, etc...) */ export const getFeatureLayer = (layer, {version = "1.1.0", filters, proj, outputFormat = 'application/json', resultType = 'results'} = {}, config) => { - const {url, name: typeName, params } = layer; + const {params } = layer; + const url = layer.type === 'wms' ? getSearchUrl(layer) : layer.url; + const typeName = getWFSLayerName(layer); const {layerFilter, filterObj: featureGridFilter} = layer; // TODO: add const {getFeature: wfsGetFeature, query, filter, and} = requestBuilder({wfsVersion: version}); const allFilters = [] @@ -175,4 +178,3 @@ export const getSupportedFormat = (url) => { }; }); }; - diff --git a/web/client/api/WMS.js b/web/client/api/WMS.js index 3eb114ffeed..c77e0b3ed07 100644 --- a/web/client/api/WMS.js +++ b/web/client/api/WMS.js @@ -225,13 +225,16 @@ export const describeLayers = (url, layers, security) => { }); descriptions = Array.isArray(descriptions) ? descriptions : [descriptions]; // make it compatible with json format of describe layer - return descriptions.map(desc => ({ - ...(desc && desc.$ || {}), - layerName: desc && desc.$ && desc.$.name, - query: { - ...(desc && desc.query && desc.query.$ || {}) - } - })); + return descriptions.map(desc => { + const query = castArray(desc?.Query || desc?.query || [])[0]; + return { + ...(desc && desc.$ || {}), + layerName: desc && desc.$ && desc.$.name, + query: { + ...(query?.$ || {}) + } + }; + }); }); }; export const textSearch = (url, startPosition, maxRecords, text, options) => { diff --git a/web/client/api/__tests__/WFS-test.js b/web/client/api/__tests__/WFS-test.js index 7a85a26ee31..c9226814f60 100644 --- a/web/client/api/__tests__/WFS-test.js +++ b/web/client/api/__tests__/WFS-test.js @@ -48,7 +48,11 @@ describe('Test WFS ogc API functions', () => { getFeatureLayer({ type: 'wfs', url: 'test', - name: 'layer1' + name: 'layer1', + search: { + url: 'linked-wfs', + typeName: 'linked-layer' + } }, {}, { headers: { 'Authentication': 'Basic token' @@ -58,6 +62,23 @@ describe('Test WFS ogc API functions', () => { done(); }); }); + it('getFeatureLayer uses the linked WFS URL and type name', (done) => { + mockAxios.onPost().reply(({ url, data }) => { + expect(url).toBe('linked-wfs'); + expect(data).toContain('typeName="workspace:linked"'); + return [200, {type: 'FeatureCollection', features: []}]; + }); + getFeatureLayer({ + type: 'wms', + url: 'wms-url', + name: 'workspace:rendered', + search: { + type: 'wfs', + url: 'linked-wfs', + typeName: 'workspace:linked' + } + }).then(() => done()).catch(done); + }); it('getFeatureLayer with layerFilter', (done) => { mockAxios.onPost().reply(({ url, data }) => { expect(url).toContain('test'); diff --git a/web/client/api/__tests__/WMS-test.js b/web/client/api/__tests__/WMS-test.js index d5ece7bf164..aa63d00bdcc 100644 --- a/web/client/api/__tests__/WMS-test.js +++ b/web/client/api/__tests__/WMS-test.js @@ -25,6 +25,7 @@ describe('Test correctness of the WMS APIs', () => { expect(result).toBeTruthy(); expect(result.length).toBe(2); expect(result[0].owsType).toBe("WFS"); + expect(result[0].query.typeName).toBe("workspace:vector_layer"); done(); } catch (ex) { done(ex); diff --git a/web/client/api/catalog/WMS.js b/web/client/api/catalog/WMS.js index 0e67dba3716..d7d33bb9ef5 100644 --- a/web/client/api/catalog/WMS.js +++ b/web/client/api/catalog/WMS.js @@ -87,8 +87,8 @@ const recordToLayer = (record, { const format = supportedGetMapFormats?.find((value) => value === defaultFormat) || supportedGetMapFormats[0] || defaultFormat; - const { featureInfo: serviceFeatureInfo, ...serviceLayerOptions } = layerOptions || {}; - const { featureInfo: recordFeatureInfo, ...recordLayerOptions } = record.layerOptions || {}; + const { featureInfo: serviceFeatureInfo, search: serviceSearch, ...serviceLayerOptions } = layerOptions || {}; + const { featureInfo: recordFeatureInfo, search: recordSearch, ...recordLayerOptions } = record.layerOptions || {}; const computedFeatureInfo = infoFormat && INFO_FORMATS_BY_MIME_TYPE[infoFormat] ? { format: INFO_FORMATS_BY_MIME_TYPE[infoFormat] } : {}; @@ -97,6 +97,11 @@ const recordToLayer = (record, { ...(serviceFeatureInfo || {}), ...(recordFeatureInfo || {}) }; + const search = { + ...(layerBaseConfig?.search || {}), + ...(serviceSearch || {}), + ...(recordSearch || {}) + }; let security; if (service?.protectedId) { security = {sourceId: service?.protectedId, type: "basic"}; @@ -133,6 +138,7 @@ const recordToLayer = (record, { ...layerBaseConfig, ...serviceLayerOptions, ...recordLayerOptions, + ...(!isEmpty(search) && { search }), localizedLayerStyles: !isNil(localizedLayerStyles) ? localizedLayerStyles : undefined, imageFormats: supportedGetMapFormats, infoFormats: supportedGetFeatureInfoFormats, diff --git a/web/client/api/catalog/__tests__/WMS-test.js b/web/client/api/catalog/__tests__/WMS-test.js index 2bcd997a3e5..d99be4be6f0 100644 --- a/web/client/api/catalog/__tests__/WMS-test.js +++ b/web/client/api/catalog/__tests__/WMS-test.js @@ -99,6 +99,32 @@ describe('Test correctness of the WMS APIs', () => { expect(layer.tileSize).toBe(512); expect(layer.serverType).toBe("no-vendor"); }); + it('merges nested search layer options by precedence', () => { + const records = getCatalogRecords({ + records: [{}], + layerOptions: { + search: {url: 'record-url', recordValue: true} + } + }, { + url: 'http://sample' + }); + const layer = getLayerFromRecord(records[0], { + layerBaseConfig: { + search: {url: 'base-url', baseValue: true} + }, + service: { + layerOptions: { + search: {url: 'service-url', serviceValue: true} + } + } + }); + expect(layer.search).toEqual({ + url: 'record-url', + baseValue: true, + serviceValue: true, + recordValue: true + }); + }); it('wms feature info layer options preserve catalog info format', () => { const records = getCatalogRecords({ diff --git a/web/client/components/TOC/fragments/LayerFields/index.jsx b/web/client/components/TOC/fragments/LayerFields/index.jsx index 50519356fb2..25078814a1b 100644 --- a/web/client/components/TOC/fragments/LayerFields/index.jsx +++ b/web/client/components/TOC/fragments/LayerFields/index.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import Fields from './Fields'; import { describeFeatureType } from '../../../../observables/wfs'; +import { getWFSLayerName } from '../../../../utils/LayersUtils'; /** * Utility function to check if the node allows to show fields tab @@ -19,15 +20,10 @@ export const hasFields = ({type, search = {}} = {}) => * @returns {Promise} a promise that resolves with the new fields */ export const loadFields = (layer, merge = true) => { - const {fields = [], type, search = {}, name} = layer; - const typeName = search.typeName ?? name; + const {fields = [], type, search = {}} = layer; + const typeName = getWFSLayerName(layer); if (type === 'wfs' || (type === 'wms' && search.type === 'wfs')) { - return describeFeatureType({ - layer: { - ...layer, - name: typeName - } - }).toPromise().then((response) => { + return describeFeatureType({ layer }).toPromise().then((response) => { const { featureTypes = [] } = response?.data ?? {}; const localTypeName = `${typeName || ''}`.split(':').pop(); const featureType = typeName diff --git a/web/client/components/TOC/fragments/settings/EditableTextField.jsx b/web/client/components/TOC/fragments/settings/EditableTextField.jsx new file mode 100644 index 00000000000..942fc6e56b5 --- /dev/null +++ b/web/client/components/TOC/fragments/settings/EditableTextField.jsx @@ -0,0 +1,110 @@ +/* + * Copyright 2026, 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 PropTypes from 'prop-types'; +import React, { useEffect, useState } from 'react'; +import { ControlLabel, FormControl, FormGroup, Glyphicon, InputGroup } from 'react-bootstrap'; +import Spinner from 'react-spinkit'; + +import Message from '../../../I18N/Message'; + +/** + * Text field that requires an explicit confirmation before updating its value. + */ +const EditableTextField = ({ + dataQa, + labelId, + value = '', + onChange = () => {}, + onValidate, + required = false, + formatValue = (currentValue) => currentValue ?? '', + parseValue = (currentValue) => currentValue +}) => { + const formattedValue = formatValue(value); + const [editing, setEditing] = useState(false); + const [currentValue, setCurrentValue] = useState(formattedValue); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + + useEffect(() => { + if (!editing) { + setCurrentValue(formattedValue); + } + }, [formattedValue, editing]); + + const confirm = () => { + const parsedValue = parseValue(currentValue); + const isEmpty = Array.isArray(parsedValue) + ? !parsedValue.length || parsedValue.some((entry) => !entry?.trim()) + : !parsedValue?.trim?.(); + if (required && isEmpty) { + setError(true); + return; + } + if (currentValue === formattedValue) { + setEditing(false); + setError(false); + return; + } + setLoading(true); + setError(false); + Promise.resolve() + .then(() => onValidate?.(parsedValue)) + .then((validationResult) => { + onChange(parsedValue, validationResult); + setEditing(false); + }) + .catch(() => setError(true)) + .then(() => setLoading(false)); + }; + + return ( + + + + setCurrentValue(event.target.value)} /> + { + if (!loading) { + if (editing) { + confirm(); + } else { + setError(false); + setEditing(true); + } + } + }}> + {loading + ? + : } + + + + ); +}; + +EditableTextField.propTypes = { + dataQa: PropTypes.string.isRequired, + labelId: PropTypes.string.isRequired, + value: PropTypes.any, + onChange: PropTypes.func, + onValidate: PropTypes.func, + required: PropTypes.bool, + formatValue: PropTypes.func, + parseValue: PropTypes.func +}; + +export default EditableTextField; diff --git a/web/client/components/TOC/fragments/settings/ExternalDataEditor.jsx b/web/client/components/TOC/fragments/settings/ExternalDataEditor.jsx index 051e914f507..10e539b05ff 100644 --- a/web/client/components/TOC/fragments/settings/ExternalDataEditor.jsx +++ b/web/client/components/TOC/fragments/settings/ExternalDataEditor.jsx @@ -18,6 +18,7 @@ import Spinner from '../../../layout/Spinner'; import Fields from '../LayerFields/Fields'; import { getCapabilities, getFeature } from '../../../../api/WFS'; import { describeFeatureType } from '../../../../observables/wfs'; +import { getWFSLayerName } from '../../../../utils/LayersUtils'; import { isGeometryType } from '../../../../utils/ogc/WFS/base'; import { interpolateExternalDataCQL, @@ -234,7 +235,7 @@ const ExternalDataEditor = ({ value = {}, onChange = () => {}, sourceLayer, curr || currentSourceLayer.describeFeatureTypeURL || currentSourceLayer.url; const url = Array.isArray(sourceUrl) ? sourceUrl[0] : sourceUrl; - const layerName = currentSourceLayer.search?.name || currentSourceLayer.name; + const layerName = currentSourceLayer.search?.name || getWFSLayerName(currentSourceLayer); return { url, layerName }; }; diff --git a/web/client/components/TOC/fragments/settings/General.jsx b/web/client/components/TOC/fragments/settings/General.jsx index 23c800ab290..58543a467a2 100644 --- a/web/client/components/TOC/fragments/settings/General.jsx +++ b/web/client/components/TOC/fragments/settings/General.jsx @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { find, includes, isNil, isObject, uniqBy } from 'lodash'; +import { castArray, find, includes, isNil, isObject, uniqBy } from 'lodash'; import PropTypes from 'prop-types'; import React from 'react'; import { Checkbox, Col, ControlLabel, FormControl, FormGroup, Grid } from 'react-bootstrap'; @@ -16,6 +16,8 @@ import Select from 'react-select'; import Spinner from 'react-spinkit'; import Message from '../../../I18N/Message'; +import SwitchPanel from '../../../misc/switch/SwitchPanel'; +import EditableTextField from './EditableTextField'; import LayerNameEditField from './LayerNameEditField'; import { getMessageById } from '../../../../utils/LocaleUtils'; import { @@ -26,7 +28,13 @@ import { supportsFeatureEditing } from "../../../../utils/FeatureGridUtils"; import { DEFAULT_GROUP_ID, flattenGroups, getTitle as _getTitle } from '../../../../utils/LayersUtils'; import { getFeatureLayerSchema } from '../../../../api/ArcGIS'; import { loadFields } from '../LayerFields'; +import { addSearch, getLayerCapabilities as getWMSLayerCapabilities } from '../../../../observables/wms'; +const formatURL = (url) => Array.isArray(url) ? url.join(', ') : url || ''; +const parseURL = (url) => { + const urls = url.split(',').map((value) => value.trim()); + return urls.length > 1 ? urls : urls[0]; +}; const mergeArcGISFields = (fields = [], previousFields = []) => fields.map((field) => { const previousField = previousFields.find(({name}) => name === field.name); return { @@ -49,6 +57,7 @@ class General extends React.Component { showTooltipOptions: PropTypes.bool, allowNew: PropTypes.bool, enableLayerNameEditFeedback: PropTypes.bool, + onLayerNameValidationError: PropTypes.func, currentLocale: PropTypes.string, showFeatureEditOption: PropTypes.bool }; @@ -107,6 +116,71 @@ class General extends React.Component { return Promise.resolve(); }; + validateLayerURL = (url) => { + const nextLayer = { ...this.props.element, url }; + if (nextLayer.type === 'wfs') { + return loadFields({ + ...nextLayer, + describeFeatureTypeURL: undefined, + search: nextLayer.search && { + ...nextLayer.search, + url: undefined + } + }, true); + } + return Promise.all(castArray(url).map((currentUrl) => + getWMSLayerCapabilities({ ...nextLayer, url: currentUrl }) + .toPromise() + .then((layerCapability) => { + if (!layerCapability) { + throw new Error('Layer not found in WMS capabilities'); + } + return layerCapability; + }) + )); + }; + + validateLinkedWFS = (search) => { + const typeName = search.typeName ?? this.props.element.name; + if (!search.url?.trim() || !typeName?.trim()) { + return Promise.reject(new Error('WFS URL and typeName are required')); + } + return loadFields({ + ...this.props.element, + describeFeatureTypeURL: undefined, + search: { + ...search, + typeName + } + }, true); + }; + + updateWFSPanel = (enabled) => { + if (!enabled) { + this.props.onChange('search', undefined); + return; + } + const emptySearch = { type: 'wfs', url: '', typeName: '' }; + addSearch(this.props.element, { detectedSearchOverrides: true }) + .toPromise() + .then(({ search }) => { + const detectedSearch = { + ...search, + type: 'wfs', + url: search?.url || '', + typeName: search?.typeName || '' + }; + if (!detectedSearch.url || !detectedSearch.typeName) { + this.props.onChange('search', detectedSearch); + return; + } + this.validateLinkedWFS(detectedSearch) + .then((fields) => this.props.onChange({ search: detectedSearch, fields })) + .catch(() => this.props.onChange('search', detectedSearch)); + }) + .catch(() => this.props.onChange('search', emptySearch)); + }; + render() { const { hideTitleTranslations = false } = this.props.pluginCfg; @@ -144,7 +218,21 @@ class General extends React.Component { element={this.props.element} enableLayerNameEditFeedback={this.props.enableLayerNameEditFeedback} onValidate={this.getLayerNameValidator()} + onValidationError={this.props.onLayerNameValidationError} onUpdateEntry={this.updateLayerName}/>} + {includes(this.supportedURLEditLayerTypes, this.props.element.type) && + this.props.onChange({ + url, + ...(this.props.element.type === 'wfs' && { fields }) + })} />} {this.props.element.capabilitiesLoading ? : @@ -224,6 +312,43 @@ class General extends React.Component { } + {this.props.element.type === 'wms' && } + onSwitch={this.updateWFSPanel}> + this.validateLinkedWFS({ + ...this.props.element.search, + url + })} + onChange={(url, fields) => this.props.onChange({ + search: { + ...this.props.element.search, + url + }, + fields + })} /> + this.validateLinkedWFS({ + ...this.props.element.search, + typeName + })} + onChange={(typeName, fields) => this.props.onChange({ + search: { + ...this.props.element.search, + typeName + }, + fields + })} /> + } @@ -231,6 +356,7 @@ class General extends React.Component { } supportedNameEditLayerTypes = ['wms', 'wfs', 'arcgis', 'arcgis-feature']; + supportedURLEditLayerTypes = ['wms', 'wfs']; updateEntry = (key, event) => isObject(key) ? this.props.onChange(key) : this.props.onChange(key, event.target.value); updateLayerName = (key, event, properties) => this.props.onChange({ diff --git a/web/client/components/TOC/fragments/settings/LayerNameEditField.jsx b/web/client/components/TOC/fragments/settings/LayerNameEditField.jsx index da903748ae8..27bd6789f87 100644 --- a/web/client/components/TOC/fragments/settings/LayerNameEditField.jsx +++ b/web/client/components/TOC/fragments/settings/LayerNameEditField.jsx @@ -28,6 +28,7 @@ const LayerNameEditField = ({ setEditingLayerName = () => {}, setLayerError = () => {}, onValidate, + onValidationError = () => {}, onUpdateEntry = () => {} }) => { const editButton = ( @@ -52,10 +53,11 @@ const LayerNameEditField = ({ Promise.resolve() .then(() => onValidate(layerName)) .then(updateLayerName) - .catch(() => { + .catch((error) => { setWaitingForLayerLoading(false); setLayerError(true); setEditingLayerName(true); + onValidationError(error); }); } else { updateLayerName(); diff --git a/web/client/components/TOC/fragments/settings/PropertiesEditor.jsx b/web/client/components/TOC/fragments/settings/PropertiesEditor.jsx index c5ac20299f1..8341be96e76 100644 --- a/web/client/components/TOC/fragments/settings/PropertiesEditor.jsx +++ b/web/client/components/TOC/fragments/settings/PropertiesEditor.jsx @@ -16,6 +16,7 @@ import Fields from '../LayerFields/Fields'; import { describeFeatureType } from '../../../../observables/wfs'; import { isGeometryType } from '../../../../utils/ogc/WFS/base'; import { notPrimaryGeometryFields } from '../../../../utils/FeatureTypeUtils'; +import { getWFSLayerName } from '../../../../utils/LayersUtils'; const EMPTY_FIELDS = []; const GEOMETRY_FIELD_TYPES = new Set(['Geometry', ...Object.values(notPrimaryGeometryFields)]); @@ -53,9 +54,7 @@ const PropertiesEditor = ({ sourceLayer = {}, value = [], onChange = () => {}, c const attributes = getAttributes(schemaFields, value); const loadAttributes = (merge) => { - const layerName = sourceLayer.search?.name - || sourceLayer.search?.typeName - || sourceLayer.name; + const layerName = sourceLayer.search?.name || getWFSLayerName(sourceLayer); const sourceUrl = sourceLayer.describeFeatureTypeURL || sourceLayer.search?.url || sourceLayer.url; @@ -68,7 +67,11 @@ const PropertiesEditor = ({ sourceLayer = {}, value = [], onChange = () => {}, c describeFeatureType({ layer: { ...sourceLayer, - name: layerName + name: layerName, + search: { + ...sourceLayer.search, + typeName: layerName + } } }).toPromise() .then(({ data }) => isMounted(() => { diff --git a/web/client/components/TOC/fragments/settings/__tests__/General-test.jsx b/web/client/components/TOC/fragments/settings/__tests__/General-test.jsx index 5effbf9736e..70166f36e99 100644 --- a/web/client/components/TOC/fragments/settings/__tests__/General-test.jsx +++ b/web/client/components/TOC/fragments/settings/__tests__/General-test.jsx @@ -16,6 +16,23 @@ import { waitFor } from '@testing-library/react'; import General from '../General'; import axios from '../../../../../libs/ajax'; +const WMS_CAPABILITIES = ` + + + image/png + layer00Layer + +`; + +const WFS_PROPERTIES = [ + {name: 'shared', localType: 'string'}, + {name: 'new-field', localType: 'number'} +]; +const WFS_DESCRIBE = { + featureTypes: ['workspace:linked', 'workspace:layer', 'workspace:renamed'] + .map((typeName) => ({typeName, properties: WFS_PROPERTIES})) +}; + const editLayerName = (name) => { const getInput = () => document.querySelector('[data-qa="layer-properties-name"]'); const getEditButton = () => getInput().parentElement.querySelector('.input-group-addon'); @@ -30,15 +47,19 @@ const editLayerName = (name) => { }); }; +let mockAxios; + describe('test Layer Properties General module component', () => { beforeEach((done) => { document.body.innerHTML = '
'; + mockAxios = new AxiosMockAdapter(axios); setTimeout(done); }); afterEach((done) => { ReactDOM.unmountComponentAtNode(document.getElementById("container")); document.body.innerHTML = ''; + mockAxios.restore(); setTimeout(done); }); @@ -104,7 +125,6 @@ describe('test Layer Properties General module component', () => { expect(document.querySelector('[data-qa="layer-properties-name"]')).toBeFalsy(); }); it('refreshes and merges fields when changing a WFS layer name', (done) => { - const mockAxios = new AxiosMockAdapter(axios); mockAxios.onGet().reply((config) => { expect(decodeURIComponent(config.url)).toContain('typeName=topp:new'); return [200, { @@ -138,16 +158,13 @@ describe('test Layer Properties General module component', () => { {name: 'added', type: 'number'} ] }]); - mockAxios.restore(); done(); }) .catch((error) => { - mockAxios.restore(); done(error); }); }); it('refreshes linked WFS fields when its type name follows the WMS layer name', (done) => { - const mockAxios = new AxiosMockAdapter(axios); mockAxios.onGet().reply((config) => { expect(decodeURIComponent(config.url)).toContain('typeName=topp:new'); return [200, { @@ -175,11 +192,9 @@ describe('test Layer Properties General module component', () => { name: 'topp:new', fields: [{name: 'newField', type: 'string'}] }]); - mockAxios.restore(); done(); }) .catch((error) => { - mockAxios.restore(); done(error); }); }); @@ -199,7 +214,6 @@ describe('test Layer Properties General module component', () => { expect(spy.calls[0].arguments).toEqual([{name: 'topp:new'}]); }); it('refreshes ArcGIS FeatureServer schema when changing the layer name', (done) => { - const mockAxios = new AxiosMockAdapter(axios); mockAxios.onGet('/arcgis/rest/services/SchemaRefresh/FeatureServer/1').reply(200, { geometryType: 'esriGeometryPoint', fields: [ @@ -233,11 +247,9 @@ describe('test Layer Properties General module component', () => { properties: {kept: '', added: 0}, geometryType: 'Point' }]); - mockAxios.restore(); done(); }) .catch((error) => { - mockAxios.restore(); done(error); }); }); @@ -439,4 +451,252 @@ describe('test Layer Properties General module component', () => { const disableFeaturesEditing = document.querySelector('[data-qa="general-read-only-attribute"]'); expect(disableFeaturesEditing).toBeFalsy(); }); + it('validates and edits WMS multi URLs using the catalog comma convention', (done) => { + const onChange = expect.createSpy(); + mockAxios.onGet().reply(200, WMS_CAPABILITIES); + ReactDOM.render(, document.getElementById("container")); + const input = document.querySelector('[data-qa="layer-properties-url"]'); + const edit = document.querySelector('[data-qa="layer-properties-url-edit"]'); + expect(input.value).toBe('url-1, url-2'); + ReactTestUtils.Simulate.click(edit); + ReactTestUtils.Simulate.change(input, {target: {value: 'url-3, url-4'}}); + ReactTestUtils.Simulate.click(edit); + setTimeout(() => { + expect(onChange).toHaveBeenCalledWith({url: ['url-3', 'url-4']}); + expect(mockAxios.history.get.length).toBe(2); + done(); + }); + }); + it('edits native WFS name and URL without adding a linked TypeName editor', () => { + ReactDOM.render(, document.getElementById("container")); + expect(document.querySelector('[data-qa="layer-properties-name"]')).toExist(); + expect(document.querySelector('[data-qa="layer-properties-url"]')).toExist(); + expect(document.querySelector('[data-qa="layer-properties-search-type-name"]')).toNotExist(); + }); + it('validates a native WFS URL and refreshes its fields', (done) => { + mockAxios.onGet().reply((config) => { + expect(config.url).toContain('new-wfs-url'); + expect(config.url).toNotContain('old-describe-url'); + expect(config.url).toNotContain('old-search-url'); + return [200, WFS_DESCRIBE]; + }); + const onChange = expect.createSpy(); + ReactDOM.render(, document.getElementById("container")); + const input = document.querySelector('[data-qa="layer-properties-url"]'); + const edit = document.querySelector('[data-qa="layer-properties-url-edit"]'); + ReactTestUtils.Simulate.click(edit); + ReactTestUtils.Simulate.change(input, {target: {value: 'new-wfs-url'}}); + ReactTestUtils.Simulate.click(edit); + setTimeout(() => { + expect(onChange).toHaveBeenCalledWith({ + url: 'new-wfs-url', + fields: [ + {name: 'shared', type: 'string', alias: 'Customized'}, + {name: 'new-field', type: 'number'} + ] + }); + done(); + }); + }); + it('detects and removes a linked WFS service', (done) => { + mockAxios.onGet().reply(({url}) => url.includes('DescribeLayer') + ? [200, { + layerDescriptions: [{ + owsURL: 'detected-wfs-url', + typeName: 'workspace:linked' + }] + }] + : [200, WFS_DESCRIBE]); + const onAdd = expect.createSpy(); + ReactDOM.render(, document.getElementById("container")); + ReactTestUtils.Simulate.click(document.querySelector('.mapstore-switch-panel .m-slider')); + setTimeout(() => { + expect(onAdd).toHaveBeenCalledWith({ + search: { + type: 'wfs', + url: 'detected-wfs-url', + typeName: 'workspace:linked' + }, + fields: [ + {name: 'shared', type: 'string'}, + {name: 'new-field', type: 'number'} + ] + }); + + const onRemove = expect.createSpy(); + ReactDOM.render(, document.getElementById("container")); + ReactTestUtils.Simulate.click(document.querySelector('.mapstore-switch-panel .m-slider')); + expect(onRemove).toHaveBeenCalledWith('search', undefined); + done(); + }); + }); + it('validates linked WFS edits, refreshes fields, and preserves service properties', (done) => { + const requestedURLs = []; + mockAxios.onGet().reply((config) => { + requestedURLs.push(config.url); + return [200, WFS_DESCRIBE]; + }); + const onChange = expect.createSpy(); + ReactDOM.render(, document.getElementById("container")); + expect(document.querySelector('[data-qa="layer-properties-search-type-name"]').value).toBe('workspace:layer'); + const input = document.querySelector('[data-qa="layer-properties-search-url"]'); + const edit = document.querySelector('[data-qa="layer-properties-search-url-edit"]'); + ReactTestUtils.Simulate.click(edit); + ReactTestUtils.Simulate.change(input, {target: {value: 'new-wfs-url'}}); + ReactTestUtils.Simulate.click(edit); + setTimeout(() => { + expect(requestedURLs[0]).toContain('new-wfs-url'); + expect(requestedURLs[0]).toNotContain('old-describe-url'); + expect(onChange).toHaveBeenCalledWith({ + search: { + type: 'wfs', + url: 'new-wfs-url', + custom: 'value' + }, + fields: [ + {name: 'shared', type: 'string', alias: 'Customized'}, + {name: 'new-field', type: 'number'} + ] + }); + const typeNameInput = document.querySelector('[data-qa="layer-properties-search-type-name"]'); + const typeNameEdit = document.querySelector('[data-qa="layer-properties-search-type-name-edit"]'); + ReactTestUtils.Simulate.click(typeNameEdit); + ReactTestUtils.Simulate.change(typeNameInput, {target: {value: 'workspace:linked'}}); + ReactTestUtils.Simulate.click(typeNameEdit); + setTimeout(() => { + expect(requestedURLs[1]).toContain('old-wfs-url'); + expect(requestedURLs[1]).toNotContain('old-describe-url'); + expect(onChange).toHaveBeenCalledWith({ + search: { + type: 'wfs', + url: 'old-wfs-url', + typeName: 'workspace:linked', + custom: 'value' + }, + fields: [ + {name: 'shared', type: 'string', alias: 'Customized'}, + {name: 'new-field', type: 'number'} + ] + }); + done(); + }); + }); + }); + it('rejects empty and invalid linked WFS values', (done) => { + mockAxios.onGet().reply(500); + const onChange = expect.createSpy(); + ReactDOM.render(, document.getElementById("container")); + const urlInput = document.querySelector('[data-qa="layer-properties-search-url"]'); + const urlEdit = document.querySelector('[data-qa="layer-properties-search-url-edit"]'); + ReactTestUtils.Simulate.click(urlEdit); + ReactTestUtils.Simulate.change(urlInput, {target: {value: ''}}); + ReactTestUtils.Simulate.click(urlEdit); + expect(onChange).toNotHaveBeenCalled(); + expect(urlInput.closest('.form-group').classList.contains('has-error')).toBe(true); + + ReactTestUtils.Simulate.change(urlInput, {target: {value: 'invalid-wfs-url'}}); + ReactTestUtils.Simulate.click(urlEdit); + setTimeout(() => { + expect(onChange).toNotHaveBeenCalled(); + expect(urlInput.closest('.form-group').classList.contains('has-error')).toBe(true); + done(); + }); + }); + it('leaves linked WFS fields empty when DescribeLayer is unsupported', (done) => { + mockAxios.onGet().reply(500); + const onChange = expect.createSpy(); + ReactDOM.render(, document.getElementById("container")); + ReactTestUtils.Simulate.click(document.querySelector('.mapstore-switch-panel .m-slider')); + setTimeout(() => { + expect(onChange).toHaveBeenCalledWith('search', { + type: 'wfs', + url: '', + typeName: '' + }); + done(); + }); + }); + it('refreshes merged fields when the WMS name supplies the legacy WFS typeName', (done) => { + mockAxios.onGet().reply(200, WFS_DESCRIBE); + const onChange = expect.createSpy(); + ReactDOM.render(, document.getElementById("container")); + const nameGroup = Array.from(document.querySelectorAll('.form-group')) + .find((group) => group.querySelector('.control-label')?.innerText === 'layerProperties.name'); + ReactTestUtils.Simulate.click(nameGroup.querySelector('.input-group-addon')); + const editingNameGroup = Array.from(document.querySelectorAll('.form-group')) + .find((group) => group.querySelector('.control-label')?.innerText === 'layerProperties.name'); + ReactTestUtils.Simulate.change(editingNameGroup.querySelector('input'), {target: {value: 'workspace:renamed'}}); + ReactTestUtils.Simulate.click(editingNameGroup.querySelector('.input-group-addon')); + setTimeout(() => { + expect(onChange).toHaveBeenCalledWith({ + name: 'workspace:renamed', + fields: [ + {name: 'shared', type: 'string', alias: 'Customized'}, + {name: 'new-field', type: 'number'} + ] + }); + done(); + }); + }); }); diff --git a/web/client/components/TOC/fragments/settings/__tests__/LayerNameEditField-test.jsx b/web/client/components/TOC/fragments/settings/__tests__/LayerNameEditField-test.jsx index 0d739ed6ee9..856b7ebec74 100644 --- a/web/client/components/TOC/fragments/settings/__tests__/LayerNameEditField-test.jsx +++ b/web/client/components/TOC/fragments/settings/__tests__/LayerNameEditField-test.jsx @@ -88,15 +88,19 @@ describe('LayerNameEditField component', () => { .catch(done); }); it('keeps editing and does not commit when validation fails', (done) => { + const validationError = new Error('Invalid layer name'); const handlers = { - onValidate: () => Promise.reject(new Error('Invalid layer name')), + onValidate: () => Promise.reject(validationError), + onValidationError: () => {}, onUpdateEntry: () => {} }; + const validationErrorSpy = expect.spyOn(handlers, 'onValidationError'); const updateSpy = expect.spyOn(handlers, 'onUpdateEntry'); ReactDOM.render( , document.getElementById('container') ); @@ -113,6 +117,7 @@ describe('LayerNameEditField component', () => { waitFor(() => expect(document.querySelector('.form-group').classList.contains('has-error')).toBe(true)) .then(() => { expect(updateSpy).toNotHaveBeenCalled(); + expect(validationErrorSpy).toHaveBeenCalledWith(validationError); expect(document.querySelector('input').getAttribute('disabled')).toBe(null); done(); }) diff --git a/web/client/components/data/query/CrossLayerFilter.jsx b/web/client/components/data/query/CrossLayerFilter.jsx index c498a1bf2c0..01fd9c8c42b 100644 --- a/web/client/components/data/query/CrossLayerFilter.jsx +++ b/web/client/components/data/query/CrossLayerFilter.jsx @@ -17,6 +17,7 @@ import GroupField from './GroupField'; import { isSameUrl } from '../../../utils/URLUtils'; import InfoPopover from '../../widgets/widget/InfoPopover'; import SwitchButton from '../../misc/switch/SwitchButton'; +import { getWFSLayerName } from '../../../utils/LayersUtils'; const isSameOGCServiceRoot = (origSearchUrl, {search, url} = {}) => isSameUrl(origSearchUrl, url) || isSameUrl(origSearchUrl, (search && search.url)); // bbox make not sense with cross layer filter @@ -119,7 +120,7 @@ export default ({ .filter( l => isSameOGCServiceRoot(searchUrl, l)) .map( l => ({ label: l.title || l.name, - value: l.name + value: getWFSLayerName(l) }))} placeholder={} filter="contains" diff --git a/web/client/components/data/query/enhancers/crossLayerFilter.js b/web/client/components/data/query/enhancers/crossLayerFilter.js index e7efbd731de..467a7465263 100644 --- a/web/client/components/data/query/enhancers/crossLayerFilter.js +++ b/web/client/components/data/query/enhancers/crossLayerFilter.js @@ -5,6 +5,7 @@ import { findGeometryProperty } from '../../../../utils/ogc/WFS/base'; import { describeFeatureTypeToAttributes } from '../../../../utils/FeatureTypeUtils'; import { compose, withProps, withPropsOnChange, withHandlers, defaultProps } from 'recompose'; import propsStreamFactory from '../../../misc/enhancers/propsStreamFactory'; +import { getSearchUrl, getWFSLayerName } from '../../../../utils/LayersUtils'; const hasCrossLayerFunctionalities = (data) => { const functions = get(data, "WFS_Capabilities.Filter_Capabilities.Scalar_Capabilities.ArithmeticOperators.Functions.FunctionNames.FunctionName"); return !!find(functions, ({_} = {}) => _ === "queryCollection"); @@ -48,7 +49,9 @@ const createCrossLayerFunctionalitiesInspectionStream = ($props) => $props const retrieveCrossLayerAttributes = ($props, setQueryCollectionParameter) => $props // retrieve layer's attributes on layer selection change - .distinctUntilChanged(({layer = {}} = {}, {layer: newLayer } = {}) => newLayer && layer.name === (newLayer && newLayer.name)) + .distinctUntilChanged(({layer = {}} = {}, {layer: newLayer } = {}) => newLayer + && getWFSLayerName(layer) === getWFSLayerName(newLayer) + && getSearchUrl(layer) === getSearchUrl(newLayer)) .filter(({layer} = {}) => !!layer) .switchMap(({layer} = {}) => Observable.defer( () => describeFeatureType({layer})) @@ -90,7 +93,7 @@ export default compose( enabledAreaOfInterest: get(crossLayerFilter, 'enabledAreaOfInterest') })), withProps(({layers = [], queryCollection = {}} = {}) => ({ - layer: find(layers, ({name} = {}) => name === queryCollection.typeName) + layer: find(layers, (layer = {}) => getWFSLayerName(layer) === queryCollection.typeName) })), withHandlers({ setQueryCollectionParameter: ({setCrossLayerFilterParameter = () => {}}) => (k, v) => { diff --git a/web/client/components/map/cesium/__tests__/Layer-test.jsx b/web/client/components/map/cesium/__tests__/Layer-test.jsx index e131b3eb36d..307b642b4ec 100644 --- a/web/client/components/map/cesium/__tests__/Layer-test.jsx +++ b/web/client/components/map/cesium/__tests__/Layer-test.jsx @@ -648,6 +648,25 @@ describe('Cesium layer', () => { }); + it('recreates a wms layer when its URL changes', (done) => { + const options = { + type: 'wms', + visibility: true, + name: 'nurc:Arc_Sample', + format: 'image/png', + url: 'http://sample.server/geoserver/old-wms' + }; + let component = ReactDOM.render( + {}}/>, document.getElementById("container")); + const oldLayer = component.layer; + component = ReactDOM.render( + {}}/>, document.getElementById("container")); + waitFor(() => { + expect(component.layer).toNotBe(oldLayer); + expect(component.layer._tileProvider._subdomains[0]).toBe('http://sample.server/geoserver/new-wms'); + }).then(() => done()).catch(done); + }); + it('respects layer ordering 1', (done) => { const options1 = { "type": "wms", @@ -1686,6 +1705,30 @@ describe('Cesium layer', () => { expect(cmp.layer.styledFeatures._queryable).toBe(true); expect(cmp.layer.detached).toBe(true); }); + it('recreates a wfs layer when its URL changes', () => { + const options = { + type: 'wfs', + url: 'geoserver/old-wfs', + title: 'Title', + name: 'workspace:layer', + id: 'ws:layer_id', + visibility: true + }; + let component = ReactDOM.render( + , document.getElementById('container')); + const oldLayer = component.layer; + component = ReactDOM.render( + , document.getElementById('container')); + expect(component.layer).toNotBe(oldLayer); + }); it('should create a non-queriable wfs layer', () => { const options = { type: 'wfs', diff --git a/web/client/components/map/cesium/plugins/WMSLayer.js b/web/client/components/map/cesium/plugins/WMSLayer.js index 98578a7d5e2..a8a1f9985ec 100644 --- a/web/client/components/map/cesium/plugins/WMSLayer.js +++ b/web/client/components/map/cesium/plugins/WMSLayer.js @@ -48,6 +48,7 @@ const updateLayer = (layer, newOptions, oldOptions) => { return !isEqual(oldOption, newOption); }); if (newParameters.length > 0 || + !isEqual(newOptions.url, oldOptions.url) || newOptions.name !== oldOptions.name || newOptions.securityToken !== oldOptions.securityToken || !isEqual(oldOptions.security, newOptions.security) || diff --git a/web/client/components/map/leaflet/__tests__/Layer-test.jsx b/web/client/components/map/leaflet/__tests__/Layer-test.jsx index 5a0802e444c..f001c2af232 100644 --- a/web/client/components/map/leaflet/__tests__/Layer-test.jsx +++ b/web/client/components/map/leaflet/__tests__/Layer-test.jsx @@ -821,6 +821,23 @@ describe('Leaflet layer', () => { expect(layer.layer.options.opacity).toBe(0.5); }); + it('recreates a wms layer when its URL changes', () => { + const options = { + type: 'wms', + visibility: true, + name: 'nurc:Arc_Sample', + format: 'image/png', + url: 'http://sample.server/geoserver/old-wms' + }; + let component = ReactDOM.render( + , document.getElementById("container")); + const oldLayer = component.layer; + component = ReactDOM.render( + , document.getElementById("container")); + expect(component.layer).toNotBe(oldLayer); + expect(component.layer._urls[0]).toBe('http://sample.server/geoserver/new-wms'); + }); + it('respects layer ordering', () => { var options = { "type": "wms", @@ -1678,6 +1695,33 @@ describe('Leaflet layer', () => { done(); }); }); + it('reloads a wfs layer when its URL changes', (done) => { + mockAxios.onGet().reply(200, { ...SAMPLE_FEATURE_COLLECTION, features: [] }); + const options = { + type: 'wfs', + visibility: true, + url: 'OLD_SAMPLE_URL', + name: 'osm:vector_tile' + }; + let firstLoad = true; + let layer = ReactDOM.render(, document.getElementById("container")); + layer.layer.on('load', () => { + if (firstLoad) { + firstLoad = false; + layer = ReactDOM.render(, document.getElementById("container")); + } else { + expect(mockAxios.history.get.some(({ url }) => url.includes('OLD_SAMPLE_URL'))).toBeTruthy(); + expect(mockAxios.history.get.some(({ url }) => url.includes('NEW_SAMPLE_URL'))).toBeTruthy(); + done(); + } + }); + }); it('test second render wfs layer', (done) => { let firstCall = false; mockAxios.onGet().reply(r => { diff --git a/web/client/components/map/leaflet/plugins/WMSLayer.js b/web/client/components/map/leaflet/plugins/WMSLayer.js index a4b2a39f760..e177bd16d9e 100644 --- a/web/client/components/map/leaflet/plugins/WMSLayer.js +++ b/web/client/components/map/leaflet/plugins/WMSLayer.js @@ -66,11 +66,12 @@ Layers.registerType('wms', { }, update: function(layer, newOptions, oldOptions) { if ( - (oldOptions.singleTile !== newOptions.singleTile + !isEqual(oldOptions.url, newOptions.url) + || ((oldOptions.singleTile !== newOptions.singleTile || oldOptions.tileSize !== newOptions.tileSize || oldOptions.securityToken !== newOptions.securityToken || !isEqual(oldOptions.security, newOptions.security)) - && newOptions.visibility) { + && newOptions.visibility)) { let newLayer; const urls = getWMSURLs(isArray(newOptions.url) ? newOptions.url : [newOptions.url]); let queryParameters = wmsToLeafletOptions(newOptions) || {}; diff --git a/web/client/components/map/openlayers/DrawSupport.jsx b/web/client/components/map/openlayers/DrawSupport.jsx index 40150dccaed..c9ce6574cc1 100644 --- a/web/client/components/map/openlayers/DrawSupport.jsx +++ b/web/client/components/map/openlayers/DrawSupport.jsx @@ -52,6 +52,7 @@ import {fromCircle, circular} from 'ol/geom/Polygon'; import {Snap} from "ol/interaction"; import {bbox, all} from "ol/loadingstrategy"; import {getFeatureURL} from "../../../api/WFS"; +import { getSearchUrl, getWFSLayerName } from '../../../utils/LayersUtils'; const geojsonFormat = new GeoJSON(); @@ -179,12 +180,16 @@ export default class DrawSupport extends React.Component { getWMSSnapSource = (snappingLayerInstance, snapConfig) => { const isLoading = this.props.toggleSnappingIsLoading; - if (this?.snapMetadata?.id !== snappingLayerInstance.id) { + const url = getSearchUrl(snappingLayerInstance); + const typeName = getWFSLayerName(snappingLayerInstance); + if (this?.snapMetadata?.id !== snappingLayerInstance.id + || this?.snapMetadata?.url !== url + || this?.snapMetadata?.typeName !== typeName) { const source = new VectorSource({ format: new GeoJSON(), loader: function(extent, resolution, projection) { const proj = projection.getCode(); - const url = getFeatureURL(snappingLayerInstance.search.url, snappingLayerInstance.name, { + const featureUrl = getFeatureURL(url, typeName, { version: '1.1.0', outputFormat: 'application/json', srsname: proj, @@ -196,7 +201,7 @@ export default class DrawSupport extends React.Component { source.removeLoadedExtent(extent); err && console.warn(err); }; - axios.get(url) + axios.get(featureUrl) .then(res => { isLoading(); if (res.status === 200) { @@ -214,6 +219,8 @@ export default class DrawSupport extends React.Component { }); this.snapMetadata = { id: snappingLayerInstance.id, + url, + typeName, source }; } @@ -1240,7 +1247,9 @@ export default class DrawSupport extends React.Component { !snappingLayerExists && !!this.snapInteraction && this.removeSnapInteraction(); if (!!this.snapInteraction) { const snappingConfigChanged = this.props.snapConfig !== newProps.snapConfig; - const snappingLayerChanged = this.props.snappingLayerInstance?.id !== newProps.snappingLayerInstance?.id; + const snappingLayerChanged = this.props.snappingLayerInstance?.id !== newProps.snappingLayerInstance?.id + || getSearchUrl(this.props.snappingLayerInstance) !== getSearchUrl(newProps.snappingLayerInstance) + || getWFSLayerName(this.props.snappingLayerInstance) !== getWFSLayerName(newProps.snappingLayerInstance); const snappingToggledOff = !newProps.snapping && this.props.snapping; const snappingToggledOn = newProps.snapping && !this.props.snapping; if (snappingToggledOn) { diff --git a/web/client/components/map/openlayers/__tests__/DrawSupport-test.jsx b/web/client/components/map/openlayers/__tests__/DrawSupport-test.jsx index 728137d9020..a4b2131d5df 100644 --- a/web/client/components/map/openlayers/__tests__/DrawSupport-test.jsx +++ b/web/client/components/map/openlayers/__tests__/DrawSupport-test.jsx @@ -2721,6 +2721,29 @@ describe('Test DrawSupport', () => { expect(snappingInteraction).toBe(true); }); + it('recreates the snapping source when the linked WFS changes', () => { + const support = renderDrawSupport(); + const layer = { + id: 'snap_layer_1', + type: 'wms', + name: 'workspace:rendered', + search: { + type: 'wfs', + url: 'old-wfs', + typeName: 'workspace:linked' + } + }; + const oldSource = support.getWMSSnapSource(layer, {}); + const newSource = support.getWMSSnapSource({ + ...layer, + search: { + ...layer.search, + url: 'new-wfs' + } + }, {}); + expect(newSource).toNotBe(oldSource); + }); + it('should complete the draw or edit events for point layers even if the current GeoJSON feature geometry is null', () => { const fakeMap = { addLayer: () => {}, diff --git a/web/client/components/map/openlayers/__tests__/Layer-test.jsx b/web/client/components/map/openlayers/__tests__/Layer-test.jsx index 48d5c64a84c..9d29b111c9c 100644 --- a/web/client/components/map/openlayers/__tests__/Layer-test.jsx +++ b/web/client/components/map/openlayers/__tests__/Layer-test.jsx @@ -1847,6 +1847,23 @@ describe('Openlayers layer', () => { expect(layer.layer.getOpacity()).toBe(0.5); }); + it('recreates a wms layer when its URL changes', () => { + const options = { + type: 'wms', + visibility: true, + name: 'nurc:Arc_Sample', + format: 'image/png', + url: 'http://sample.server/geoserver/old-wms' + }; + let component = ReactDOM.render( + , document.getElementById("container")); + const oldLayer = component.layer; + component = ReactDOM.render( + , document.getElementById("container")); + expect(component.layer).toNotBe(oldLayer); + expect(component.layer.getSource().getUrls()[0]).toBe('http://sample.server/geoserver/new-wms'); + }); + it('respects layer ordering', () => { var options = { "type": "wms", @@ -2959,6 +2976,32 @@ describe('Openlayers layer', () => { map={map} />, document.getElementById("container")); expect(layer.layer.getSource()).toBeTruthy(); }); + it('reloads a wfs layer when its URL changes', (done) => { + mockAxios.onGet().reply(200, { ...SAMPLE_FEATURE_COLLECTION, features: [] }); + const options = { + type: 'wfs', + visibility: true, + url: 'OLD_SAMPLE_URL', + name: 'osm:vector_tile' + }; + let layer = ReactDOM.render(, document.getElementById("container")); + waitFor(() => expect(mockAxios.history.get.some(({ url }) => url.includes('OLD_SAMPLE_URL'))).toBeTruthy()) + .then(() => { + layer = ReactDOM.render(, document.getElementById("container")); + return waitFor(() => expect(mockAxios.history.get.some(({ url }) => url.includes('NEW_SAMPLE_URL'))).toBeTruthy()); + }) + .then(() => { + expect(layer.layer.getSource()).toBeTruthy(); + done(); + }) + .catch(done); + }); it('render wfs layer with legacy style', (done) => { mockAxios.onGet().reply(r => { expect(r.url.indexOf('SAMPLE_URL') >= 0 ).toBeTruthy(); diff --git a/web/client/components/map/openlayers/plugins/WMSLayer.js b/web/client/components/map/openlayers/plugins/WMSLayer.js index 4902361159f..563b284e866 100644 --- a/web/client/components/map/openlayers/plugins/WMSLayer.js +++ b/web/client/components/map/openlayers/plugins/WMSLayer.js @@ -208,6 +208,7 @@ const createLayer = (options, map, mapId) => { const mustCreateNewLayer = (oldOptions, newOptions) => { return (oldOptions.singleTile !== newOptions.singleTile + || !isEqual(oldOptions.url, newOptions.url) || oldOptions.cropToProjectionExtent !== newOptions.cropToProjectionExtent || oldOptions.securityToken !== newOptions.securityToken || oldOptions.ratio !== newOptions.ratio diff --git a/web/client/components/style/ThemaClassesEditor.jsx b/web/client/components/style/ThemaClassesEditor.jsx index 1eb14c060bf..259877d03a5 100644 --- a/web/client/components/style/ThemaClassesEditor.jsx +++ b/web/client/components/style/ThemaClassesEditor.jsx @@ -22,6 +22,7 @@ import { AutocompleteCombobox } from '../../components/misc/AutocompleteCombobox import ConfigUtils from '../../utils/ConfigUtils'; import { generateRandomHexColor } from '../../utils/ColorUtils'; import { v1 as uuid } from 'uuid'; +import { getSearchUrl, getWFSLayerName } from '../../utils/LayersUtils'; class ThemaClassesEditor extends React.Component { static propTypes = { classification: PropTypes.array, @@ -72,8 +73,8 @@ class ThemaClassesEditor extends React.Component { column={{key: classificationAttribute}} onChange={value => this.updateUnique(index, value)} dataType="string" - typeName={layer.name} - url={ConfigUtils.getParsedUrl(layer.url, {"outputFormat": "json"})} + typeName={getWFSLayerName(layer)} + url={ConfigUtils.getParsedUrl(getSearchUrl(layer), {"outputFormat": "json"})} value={classItem.unique} filter="contains" autocompleteStreamFactory={createPagedUniqueAutompleteStream}/>); diff --git a/web/client/components/style/__tests__/ThemaClassesEditor-test.jsx b/web/client/components/style/__tests__/ThemaClassesEditor-test.jsx index ce3b769deca..a2b36b73dcc 100644 --- a/web/client/components/style/__tests__/ThemaClassesEditor-test.jsx +++ b/web/client/components/style/__tests__/ThemaClassesEditor-test.jsx @@ -345,4 +345,22 @@ describe("Test the ThemaClassesEditor component", () => { expect(arg1).toBeTruthy(); expect(arg1.length).toBe(1); }); + it('uses the linked WFS service for classification autocomplete', () => { + const cmp = new ThemaClassesEditor(); + const field = cmp.renderFieldByClassification({unique: 'value'}, 0, true, { + classificationAttribute: 'attribute', + layer: { + type: 'wms', + name: 'workspace:rendered', + url: 'wms-url', + search: { + type: 'wfs', + url: 'http://example.com/geoserver/wfs', + typeName: 'workspace:linked' + } + } + }); + expect(field.props.typeName).toBe('workspace:linked'); + expect(field.props.url).toContain('http://example.com/geoserver/wps'); + }); }); diff --git a/web/client/components/widgets/enhancers/__tests__/dependenciesToExtent-test.jsx b/web/client/components/widgets/enhancers/__tests__/dependenciesToExtent-test.jsx index 9e9ca65f334..0c6f25f7e1f 100644 --- a/web/client/components/widgets/enhancers/__tests__/dependenciesToExtent-test.jsx +++ b/web/client/components/widgets/enhancers/__tests__/dependenciesToExtent-test.jsx @@ -102,5 +102,30 @@ describe('widgets dependenciesToExtent enhancer', () => { }); + it('uses the linked WFS type name to fetch filtered bounds', (done) => { + const Sink = dependenciesToExtent(createSink(() => {})); + mockAxios.onPost().reply(({data}) => { + expect(data).toContain('workspace:linked'); + done(); + return [200, '0 01 1']; + }); + ReactDOM.render(, document.getElementById("container")); + }); + }); diff --git a/web/client/components/widgets/enhancers/builderConfiguration.jsx b/web/client/components/widgets/enhancers/builderConfiguration.jsx index 7f346718a48..66840886039 100644 --- a/web/client/components/widgets/enhancers/builderConfiguration.jsx +++ b/web/client/components/widgets/enhancers/builderConfiguration.jsx @@ -12,6 +12,7 @@ import {Message, HTML} from "../../I18N/I18N"; const TYPES = "ALL"; import {findGeometryProperty} from '../../../utils/ogc/WFS/base'; import { extractTraceData } from '../../../utils/WidgetsUtils'; +import { getSearchUrl, getWFSLayerName } from '../../../utils/LayersUtils'; const getGeometryKey = (editorData) => { if (editorData?.selectedChartId) { @@ -30,7 +31,9 @@ export default ({needsWPS} = {}) => compose( defaultProps({ dataStreamFactory: ($props, {onEditorChange = () => {}, onConfigurationError = () => {}} = {}) => $props - .distinctUntilChanged( ({layer = {}} = {}, {layer: newLayer} = {})=> layer.name === newLayer.name) + .distinctUntilChanged( ({layer = {}} = {}, {layer: newLayer} = {}) => + getSearchUrl(layer) === getSearchUrl(newLayer) + && getWFSLayerName(layer) === getWFSLayerName(newLayer)) .switchMap(({ layer, editorData } = {}) => Observable.forkJoin( describeFeatureType({ layer }), // if the builder needWPS service, then if missing it emits an exception diff --git a/web/client/components/widgets/enhancers/dependenciesToExtent.js b/web/client/components/widgets/enhancers/dependenciesToExtent.js index 45fc20db905..9c1f4070b8b 100644 --- a/web/client/components/widgets/enhancers/dependenciesToExtent.js +++ b/web/client/components/widgets/enhancers/dependenciesToExtent.js @@ -13,7 +13,7 @@ import { isEmpty, isEqual } from 'lodash'; import { composeFilterObject } from './utils'; import wpsBounds from '../../../observables/wps/bounds'; import { composeAttributeFilters, toOGCFilter } from '../../../utils/FilterUtils'; -import { getWpsUrl } from '../../../utils/LayersUtils'; +import { getWFSLayerName, getWpsUrl } from '../../../utils/LayersUtils'; import { set } from '../../../utils/ImmutableUtils'; import { createRegisterHooks, ZOOM_TO_EXTENT_HOOK } from '../../../utils/MapUtils'; @@ -86,7 +86,7 @@ export default compose( if (dependencies.filter) { filterObjCollection = {...filterObjCollection, ...composeAttributeFilters([filterObjCollection, dependencies.filter])}; } - const featureTypeName = dependencies && dependencies.layer && dependencies.layer.name; + const featureTypeName = getWFSLayerName(dependencies.layer); if (!isEmpty(filterObjCollection)) { // remove xsi:schemaLocation for performance improvements. filterObjCollection = { diff --git a/web/client/components/widgets/enhancers/filterWidget.js b/web/client/components/widgets/enhancers/filterWidget.js index 81c5e45e70e..bd1eb6d82ae 100644 --- a/web/client/components/widgets/enhancers/filterWidget.js +++ b/web/client/components/widgets/enhancers/filterWidget.js @@ -10,7 +10,7 @@ import debounce from 'lodash/debounce'; import moment from 'moment'; import { getLayerJSONFeature } from '../../../observables/wfs'; import axios from '../../../libs/ajax'; -import { getWpsUrl } from '../../../utils/LayersUtils'; +import { getWFSLayerName, getWpsUrl } from '../../../utils/LayersUtils'; import { getWpsPayload } from '../../../utils/ogc/WPS/autocomplete'; import { executeProcess } from '../../../observables/wps/execute'; import { isFilterValid, composeAttributeFilters } from '../../../utils/FilterUtils'; @@ -52,7 +52,7 @@ const fetchWPSFilterData = (filterData, options = {}) => { // Build WPS payload for distinct values const wpsPayload = getWpsPayload({ - layerName: layer.name, + layerName: getWFSLayerName(layer), attribute: valueAttribute, maxFeatures: maxFeatures, startIndex: 0, @@ -117,7 +117,7 @@ const fetchWFSFilterData = (filterData, options = {}) => { // Build filter object for WFS request const filterObj = { - featureTypeName: layer.name, + featureTypeName: getWFSLayerName(layer), filterType: 'OGC', ogcVersion: '1.1.0', pagination: { diff --git a/web/client/components/widgets/enhancers/multiProtocolChart.js b/web/client/components/widgets/enhancers/multiProtocolChart.js index b2f8a91b0e2..a984244807d 100644 --- a/web/client/components/widgets/enhancers/multiProtocolChart.js +++ b/web/client/components/widgets/enhancers/multiProtocolChart.js @@ -10,7 +10,7 @@ import React, { useEffect, useRef, useState, memo } from 'react'; import { castArray, isObject, isNil, sortBy, debounce } from 'lodash'; import wpsAggregate from '../../../observables/wps/aggregate'; import { getLayerJSONFeature } from '../../../observables/wfs'; -import { getWpsUrl, getSearchUrl } from '../../../utils/LayersUtils'; +import { getWFSLayerName, getWpsUrl, getSearchUrl } from '../../../utils/LayersUtils'; import axios from '../../../libs/ajax'; const CancelToken = axios.CancelToken; @@ -132,7 +132,7 @@ const dataServiceRequests = { .then((response) => wfsToChartData(response, options)), wps: ({ layer, options, filter }, { cancelToken }) => wpsAggregate( getWpsUrl(layer), - {featureType: layer.name, ...options, filter}, { + {featureType: getWFSLayerName(layer), ...options, filter}, { timeout: 15000, cancelToken }, layer) @@ -158,7 +158,10 @@ const getDataServiceType = ({ layer, options }) => { }; const arePropsEqual = (prevProps, nextProps) => - (nextProps.layer && prevProps.layer.name === nextProps.layer.name && prevProps.layer.loadingError === nextProps.layer.loadingError) + (nextProps.layer + && getSearchUrl(prevProps.layer) === getSearchUrl(nextProps.layer) + && getWFSLayerName(prevProps.layer) === getWFSLayerName(nextProps.layer) + && prevProps.layer.loadingError === nextProps.layer.loadingError) && sameOptions(prevProps.options, nextProps.options) && sameFilter(prevProps.filter, nextProps.filter); @@ -263,4 +266,3 @@ const multiProtocolChart = (Component) => { }; export default multiProtocolChart; - diff --git a/web/client/components/widgets/enhancers/tableWidget.js b/web/client/components/widgets/enhancers/tableWidget.js index fca4cf24208..f3d065fb050 100644 --- a/web/client/components/widgets/enhancers/tableWidget.js +++ b/web/client/components/widgets/enhancers/tableWidget.js @@ -18,6 +18,7 @@ import { zoomToExtent } from '../../../actions/map'; import {error} from '../../../actions/notifications'; import { gridTools } from '../../../plugins/featuregrid/index'; import { getFeature } from '../../../api/WFS'; +import { getWFSLayerName } from '../../../utils/LayersUtils'; const withSorting = () => withPropsOnChange(["gridEvents"], ({ gridEvents = {}, updateProperty = () => { }, id } = {}) => ({ gridEvents: { ...gridEvents, @@ -45,7 +46,7 @@ export default compose( // fetch feature with geometry and zoom to it if geometry not exist if (!p?.bbox) { ownProps?.updateProperty(ownProps.id, `dependencies.zoomLoader`, true); // show loader instead of zoom icon - let { data: featureData } = await getFeature(ownProps?.layer?.search?.url, ownProps?.layer?.name, { + let { data: featureData } = await getFeature(ownProps?.layer?.search?.url, getWFSLayerName(ownProps?.layer), { outputFormat: "application/json", srsname: 'EPSG:4326', featureId: p.id, diff --git a/web/client/components/widgets/enhancers/wfsTable/describeFetch.js b/web/client/components/widgets/enhancers/wfsTable/describeFetch.js index 7c4e7799d4d..1b70301c4e7 100644 --- a/web/client/components/widgets/enhancers/wfsTable/describeFetch.js +++ b/web/client/components/widgets/enhancers/wfsTable/describeFetch.js @@ -9,7 +9,7 @@ import Rx from 'rxjs'; import { describeFeatureType } from '../../../../observables/wfs'; -import { getSearchUrl } from '../../../../utils/LayersUtils'; +import { getSearchUrl, getWFSLayerName } from '../../../../utils/LayersUtils'; /** * Retrieves feature types for the layer provided in props. When the layer changes url, @@ -19,6 +19,7 @@ export default props$ => props$ .distinctUntilChanged(({ layer: layer1 } = {}, { layer: layer2 } = {}) => getSearchUrl(layer1) === getSearchUrl(layer2) + && getWFSLayerName(layer1) === getWFSLayerName(layer2) && layer1.loadingError === layer2.loadingError) // this check is not too precise,it may need a refinement .switchMap(({ layer } = {}) => describeFeatureType({ layer }) .map(r => ({ describeFeatureType: r.data, loading: false, error: undefined })) diff --git a/web/client/components/widgets/enhancers/wfsTable/triggerFetch.js b/web/client/components/widgets/enhancers/wfsTable/triggerFetch.js index 4b84a62669b..5ab079ca6fc 100644 --- a/web/client/components/widgets/enhancers/wfsTable/triggerFetch.js +++ b/web/client/components/widgets/enhancers/wfsTable/triggerFetch.js @@ -7,6 +7,7 @@ */ import { checkMapSyncWithWidgetOfMapType } from '../../../../utils/WidgetsUtils'; +import { getSearchUrl, getWFSLayerName } from '../../../../utils/LayersUtils'; require('rxjs'); // const { getSearchUrl } = require('../../../../utils/LayersUtils'); @@ -31,12 +32,14 @@ export default ($props) => if (mapSync && checkMapSyncWithWidgetOfMapType(widgets, dependenciesMap) && !dependencies?.viewport) { return false; } - return layer.name; + return getWFSLayerName(layer); } ) .distinctUntilChanged( ({ layer = {}, options = {}, filter, sortOptions }, newProps) => - /* getSearchUrl(layer) === getSearchUrl(layer) && */ - (newProps.layer && layer.name === newProps.layer.name && layer.loadingError === newProps.layer.loadingError) + (newProps.layer + && getSearchUrl(layer) === getSearchUrl(newProps.layer) + && getWFSLayerName(layer) === getWFSLayerName(newProps.layer) + && layer.loadingError === newProps.layer.loadingError) && sameOptions(options, newProps.options) && sameFilter(filter, newProps.filter) && sameSortOptions(sortOptions, newProps.sortOptions)) diff --git a/web/client/components/widgets/enhancers/wpsCounter.js b/web/client/components/widgets/enhancers/wpsCounter.js index 5857c96de17..faca3e6a6ba 100644 --- a/web/client/components/widgets/enhancers/wpsCounter.js +++ b/web/client/components/widgets/enhancers/wpsCounter.js @@ -20,7 +20,7 @@ const sameOptions = (o1 = {}, o2 = {}) => o1.aggregateFunction === o2.aggregateFunction && o1.aggregationAttribute === o2.aggregationAttribute && o1.viewParams === o2.viewParams; -import { getWpsUrl } from '../../../utils/LayersUtils'; +import { getWFSLayerName, getWpsUrl } from '../../../utils/LayersUtils'; import { checkMapSyncWithWidgetOfMapType } from '../../../utils/WidgetsUtils'; @@ -43,14 +43,17 @@ const dataStreamFactory = ($props) => }) .distinctUntilChanged( ({layer = {}, options = {}, filter}, newProps) => - (newProps.layer && layer.name === newProps.layer.name && layer.loadingError === newProps.layer.loadingError) + (newProps.layer + && getWpsUrl(layer) === getWpsUrl(newProps.layer) + && getWFSLayerName(layer) === getWFSLayerName(newProps.layer) + && layer.loadingError === newProps.layer.loadingError) && sameOptions(options, newProps.options) && sameFilter(filter, newProps.filter)) .switchMap( ({layer = {}, options, filter, onLoad = () => {}, onLoadError = () => {}}) => wpsAggregate( getWpsUrl(layer), - {featureType: layer.name, ...options, filter}, + {featureType: getWFSLayerName(layer), ...options, filter}, {timeout: 15000}, layer ).map((data) => ({ diff --git a/web/client/epics/__tests__/catalog-test.js b/web/client/epics/__tests__/catalog-test.js index 0af9ead4eea..ce64e0c2ead 100644 --- a/web/client/epics/__tests__/catalog-test.js +++ b/web/client/epics/__tests__/catalog-test.js @@ -753,6 +753,10 @@ describe('catalog Epics', () => { title: 'workspace:vector_layer', bbox: {"crs": "EPSG:4326", "bounds": {"minx": "-103.87791475407893", "miny": "44.37246687108142", "maxx": "-103.62278893469492", "maxy": "44.50235105543566"}}, links: [], + search: { + typeName: 'workspace:configured_type', + custom: true + }, params: { CQL_FILTER: 'NAME=\'Test\'' }, @@ -779,6 +783,8 @@ describe('catalog Epics', () => { expect(action.newProperties.search).toExist(); expect(action.newProperties.search.url).toBe("http://some.geoserver.org:80/geoserver/wfs"); expect(action.newProperties.search.type).toBe("wfs"); + expect(action.newProperties.search.typeName).toBe("workspace:configured_type"); + expect(action.newProperties.search.custom).toBe(true); break; case TEST_TIMEOUT: break; @@ -838,6 +844,7 @@ describe('catalog Epics', () => { expect(action.newProperties.search).toExist(); expect(action.newProperties.search.url).toBe("http://some.geoserver.org:80/geoserver/wfs"); expect(action.newProperties.search.type).toBe("wfs"); + expect(action.newProperties.search.typeName).toBe("workspace:vector_layer"); expect(action.newProperties.tileGridStrategy).toEqual('custom'); expect(action.newProperties.tileGrids).toExist(); break; diff --git a/web/client/epics/__tests__/layerdownload-test.js b/web/client/epics/__tests__/layerdownload-test.js index 2a3a83addf6..286c8b08dea 100644 --- a/web/client/epics/__tests__/layerdownload-test.js +++ b/web/client/epics/__tests__/layerdownload-test.js @@ -53,7 +53,7 @@ describe('layerdownload Epics', () => { expect(action.value).toBe(false); break; case QUERY_CREATE: - expect(action.searchUrl).toBe('myurl'); + expect(action.searchUrl).toBe('http://search'); expect(action.filterObj.featureTypeName).toBe('mylayer'); break; default: @@ -98,6 +98,40 @@ describe('layerdownload Epics', () => { state ); }); + it('startFeatureExportDownload uses the linked WFS URL', (done) => { + mockAxios.onGet().reply(404); + const state = { + controls: { + queryPanel: { enabled: false }, + layerdownload: { enabled: true } + }, + featuregrid: {}, + layers: { + flat: [{ + id: 'test layer', + type: 'wms', + name: 'workspace:rendered', + url: 'wms-url', + search: { + type: 'wfs', + url: 'linked-wfs-url', + typeName: 'workspace:linked' + } + }], + selected: ['test layer'] + } + }; + testEpic( + startFeatureExportDownload, + 1, + downloadFeatures('wms-url', { featureTypeName: 'workspace:linked' }, { selectedFormat: 'test-format' }), + (actions) => { + expect(actions[0].error.config.url).toContain('linked-wfs-url'); + done(); + }, + state + ); + }); it('startFeatureExportDownload adds viewport filter to WFS export when cropDataSet is enabled', (done) => { const epicResult = actions => { expect(actions.length).toBe(1); diff --git a/web/client/epics/catalog.js b/web/client/epics/catalog.js index 4031b4c0a1a..53dc60075ce 100644 --- a/web/client/epics/catalog.js +++ b/web/client/epics/catalog.js @@ -298,7 +298,9 @@ export default (API) => ({ return Rx.Observable.of(changeLayerProperties(id, { search: { url: filteredUrl, - type: 'wfs' + type: 'wfs', + ...(description.query?.typeName && { typeName: description.query.typeName }), + ...(layer.search || {}) }, ...tileGridProperties })); } diff --git a/web/client/epics/dashboard.js b/web/client/epics/dashboard.js index 588976bb3e5..dcd15872d39 100644 --- a/web/client/epics/dashboard.js +++ b/web/client/epics/dashboard.js @@ -40,10 +40,11 @@ import { createResource, updateResource, getResource, updateResourceAttribute } import { wrapStartStop } from '../observables/epics'; import { LOCATION_CHANGE, push } from 'connected-react-router'; import { convertDependenciesMappingForCompatibility, updateDependenciesForMultiViewCompatibility } from "../utils/WidgetsUtils"; +import { getSearchUrl, getWFSLayerName } from '../utils/LayersUtils'; const getFTSelectedArgs = (state) => { let layer = getEditingWidgetLayer(state); - let url = layer.search && layer.search.url; - let typeName = layer.name; + let url = getSearchUrl(layer); + let typeName = getWFSLayerName(layer); return [url, typeName]; }; diff --git a/web/client/epics/featuregrid.js b/web/client/epics/featuregrid.js index b4ebabecb10..8b521c9a6de 100644 --- a/web/client/epics/featuregrid.js +++ b/web/client/epics/featuregrid.js @@ -167,6 +167,7 @@ import {dockPanelsSelector} from "../selectors/maplayout"; import {shutdownToolOnAnotherToolDrawing} from "../utils/ControlUtils"; import {mapTypeSelector} from "../selectors/maptype"; import { MapLibraries } from '../utils/MapTypeUtils'; +import { getSearchUrl, getWFSLayerName } from '../utils/LayersUtils'; const setupDrawSupport = (state, original) => { const defaultFeatureProj = getDefaultFeatureProjection(); @@ -260,7 +261,10 @@ const createLoadPageFlow = (store) => ({page, size, reason} = {}) => { )); }; -const createInitialQueryFlow = (action$, store, {url, name, id, fields} = {}) => { +const createInitialQueryFlow = (action$, store, layer = {}) => { + const {id, fields} = layer; + const url = getSearchUrl(layer); + const name = getWFSLayerName(layer); const filterObj = get(store.getState(), `featuregrid.advancedFilters["${id}"]`); const createInitialQuery = (action) => createQuery(url, filterObj || { featureTypeName: name, @@ -323,8 +327,9 @@ const updateFilterFunc = (store) => ({update = {}, append} = {}) => { export const featureGridBrowseData = (action$, store) => action$.ofType(BROWSE_DATA).switchMap( ({layer}) => { const currentTypeName = get(store.getState(), "query.typeName"); + const typeName = getWFSLayerName(layer); return Rx.Observable.of( - ...(currentTypeName !== layer.name ? [reset()] : []), + ...(currentTypeName !== typeName ? [reset()] : []), setControlProperty('drawer', 'enabled', false), setLayer(layer.id), openFeatureGrid() diff --git a/web/client/epics/geoProcessing.js b/web/client/epics/geoProcessing.js index 7213ee79a5e..31b96f8da01 100644 --- a/web/client/epics/geoProcessing.js +++ b/web/client/epics/geoProcessing.js @@ -90,6 +90,7 @@ import { warning as showWarningNotification } from '../actions/notifications'; import { getLayerJSONFeature } from '../observables/wfs'; +import { getWFSLayerName } from '../utils/LayersUtils'; import bufferXML from '../observables/wps/buffer'; import collectGeometriesXML from '../observables/wps/collectGeometries'; import { describeProcess } from '../observables/wps/describe'; @@ -327,14 +328,7 @@ export const getFeaturesGPTEpic = (action$, store) => action$ }; const geometryProperty = findGeometryProperty(layer.describeFeatureType); return Rx.Observable.merge( - getLayerJSONFeature({ - ...layer, - name: layer?.name, - search: { - ...(layer?.search ?? {}), - url: layer.url - } - }, filterObj, options) + getLayerJSONFeature(layer, filterObj, options) .map(data => setFeatures(layerId, source, data, page, geometryProperty)) .catch(e => { logError(e); @@ -373,7 +367,7 @@ export const getFeatureDataGPTEpic = (action$, store) => action$ return Rx.Observable.of(layer); } return getFeatureSimple(layer.search.url, { - typeName: layer.name, + typeName: getWFSLayerName(layer), featureID: featureId, outputFormat: "application/json", srsName: "EPSG:4326" @@ -428,7 +422,7 @@ export const getIntersectionFeatureDataGPTEpic = (action$, store) => action$ return Rx.Observable.of(layer); } return getFeatureSimple(layer.search.url, { - typeName: layer.name, + typeName: getWFSLayerName(layer), featureID: featureId, outputFormat: "application/json", srsName: "EPSG:4326" @@ -601,7 +595,7 @@ export const runBufferProcessGPTEpic = (action$, store) => action$ // then run the collect geometries which and then run the buffer const executeCollectProcess$ = executeProcess( layerUrl, - collectGeometriesXML({ name: layer.name, featureCollection: (layer.type === "vector") ? createFC(layer.features) : null }), + collectGeometriesXML({ name: getWFSLayerName(layer), featureCollection: (layer.type === "vector") ? createFC(layer.features) : null }), executeOptions, { headers: {'Content-Type': 'application/xml', 'Accept': `application/xml, application/json`} @@ -709,7 +703,7 @@ export const runIntersectProcessGPTEpic = (action$, store) => action$ if (isEmpty(sourceFeature)) { sourceFC$ = executeProcess( layerUrl, - collectGeometriesXML({ name: layer.name, featureCollection: (layer.type === "vector") ? createFC(layer.features) : null }), + collectGeometriesXML({ name: getWFSLayerName(layer), featureCollection: (layer.type === "vector") ? createFC(layer.features) : null }), executeOptions, { headers: {'Content-Type': 'application/xml', 'Accept': `application/xml, application/json`} @@ -725,7 +719,7 @@ export const runIntersectProcessGPTEpic = (action$, store) => action$ if (isEmpty(intersectionFeature)) { intersectionFC$ = executeProcess( intersectionLayerUrl, - collectGeometriesXML({ name: intersectionLayer.name, featureCollection: (intersectionLayer.type === "vector") ? createFC(intersectionLayer.features) : null }), + collectGeometriesXML({ name: getWFSLayerName(intersectionLayer), featureCollection: (intersectionLayer.type === "vector") ? createFC(intersectionLayer.features) : null }), executeOptions, { headers: {'Content-Type': 'application/xml', 'Accept': `application/xml, application/json`} diff --git a/web/client/epics/interactions.js b/web/client/epics/interactions.js index d18e6c9f468..bf9a4a14ced 100644 --- a/web/client/epics/interactions.js +++ b/web/client/epics/interactions.js @@ -35,7 +35,7 @@ import { unplugOrphanZoomToInteractions } from '../utils/InteractionUtils'; import { defaultLayerFilter, toOGCFilter } from '../utils/FilterUtils'; -import { getWpsUrl } from '../utils/LayersUtils'; +import { getWFSLayerName, getWpsUrl } from '../utils/LayersUtils'; import { processFilterToCQL, buildExcludeCQLFilter, buildDefaultCQLFilter } from '../utils/FilterEventUtils'; import { FILTER_SELECTION_MODES } from '../components/widgets/builder/wizard/filter/FilterDataTab/constants'; import { getChartAxisDependencyPath, getMapDependencyPath } from '../utils/WidgetsUtils'; @@ -1027,7 +1027,7 @@ function fetchLayerExtent(layer) { const wfsFallback = () => Rx.Observable.fromPromise(getFeatureLayer(layer)).map(response => bbox(response.data)); const wpsUrl = getWpsUrl(layer); const filterObj = layer.layerFilter; - const featureTypeName = layer.name; + const featureTypeName = getWFSLayerName(layer); if (!wpsUrl || !featureTypeName) { return Rx.Observable.throw(new Error('No WPS URL or feature type name')); } diff --git a/web/client/epics/layerdownload.js b/web/client/epics/layerdownload.js index 9a7779487b7..11ae306e7e5 100644 --- a/web/client/epics/layerdownload.js +++ b/web/client/epics/layerdownload.js @@ -59,7 +59,7 @@ import { referenceOutputExtractor, makeOutputsExtractor, getExecutionStatus } f import { mergeFiltersToOGC } from '../utils/FilterUtils'; import { getByOutputFormat } from '../utils/FileFormatUtils'; -import { getLayerTitle } from '../utils/LayersUtils'; +import { getLayerTitle, getSearchUrl, getWFSLayerName } from '../utils/LayersUtils'; import { bboxToFeatureGeometry } from '../utils/CoordinatesUtils'; import { interceptOGCError } from '../utils/ObservableUtils'; import requestBuilder from '../utils/ogc/WFS/RequestBuilder'; @@ -256,7 +256,7 @@ export const openDownloadTool = (action$) => return Rx.Observable.from([ toggleControl("layerdownload"), onDownloadOptionChange("singlePage", false), - ...(action.layer.search?.url ? [createQuery(action.layer.url, {featureTypeName: action.layer.name})] : []) + ...(action.layer.search?.url ? [createQuery(getSearchUrl(action.layer), {featureTypeName: getWFSLayerName(action.layer)})] : []) ]); }); export const fetchFormatsWFSDownload = (action$) => @@ -289,7 +289,7 @@ export const startFeatureExportDownload = (action$, store) => const mapBbox = mapBboxSelector(state); const currentLocale = currentLocaleSelector(state); - const geometryAttribute = extractGeometryAttributeName(layerDescribeSelector(state, layer.name)); + const geometryAttribute = extractGeometryAttributeName(layerDescribeSelector(state, getWFSLayerName(layer))); const propertyNames = action.downloadOptions.propertyName ? [ ...(geometryAttribute ? [geometryAttribute] : []), ...action.downloadOptions.propertyName @@ -297,7 +297,7 @@ export const startFeatureExportDownload = (action$, store) => const { layerFilter } = layer; const wfsFlow = () => getWFSFeature({ - url: action.url, + url: getSearchUrl(layer) || action.url, downloadOptions: action.downloadOptions, filterObj: isNil(action.filterObj) ? {} : action.filterObj, layer, @@ -319,7 +319,7 @@ export const startFeatureExportDownload = (action$, store) => .catch(() => { // check here return getWFSFeature({ - url: action.url, + url: getSearchUrl(layer) || action.url, downloadOptions: action.downloadOptions, filterObj: action.filterObj, layer, @@ -365,7 +365,7 @@ export const startFeatureExportDownload = (action$, store) => xmlnsToAdd: ['xmlns:ogc="http://www.opengis.net/ogc"', 'xmlns:gml="http://www.opengis.net/gml"'] }, layer.layerFilter, action.filterObj, cqlFilter); const wpsDownloadOptions = { - layerName: layer.name, + layerName: getWFSLayerName(layer), outputFormat: action.downloadOptions.selectedFormat, asynchronous: true, outputAsReference: true, diff --git a/web/client/epics/layerfilter.js b/web/client/epics/layerfilter.js index 4543e7cdcf4..5f07dc9ce84 100644 --- a/web/client/epics/layerfilter.js +++ b/web/client/epics/layerfilter.js @@ -28,6 +28,7 @@ import { featureTypeSelected, toggleLayerFilter, initQueryPanel } from '../actio import { getSelectedLayer } from '../selectors/layers'; import { changeDrawingStatus } from '../actions/draw'; import {setupCrossLayerFilterDefaults} from '../utils/FilterUtils'; +import { getSearchUrl, getWFSLayerName } from '../utils/LayersUtils'; const isNotEmptyFilter = ({crossLayerFilter, spatialField, filterFields, filters } = {}) => { return !!(filterFields && head(filterFields) @@ -58,10 +59,9 @@ const addFilterToLayer = (layer, filter) => { export const handleLayerFilterPanel = (action$, {getState}) => action$.ofType(OPEN_QUERY_BUILDER).switchMap(() => { const layer = getSelectedLayer(getState()); - const {url, name, layerFilter, fields} = layer || {}; - const searchUrl = layer && layer.search && layer.search.url; + const {layerFilter, fields} = layer || {}; return Rx.Observable.of( - featureTypeSelected(searchUrl || url, name, fields), + featureTypeSelected(getSearchUrl(layer), getWFSLayerName(layer), fields), // Load the filter from the layer if it exist loadFilter(layerFilter), initLayerFilter(layerFilter), diff --git a/web/client/epics/layers.js b/web/client/epics/layers.js index 10542a6e6ce..6b74165663e 100644 --- a/web/client/epics/layers.js +++ b/web/client/epics/layers.js @@ -19,11 +19,11 @@ import { updateNode, updateSettings, layersRefreshError, - changeLayerParams + changeLayerParams, + layerNameChangeError } from '../actions/layers'; import { getLayersWithDimension, layerSettingSelector, getLayerFromId } from '../selectors/layers'; -import { basicError } from '../utils/NotificationUtils'; import { getCapabilitiesUrl, getLayerTitleTranslations, removeWorkspace } from '../utils/LayersUtils'; import { isArray, head } from 'lodash'; @@ -61,7 +61,9 @@ export const refresh = action$ => if (result && result.name === layer.name && result.owsType === 'WFS') { return { url: result.owsURL, - type: 'wfs' + type: 'wfs', + ...(result.query?.[0]?.typeName && {typeName: result.query[0].typeName}), + ...(layer.search || {}) }; } return null; @@ -136,11 +138,7 @@ export const updateSettingsParamsEpic = (action$, store) => // this handles errors due to name changes ).concat(newParams.name && layer && layer.name !== newParams.name ? action$.ofType(LAYER_LOAD).filter(({layerId}) => layerId === layer?.id).take(1).flatMap(({error}) => error ? - Rx.Observable.of(basicError({ - title: 'layerNameChangeError.title', - message: 'layerNameChangeError.message', - autoDismiss: 5 - })) : + Rx.Observable.of(layerNameChangeError()) : Rx.Observable.empty()) : Rx.Observable.empty()); }); diff --git a/web/client/epics/wfsquery.js b/web/client/epics/wfsquery.js index 9e4d90e46a9..220bde32eff 100644 --- a/web/client/epics/wfsquery.js +++ b/web/client/epics/wfsquery.js @@ -101,8 +101,11 @@ export const featureTypeSelectedEpic = (action$, store) => .filter(action => action.url && action.typeName) .switchMap(action => { const state = store.getState(); - if (isDescribeLoaded(state, action.typeName)) { - const info = extractInfo(layerDescribeSelector(state, action.typeName), action.fields); + if (isDescribeLoaded(state, action.typeName, action.url)) { + const info = { + ...extractInfo(layerDescribeSelector(state, action.typeName), action.fields), + url: action.url + }; const geometry = info.geometry[0] && info.geometry[0].attribute ? info.geometry[0].attribute : 'the_geom'; return Rx.Observable.of(featureTypeLoaded(action.typeName, info, action.owner), changeSpatialAttribute(geometry), Rx.Scheduler.async); // async scheduler is needed to allow invokers of `FEATURE_TYPE_SELECTED` to intercept `FEATURE_TYPE_LOADED` action as response. @@ -122,7 +125,8 @@ export const featureTypeSelectedEpic = (action$, store) => .map((attribute) => attribute ), original: originalData, - attributes: describeFeatureTypeToAttributes(originalData, action.fields) + attributes: describeFeatureTypeToAttributes(originalData, action.fields), + url: action.url }; const geometry = info.geometry[0] && info.geometry[0].attribute ? info.geometry[0].attribute : 'the_geom'; @@ -142,7 +146,10 @@ export const featureTypeSelectedEpic = (action$, store) => return Rx.Observable.defer( () => axios.get(ConfigUtils.filterUrlParams(action.url, authkeyParamNameSelector(store.getState())) + '?service=WFS&version=1.1.0&request=DescribeFeatureType&typeName=' + action.typeName + '&outputFormat=application/json', {_msAuthSourceId: selectedLayer?.security?.sourceId})) .map((response) => { if (typeof response.data === 'object' && response.data.featureTypes && response.data.featureTypes[0]) { - const info = extractInfo(response.data, action.fields); + const info = { + ...extractInfo(response.data, action.fields), + url: action.url + }; const geometry = info.geometry[0] && info.geometry[0].attribute ? info.geometry[0].attribute : 'the_geom'; return Rx.Observable.from([changeSpatialAttribute(geometry), featureTypeLoaded(action.typeName, info)]); } diff --git a/web/client/epics/widgetsbuilder.js b/web/client/epics/widgetsbuilder.js index 2cf26d50629..940b7bb91e1 100644 --- a/web/client/epics/widgetsbuilder.js +++ b/web/client/epics/widgetsbuilder.js @@ -29,10 +29,11 @@ import { getWidgetLayer, getEditingWidgetFilter, getWidgetFilterKey, getEditingW import { wfsFilter } from '../selectors/query'; import { widgetBuilderAvailable } from '../selectors/controls'; import { generateNewTrace } from '../utils/WidgetsUtils'; +import { getSearchUrl, getWFSLayerName } from '../utils/LayersUtils'; const getFTSelectedArgs = (state) => { let layer = getWidgetLayer(state); - let url = layer.search && layer.search.url; - let typeName = layer.name; + let url = getSearchUrl(layer); + let typeName = getWFSLayerName(layer); return [url, typeName, layer.fields]; }; diff --git a/web/client/observables/__tests__/wfs-test.js b/web/client/observables/__tests__/wfs-test.js index 19b6fa472ee..52e67ff5075 100644 --- a/web/client/observables/__tests__/wfs-test.js +++ b/web/client/observables/__tests__/wfs-test.js @@ -6,7 +6,11 @@ * LICENSE file in the root directory of this source tree. */ -import { toDescribeURL, getFeatureUtilities } from '../wfs'; +import { parse } from 'url'; +import MockAdapter from 'axios-mock-adapter'; + +import axios from '../../libs/ajax'; +import { toDescribeURL, getFeatureUtilities, getLayerJSONFeature } from '../wfs'; import expect from 'expect'; describe("WFS Observables", () => { @@ -19,6 +23,46 @@ describe("WFS Observables", () => { expect(toDescribeURL({ name: 'testName', search: { url: _url }}).split('?')[0]).toBe(_url[0]); }); + it('uses the linked WFS type name in DescribeFeatureType requests', () => { + const parsed = parse(toDescribeURL({ + type: 'wms', + name: 'workspace:wms-name', + url: 'wms-url', + search: {url: 'wfs-url', typeName: 'workspace:wfs-name'} + }), true); + expect(parsed.query.typeName).toBe('workspace:wfs-name'); + }); + it('falls back to the WMS name and ignores search.typeName for native WFS', () => { + expect(parse(toDescribeURL({ + type: 'wms', + name: 'workspace:wms-name', + search: {url: 'wfs-url'} + }), true).query.typeName).toBe('workspace:wms-name'); + expect(parse(toDescribeURL({ + type: 'wfs', + name: 'workspace:native-wfs-name', + url: 'wfs-url', + search: {typeName: 'workspace:ignored'} + }), true).query.typeName).toBe('workspace:native-wfs-name'); + }); + it('uses the linked WFS type name in GetFeature requests', (done) => { + const mockAxios = new MockAdapter(axios); + mockAxios.onPost().reply(({data}) => { + expect(data).toContain('typeName="workspace:linked"'); + return [200, {type: 'FeatureCollection', features: []}]; + }); + getLayerJSONFeature({ + type: 'wms', + name: 'workspace:rendered', + search: {url: 'wfs-url', typeName: 'workspace:linked'} + }).subscribe(() => { + mockAxios.restore(); + done(); + }, (error) => { + mockAxios.restore(); + done(error); + }); + }); it('getFeatureUtilities', () => { const _url = [ 'http://gs-stable.geosolutionsgroup.com:443/geoserver1', diff --git a/web/client/observables/__tests__/wms-test.js b/web/client/observables/__tests__/wms-test.js index cc0aecd2d3d..e29f6f2fd50 100644 --- a/web/client/observables/__tests__/wms-test.js +++ b/web/client/observables/__tests__/wms-test.js @@ -6,7 +6,10 @@ * LICENSE file in the root directory of this source tree. */ -import { toDescribeLayerURL } from '../wms'; +import AxiosMockAdapter from 'axios-mock-adapter'; + +import axios from '../../libs/ajax'; +import { addSearch, toDescribeLayerURL } from '../wms'; import expect from 'expect'; describe("WMS Observables", () => { @@ -19,4 +22,68 @@ describe("WMS Observables", () => { expect(toDescribeLayerURL({ name: 'testName', search: { url: _url }}).split('?')[0]).toBe(_url[0]); }); + it('uses the primary URL for WMS DescribeLayer', () => { + expect(toDescribeLayerURL({ + name: 'testName', + url: 'wms-url', + search: {url: 'linked-wfs-url'} + }).split('?')[0]).toBe('wms-url'); + }); + it('allows an explicit DescribeLayer action to override the previous linked service', (done) => { + const mockAxios = new AxiosMockAdapter(axios); + mockAxios.onGet().reply(200, { + layerDescriptions: [{ + owsURL: 'detected-wfs-url', + typeName: 'workspace:detected' + }] + }); + addSearch({ + name: 'workspace:wms', + url: 'wms-url', + search: { + type: 'wfs', + url: 'custom-wfs-url', + typeName: 'workspace:custom', + custom: true + } + }, { detectedSearchOverrides: true }) + .toPromise() + .then((layer) => { + expect(layer.search).toEqual({ + type: 'wfs', + url: 'detected-wfs-url', + typeName: 'workspace:detected', + custom: true + }); + mockAxios.restore(); + done(); + }) + .catch((error) => { + mockAxios.restore(); + done(error); + }); + }); + it('supports the legacy nested DescribeLayer typeName response', (done) => { + const mockAxios = new AxiosMockAdapter(axios); + mockAxios.onGet().reply(200, { + layerDescriptions: [{ + owsURL: 'detected-wfs-url', + query: {typeName: 'workspace:nested'} + }] + }); + addSearch({ + name: 'workspace:wms', + url: 'wms-url' + }) + .toPromise() + .then((layer) => { + expect(layer.search.typeName).toBe('workspace:nested'); + mockAxios.restore(); + done(); + }) + .catch((error) => { + mockAxios.restore(); + done(error); + }); + }); }); diff --git a/web/client/observables/wfs.js b/web/client/observables/wfs.js index f542b67ab63..68268cf7ec2 100644 --- a/web/client/observables/wfs.js +++ b/web/client/observables/wfs.js @@ -15,14 +15,15 @@ import { stripPrefix } from 'xml2js/lib/processors'; import axios from '../libs/ajax'; import { createFeatureFilter, getWFSFilterData } from '../utils/FilterUtils'; -import { getCapabilitiesUrl } from '../utils/LayersUtils'; +import { getCapabilitiesUrl, getWFSLayerName } from '../utils/LayersUtils'; import { interceptOGCError } from '../utils/ObservableUtils'; import requestBuilder from '../utils/ogc/WFS/RequestBuilder'; import { getDefaultUrl } from '../utils/URLUtils'; const {getFeature, query, sortBy, propertyName} = requestBuilder({ wfsVersion: "1.1.0" }); -export const toDescribeURL = ({ name, search = {}, url, describeFeatureTypeURL} = {}) => { +export const toDescribeURL = ({ name, search = {}, url, describeFeatureTypeURL, type } = {}) => { + const typeName = getWFSLayerName({ name, search, type }); const parsed = urlUtil.parse(getDefaultUrl(describeFeatureTypeURL || search.url || url), true); return urlUtil.format( { @@ -33,14 +34,14 @@ export const toDescribeURL = ({ name, search = {}, url, describeFeatureTypeURL} service: "WFS", version: "1.1.0", - typeName: name, + typeName, outputFormat: 'application/json', request: "DescribeFeatureType" } }); }; -export const toLayerCapabilitiesURL = ({name, search = {}, url} = {}) => { - const URL = getCapabilitiesUrl({name, url: search && search.url || url }); +export const toLayerCapabilitiesURL = ({name, search = {}, url, type} = {}) => { + const URL = getCapabilitiesUrl({name: getWFSLayerName({name, search, type}), url: search && search.url || url }); const parsed = urlUtil.parse(URL, true); return urlUtil.format( { @@ -240,40 +241,43 @@ export const getJSONFeatureWA = (searchUrl, filterObj, { sortOptions = {}, ...op * retro compatibility the filter object can contain pagination info, typeName and so on. * @param {object} options the options (pagination, totalFeatures and so on ...) */ -export const getLayerJSONFeature = ({ search = {}, url, name, security } = {}, filter, {sortOptions, propertyName: pn, ...options} = {}) => +export const getLayerJSONFeature = ({ search = {}, url, name, security, type } = {}, filter, {sortOptions, propertyName: pn, ...options} = {}) => { + const typeName = getWFSLayerName({search, name, type}); + return ( // TODO: Apply sort workaround for no primary keys - getJSONFeature(search.url || url, - filter && typeof filter === 'object' ? { - ...filter, - typeName: name || filter.typeName - } : getFeature( - query(name, - [ - ...( sortOptions ? [sortBy(sortOptions.sortBy, sortOptions.sortOrder)] : []), - ...(pn ? [propertyName(pn)] : []), - ...(filter ? castArray(filter) : []) - ]), - options), // options contains startIndex, maxFeatures and it can be passed as it is - {security, ...options}) - // retry using 1st propertyNames property, if present, to workaround primary-key issues - .catch(error => { - if (error.name === "OGCError" && error.code === 'NoApplicableCode' && !sortOptions && pn && pn[0]) { - return getJSONFeature(search.url || url, - filter && typeof filter === 'object' ? { - ...filter, - typeName: name || filter.typeName - } : getFeature( - query(name, - [ - sortBy(isArray(pn) ? pn[0] : pn), - ...(pn ? [propertyName(pn)] : []), - ...(filter ? castArray(filter) : []) - ]), - options), // options contains startIndex, maxFeatures and it can be passed as it is - options); - } - throw error; - }); + getJSONFeature(search.url || url, + filter && typeof filter === 'object' ? { + ...filter, + typeName: typeName || filter.typeName + } : getFeature( + query(typeName, + [ + ...( sortOptions ? [sortBy(sortOptions.sortBy, sortOptions.sortOrder)] : []), + ...(pn ? [propertyName(pn)] : []), + ...(filter ? castArray(filter) : []) + ]), + options), // options contains startIndex, maxFeatures and it can be passed as it is + {security, ...options}) + // retry using 1st propertyNames property, if present, to workaround primary-key issues + .catch(error => { + if (error.name === "OGCError" && error.code === 'NoApplicableCode' && !sortOptions && pn && pn[0]) { + return getJSONFeature(search.url || url, + filter && typeof filter === 'object' ? { + ...filter, + typeName: typeName || filter.typeName + } : getFeature( + query(typeName, + [ + sortBy(isArray(pn) ? pn[0] : pn), + ...(pn ? [propertyName(pn)] : []), + ...(filter ? castArray(filter) : []) + ]), + options), // options contains startIndex, maxFeatures and it can be passed as it is + options); + } + throw error; + })); +}; export const describeFeatureType = ({layer}) => { const url = toDescribeURL(layer); @@ -299,4 +303,3 @@ export default { describeFeatureType, getLayerWFSCapabilities }; - diff --git a/web/client/observables/wms.js b/web/client/observables/wms.js index a5c8952593d..c50b95162ca 100644 --- a/web/client/observables/wms.js +++ b/web/client/observables/wms.js @@ -8,7 +8,7 @@ import urlUtil from 'url'; -import { head } from 'lodash'; +import { castArray, head } from 'lodash'; import Proj4js from 'proj4'; import { Observable } from 'rxjs'; @@ -23,7 +23,7 @@ import { getDefaultUrl } from '../utils/URLUtils'; const proj4 = Proj4js; export const toDescribeLayerURL = ({name, search = {}, url} = {}) => { - const parsed = urlUtil.parse(getDefaultUrl(search.url || url), true); + const parsed = urlUtil.parse(getDefaultUrl(url || search.url), true); return urlUtil.format( { ...parsed, @@ -48,17 +48,25 @@ export const getLayerCapabilities = l => { .map(c => WMS.parseLayerCapabilities(c, l)); }; -export const addSearch = l => +export const addSearch = (l, { detectedSearchOverrides = false } = {}) => describeLayer(l) .map( ({data = {}}) => data && data.layerDescriptions[0]) - .map(({owsURL} = {}) => ({ - ...l, - params: {}, // TODO: if needed, clean them up - search: owsURL ? { + .map(({owsURL, typeName, query} = {}) => { + const detectedTypeName = typeName || head(castArray(query))?.typeName; + const detectedSearch = owsURL ? { type: "wfs", - url: cleanAuthParamsFromURL(owsURL) - } : undefined - })); + url: cleanAuthParamsFromURL(owsURL), + ...(detectedTypeName && { typeName: detectedTypeName }) + } : undefined; + return { + ...l, + params: {}, // TODO: if needed, clean them up + search: detectedSearch ? { + ...(detectedSearchOverrides ? l.search : detectedSearch), + ...(detectedSearchOverrides ? detectedSearch : l.search) + } : undefined + }; + }); export const getNativeCrs = (layer) => Observable.of(layer).filter(({nativeCrs}) => !nativeCrs) .switchMap((l) => { return getLayerCapabilities(l) diff --git a/web/client/plugins/LayersSelection/components/EllipsisButton.jsx b/web/client/plugins/LayersSelection/components/EllipsisButton.jsx index 86a2cd0a746..813b22efb83 100644 --- a/web/client/plugins/LayersSelection/components/EllipsisButton.jsx +++ b/web/client/plugins/LayersSelection/components/EllipsisButton.jsx @@ -5,6 +5,7 @@ import axios from 'axios'; import Message from '../../../components/I18N/Message'; import { describeFeatureType } from '../../../api/WFS'; +import { getSearchUrl, getWFSLayerName } from '../../../utils/LayersUtils'; import Statistics from './Statistics'; import { DropdownButton, Glyphicon, MenuItem } from 'react-bootstrap'; import { v1 as uuidv1 } from 'uuid'; @@ -149,9 +150,10 @@ export default ({ } case 'wms': case 'wfs': { - describeFeatureType(node.url, node.name) + const typeName = getWFSLayerName(node); + describeFeatureType(getSearchUrl(node), typeName) .then(describe => { - const featureType = describe.featureTypes.find(fType => node.name.endsWith(fType.typeName)); + const featureType = describe.featureTypes.find(fType => typeName.endsWith(fType.typeName)); const newNumericFields = featureType.properties.filter(property => property.localType === 'number').map(property => property.name); // primary key is not always exposed const newPrimaryKey = featureType.properties @@ -169,7 +171,7 @@ export default ({ } default: } - }, [node.name]); + }, [node.name, node.search?.typeName, node.search?.url, node.url]); return ( <> diff --git a/web/client/plugins/LayersSelection/epics/layersSelection.js b/web/client/plugins/LayersSelection/epics/layersSelection.js index d8a2a520bfa..da86f1f149d 100644 --- a/web/client/plugins/LayersSelection/epics/layersSelection.js +++ b/web/client/plugins/LayersSelection/epics/layersSelection.js @@ -17,7 +17,7 @@ import { extractGeometryAttributeName } from '../../../utils/WFSLayerUtils'; import { mergeOptionsByOwner, removeAdditionalLayer } from '../../../actions/additionallayers'; import { highlightStyleSelector } from '../../../selectors/mapInfo'; import { layersSelector, groupsSelector } from '../../../selectors/layers'; -import { flattenArrayOfObjects, getInactiveNode } from '../../../utils/LayersUtils'; +import { flattenArrayOfObjects, getInactiveNode, getSearchUrl, getWFSLayerName } from '../../../utils/LayersUtils'; import { optionsToVendorParams } from '../../../utils/VendorParamsUtils'; import { selectLayersSelector, isSelectEnabled, filterLayerForSelect, isSelectQueriable, getSelectQueryMaxFeatureCount, getSelectHighlightOptions } from '../selectors/layersSelection'; @@ -94,11 +94,13 @@ const queryLayer = (layer, geometry, selectQueryMaxCount) => { } case 'wms': case 'wfs': { - return describeFeatureType(layer.url, layer.name) + const url = getSearchUrl(layer); + const typeName = getWFSLayerName(layer); + return describeFeatureType(url, typeName) .then(describe => axios.get( getFeatureURL( - layer.url, layer.name, + url, typeName, optionsToVendorParams({ filterObj: { spatialField: { diff --git a/web/client/plugins/TOCItemsSettings.jsx b/web/client/plugins/TOCItemsSettings.jsx index b241ad81e86..38bd035962e 100644 --- a/web/client/plugins/TOCItemsSettings.jsx +++ b/web/client/plugins/TOCItemsSettings.jsx @@ -14,7 +14,7 @@ import {createSelector} from 'reselect'; import {setControlProperty} from '../actions/controls'; import {getLayerCapabilities} from '../actions/layerCapabilities'; -import {hideSettings, updateNode, updateSettings, updateSettingsParams, showSettings} from '../actions/layers'; +import {hideSettings, layerNameChangeError, updateNode, updateSettings, updateSettingsParams, showSettings} from '../actions/layers'; import {toggleStyleEditor} from '../actions/styleeditor'; import {updateSettingsLifecycle} from "../components/TOC/enhancers/tocItemsSettings"; import TOCItemsSettings from '../components/TOC/TOCItemsSettings'; @@ -141,6 +141,7 @@ const TOCItemsSettingsPlugin = compose( onRetrieveLayerData: getLayerCapabilities, onSetTab: setControlProperty.bind(null, 'layersettings', 'activeTab'), onUpdateParams: updateSettingsParams, + onLayerNameValidationError: layerNameChangeError, onToggleStyleEditor: toggleStyleEditor }), updateSettingsLifecycle, @@ -169,4 +170,3 @@ export default createPlugin('TOCItemsSettings', { } }); - diff --git a/web/client/plugins/__tests__/TOCItemsSettings-test.jsx b/web/client/plugins/__tests__/TOCItemsSettings-test.jsx index 5baf46b336b..cea061fa63b 100644 --- a/web/client/plugins/__tests__/TOCItemsSettings-test.jsx +++ b/web/client/plugins/__tests__/TOCItemsSettings-test.jsx @@ -12,9 +12,12 @@ import expect from 'expect'; import CAPABILITIES from 'raw-loader!../../test-resources/wms/GetCapabilities-1.3.0.xml'; import React from 'react'; import ReactDOM from 'react-dom'; +import ReactTestUtils from 'react-dom/test-utils'; +import { waitFor } from '@testing-library/react'; import { setControlProperty } from '../../actions/controls'; import { UPDATE_NODE, addLayer, selectNode, showSettings } from '../../actions/layers'; +import { SHOW_NOTIFICATION } from '../../actions/notifications'; import { INIT_STYLE_SERVICE } from '../../actions/styleeditor'; import { createStateMocker } from '../../reducers/__tests__/reducersTestUtils'; import controls from '../../reducers/controls'; @@ -75,9 +78,39 @@ describe('TOCItemsSettings Plugin', () => { const tabIndexes = document.querySelectorAll(TAB_INDEX_SELECTOR); expect(tabIndexes.length).toBe(4); expect(tabIndexes[0].className).toBe("active"); // general tab active - expect(document.querySelectorAll(`${TAB_CONTENT_SELECTOR} div.form-group`).length).toBe(4); // check content is general settings tab. + expect(document.querySelectorAll(`${TAB_CONTENT_SELECTOR} div.form-group`).length).toBe(7); // check content is general settings tab. }); + it('shows the layer name error notification when pre-validation fails', (done) => { + mockAxios.onGet().reply(404); + const wfsLayer = { + id: 'TEST_WFS', + type: 'wfs', + name: 'workspace:old', + url: '/geoserver/wfs' + }; + const wfsPanelState = stateMocker( + addLayer(wfsLayer), + selectNode(wfsLayer.id, 'layer'), + showSettings(wfsLayer.id, 'layers', {opacity: 1}) + ); + const { Plugin, actions } = getPluginForTest(TOCItemsSettingsPlugin, wfsPanelState); + ReactDOM.render(, document.getElementById('container')); + + const getInput = () => document.querySelector('[data-qa="layer-properties-name"]'); + const getEditButton = () => getInput().parentElement.querySelector('.input-group-addon'); + ReactTestUtils.Simulate.click(getEditButton()); + ReactTestUtils.Simulate.change(getInput(), {target: {value: 'workspace:missing'}}); + ReactTestUtils.Simulate.click(getEditButton()); + + waitFor(() => { + const notification = actions.find(({type}) => type === SHOW_NOTIFICATION); + expect(notification).toExist(); + expect(notification.level).toBe('error'); + expect(notification.title).toBe('layerNameChangeError.title'); + expect(notification.message).toBe('layerNameChangeError.message'); + }).then(() => done()).catch(done); + }); it('display panel', () => { const { Plugin } = getPluginForTest(TOCItemsSettingsPlugin, DISPLAY_PANEL_STATE); ReactDOM.render(, document.getElementById("container")); diff --git a/web/client/plugins/featuregrid/panels/index.jsx b/web/client/plugins/featuregrid/panels/index.jsx index d7e16810d45..47e40b8ca66 100644 --- a/web/client/plugins/featuregrid/panels/index.jsx +++ b/web/client/plugins/featuregrid/panels/index.jsx @@ -60,6 +60,7 @@ import { resultsSelector } from '../../../selectors/query'; import { getFeatureTypeProperties, isGeometryType } from '../../../utils/ogc/WFS/base'; +import { getSearchUrl } from '../../../utils/LayersUtils'; import { pageEvents, toolbarEvents } from '../index'; import settings from './AttributeSelector'; import { @@ -86,7 +87,8 @@ const Toolbar = connect( isDrawing: isDrawingSelector, isSimpleGeom: isSimpleGeomSelector, selectedCount: selectedFeaturesCount, - disableToolbar: state => state && state.featuregrid && state.featuregrid.disableToolbar || !isDescribeLoaded(state, selectedLayerNameSelector(state)), + disableToolbar: state => state && state.featuregrid && state.featuregrid.disableToolbar + || !isDescribeLoaded(state, selectedLayerNameSelector(state), getSearchUrl(selectedLayerSelector(state))), results: resultsSelector, isSyncActive: isSyncWmsActive, isColumnsOpen: state => state && state.featuregrid && state.featuregrid.tools && state.featuregrid.tools.settings, diff --git a/web/client/selectors/__tests__/query-test.js b/web/client/selectors/__tests__/query-test.js index 027d4fe91c7..4cec06e4b93 100644 --- a/web/client/selectors/__tests__/query-test.js +++ b/web/client/selectors/__tests__/query-test.js @@ -361,6 +361,21 @@ describe('Test query selectors', () => { const isLoaded = isDescribeLoaded(initialState, "editing:polygons.layer"); expect(isLoaded).toBe(true); }); + it('test isDescribeLoaded includes the WFS URL when provided', () => { + const featureType = initialState.query.featureTypes["editing:polygons.layer"]; + const state = { + ...initialState, + query: { + ...initialState.query, + featureTypes: { + ...initialState.query.featureTypes, + "editing:polygons.layer": {...featureType, url: 'wfs-url'} + } + } + }; + expect(isDescribeLoaded(state, "editing:polygons.layer", 'wfs-url')).toBe(true); + expect(isDescribeLoaded(state, "editing:polygons.layer", 'other-wfs-url')).toBe(false); + }); it('test isDescribeLoaded with missing describe', () => { const isLoaded = isDescribeLoaded(initialState, "editing:polygosns"); expect(isLoaded).toBe(false); diff --git a/web/client/selectors/featuregrid.js b/web/client/selectors/featuregrid.js index 86743a79713..1eabd81191c 100644 --- a/web/client/selectors/featuregrid.js +++ b/web/client/selectors/featuregrid.js @@ -22,6 +22,7 @@ import isEqual from "lodash/isEqual"; import { mapBboxSelector, projectionSelector } from "./map"; import { bboxToFeatureGeometry } from "../utils/CoordinatesUtils"; import { MapLibraries } from '../utils/MapTypeUtils'; +import { getWFSLayerName } from '../utils/LayersUtils'; export const getLayerById = getLayerFromId; export const getTitle = (layer = {}) => layer.title || layer.name; @@ -175,7 +176,7 @@ export const isSimpleGeomSelector = state => isSimpleGeomType(geomTypeSelectedFe */ export const selectedLayerNameSelector = state => { const layer = getLayerById(state, selectedLayerIdSelector(state)); - return layer && layer.name || ''; + return getWFSLayerName(layer) || ''; }; /** diff --git a/web/client/selectors/layerdownload.js b/web/client/selectors/layerdownload.js index 754bbf21e82..ba13aa3239d 100644 --- a/web/client/selectors/layerdownload.js +++ b/web/client/selectors/layerdownload.js @@ -14,6 +14,7 @@ import { wfsFilter } from './query'; import { composeFilterObject } from '../components/widgets/enhancers/utils'; import { getTableWidgets } from './widgets'; +import { getWFSLayerName } from '../utils/LayersUtils'; export const layerDownloadControlEnabledSelector = state => state?.controls?.layerdownload?.enabled; export const downloadOptionsSelector = state => state?.layerdownload?.downloadOptions; @@ -46,8 +47,9 @@ export const wfsFilterSelector = createSelector( if (widget?.filter && widget?.quickFilters) { updatedFilter = composeFilterObject(widget.filter, widget.quickFilters, options); } - return featureGridOpen ? wfsFilterObj || updatedFilter : selectedLayer?.name ? updatedFilter || { - featureTypeName: selectedLayer.name, + const typeName = getWFSLayerName(selectedLayer); + return featureGridOpen ? wfsFilterObj || updatedFilter : typeName ? updatedFilter || { + featureTypeName: typeName, filterType: 'OGC', ogcVersion: '1.1.0' } : null; diff --git a/web/client/selectors/query.js b/web/client/selectors/query.js index 07c195f34b2..e6f07e081c8 100644 --- a/web/client/selectors/query.js +++ b/web/client/selectors/query.js @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { isNil, get, head, isArray, findIndex } from 'lodash'; +import { isNil, get, head, isArray, findIndex, isEqual } from 'lodash'; /** * Selects the featureType name of the query filterObject @@ -58,9 +58,9 @@ export const paginationInfo = { resultSize: (state) =>get(state, "query.result.features.length"), totalFeatures: (state) => get(state, "query.result.totalFeatures") }; -export const isDescribeLoaded = (state, name) => { +export const isDescribeLoaded = (state, name, url) => { const ft = featureTypeSelectorCreator(name)(state); - if (ft && ft.attributes && ft.geometry && ft.original) { + if (ft && ft.attributes && ft.geometry && ft.original && (!url || isEqual(ft.url, url))) { return true; } return false; diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index 0b9975acdd2..4668732d80a 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -87,6 +87,9 @@ "windowTitle": "Ebenen Eigenschaften", "title": "Titel", "name": "Name", + "url": "URL", + "typeName": "TypeName", + "wfsLinkedService": "Verknüpfter WFS-Dienst", "group": "Gruppe", "description": "Beschreibung", "general": "Generell", diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index 99a13ce0eef..7ed259d62c0 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -87,6 +87,9 @@ "windowTitle": "Layer Properties", "title": "Title", "name": "Name", + "url": "URL", + "typeName": "TypeName", + "wfsLinkedService": "WFS linked service", "group": "Group", "general": "General", "description": "Description", diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index 0fb98c9e4f5..5cfb70758cb 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -87,6 +87,9 @@ "windowTitle": "Propiedades de la capa", "title": "Título", "name": "Nombre", + "url": "URL", + "typeName": "TypeName", + "wfsLinkedService": "Servicio WFS vinculado", "group": "Grupo", "description": "Descripción", "general": "General", diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index 7cc900a2cd8..f2abbf2b9fe 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -87,6 +87,9 @@ "windowTitle": "Propriétés de la couche", "title": "Titre", "name": "Nom", + "url": "URL", + "typeName": "TypeName", + "wfsLinkedService": "Service WFS lié", "group": "Groupe", "description": "Description", "general": "Général", diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index 650525891f0..9bd1d5dd1f6 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -87,6 +87,9 @@ "windowTitle": "Proprietà del livello", "title": "Titolo", "name": "Nome", + "url": "URL", + "typeName": "TypeName", + "wfsLinkedService": "Servizio WFS collegato", "group": "Gruppo", "description": "Descrizione", "general": "Generale", diff --git a/web/client/utils/LayersUtils.js b/web/client/utils/LayersUtils.js index 4fea803a8d8..17e5c59cdd7 100644 --- a/web/client/utils/LayersUtils.js +++ b/web/client/utils/LayersUtils.js @@ -864,7 +864,21 @@ export const getCapabilitiesUrl = (layer) => { * @param {Object} layer * @returns {string} layer url */ -export const getSearchUrl = (l = {}) => l.search && l.search.url || l.url; +export const getSearchUrl = (l = {}) => l.search?.url ?? l.url; +/** + * Returns the feature type used by the WFS service associated with a layer. + * WMS layers can configure a distinct linked WFS type name; native WFS layers + * continue to use their layer name. + * + * @param {Object} layer layer configuration + * @returns {string} WFS feature type name + */ +export const getWFSLayerName = (layer = {}) => + (layer.type === 'wms' + && layer.search?.typeName !== undefined + && layer.search.typeName !== null) + ? layer.search.typeName + : layer.name; export const invalidateUnsupportedLayer = (layer, maptype) => { return isSupportedLayerFunc(layer, maptype) ? checkInvalidParam(layer) : Object.assign({}, layer, {invalid: true}); }; @@ -1251,4 +1265,3 @@ LayersUtils = { isInsideResolutionsLimits, visibleTimelineLayers }; - diff --git a/web/client/utils/WFSLayerUtils.js b/web/client/utils/WFSLayerUtils.js index 62004b820c4..f4777bd3fdf 100644 --- a/web/client/utils/WFSLayerUtils.js +++ b/web/client/utils/WFSLayerUtils.js @@ -8,14 +8,18 @@ import { optionsToVendorParams } from './VendorParamsUtils'; import urlUtil from 'url'; -import {get, head} from 'lodash'; +import {get, head, isEqual} from 'lodash'; import { getDefaultUrl } from './URLUtils'; import { getCredentials } from './SecurityUtils'; +import { getWFSLayerName } from './LayersUtils'; export const needsReload = (oldOptions, newOptions) => { const oldParams = { ...(optionsToVendorParams(oldOptions) || {}), _v_: oldOptions._v_ }; const newParams = { ...(optionsToVendorParams(newOptions) || {}), _v_: newOptions._v_ }; - return oldOptions.name !== newOptions.name || ["_v_", "CQL_FILTER", "VIEWPARAMS"].reduce((found, param) => { + if (!isEqual(oldOptions.url, newOptions.url) || oldOptions.name !== newOptions.name) { + return true; + } + return ["_v_", "CQL_FILTER", "VIEWPARAMS"].reduce((found, param) => { if (oldParams[param] !== newParams[param]) { return true; } @@ -23,7 +27,7 @@ export const needsReload = (oldOptions, newOptions) => { }, false); }; -export const toDescribeURL = ({ name, search = {}, url, describeFeatureTypeURL } = {}) => { +export const toDescribeURL = ({ name, search = {}, url, describeFeatureTypeURL, type } = {}) => { const parsed = urlUtil.parse(getDefaultUrl(describeFeatureTypeURL || search.url || url), true); return urlUtil.format( { @@ -34,7 +38,7 @@ export const toDescribeURL = ({ name, search = {}, url, describeFeatureTypeURL } service: "WFS", version: "1.1.0", - typeName: name, + typeName: getWFSLayerName({name, search, type}), outputFormat: 'application/json', request: "DescribeFeatureType" } diff --git a/web/client/utils/__tests__/LayersUtils-test.js b/web/client/utils/__tests__/LayersUtils-test.js index 561c53446f3..b52e34158b9 100644 --- a/web/client/utils/__tests__/LayersUtils-test.js +++ b/web/client/utils/__tests__/LayersUtils-test.js @@ -32,6 +32,41 @@ const noVendorWmsLayer = { type: 'wms', serverType: 'no-vendor' }; + +describe('getWFSLayerName', () => { + it('uses a linked WFS type name only for WMS layers', () => { + expect(LayersUtils.getWFSLayerName({ + type: 'wms', + name: 'workspace:wms-name', + search: {typeName: 'workspace:wfs-name'} + })).toBe('workspace:wfs-name'); + expect(LayersUtils.getWFSLayerName({ + type: 'wfs', + name: 'workspace:native-name', + search: {typeName: 'workspace:ignored'} + })).toBe('workspace:native-name'); + }); + it('falls back to layer.name for legacy WMS configurations', () => { + expect(LayersUtils.getWFSLayerName({ + type: 'wms', + name: 'workspace:legacy-name', + search: {url: 'wfs-url'} + })).toBe('workspace:legacy-name'); + }); + it('does not replace an explicitly empty linked WFS type name', () => { + expect(LayersUtils.getWFSLayerName({ + type: 'wms', + name: 'workspace:wms-name', + search: {typeName: ''} + })).toBe(''); + }); +}); +describe('getSearchUrl', () => { + it('falls back only when the linked WFS URL is missing', () => { + expect(LayersUtils.getSearchUrl({url: 'wms-url', search: {type: 'wfs'}})).toBe('wms-url'); + expect(LayersUtils.getSearchUrl({url: 'wms-url', search: {type: 'wfs', url: ''}})).toBe(''); + }); +}); const groupsExample = [{ "id": "first", "title": "first", diff --git a/web/client/utils/__tests__/WFSLayerUtils-test.js b/web/client/utils/__tests__/WFSLayerUtils-test.js index 615ae9a965a..73e0003134f 100644 --- a/web/client/utils/__tests__/WFSLayerUtils-test.js +++ b/web/client/utils/__tests__/WFSLayerUtils-test.js @@ -26,7 +26,11 @@ describe("WFSLayerUtils", () => { expect(toDescribeURL({ name: 'testName', search: { url: _url }}).split('?')[0]).toBe(_url[0]); }); - it('requires a reload only when the service layer name changes', () => { + it('reloads native WFS data when its URL changes', () => { + expect(needsReload({url: 'old-url'}, {url: 'new-url'})).toBe(true); + expect(needsReload({url: ['old-url']}, {url: ['old-url']})).toBe(false); + }); + it('reloads native WFS data when its service layer name changes', () => { expect(needsReload({name: 'old-name'}, {name: 'new-name'})).toBe(true); expect(needsReload({name: 'same-name'}, {name: 'same-name', title: 'New title'})).toBe(false); }); diff --git a/web/client/utils/ogc/WMC/index.js b/web/client/utils/ogc/WMC/index.js index 39686e6d874..d790197fd3f 100644 --- a/web/client/utils/ogc/WMC/index.js +++ b/web/client/utils/ogc/WMC/index.js @@ -214,7 +214,10 @@ export const toMapConfig = (wmcString, generateLayersGroup = false) => { group: get(msTagExtractor(layerExtensions, 'group'), 'charContent'), search: searchTag && { url: xlinkExtractor(searchTag, 'href'), - type: attrExtractor(searchTag, 'type') + type: attrExtractor(searchTag, 'type'), + ...(attrExtractor(searchTag, 'typeName') && { + typeName: attrExtractor(searchTag, 'typeName') + }) }, dimensions: dimensions.map(dim => ({ name: attrExtractor(dim, 'name'), @@ -515,7 +518,10 @@ export const toWMC = ( attributes: [{ name: 'type', value: layer.search.type - }, ...makeSimpleXlink(layer.search.url)] + }, ...(layer.search.typeName ? [{ + name: 'typeName', + value: layer.search.typeName + }] : []), ...makeSimpleXlink(layer.search.url)] }, layer.layerFilter && { name: 'filter', textContent: JSON.stringify(layer.layerFilter) diff --git a/web/client/utils/ogc/__tests__/WMC-test.js b/web/client/utils/ogc/__tests__/WMC-test.js index 940e3a78d92..9aa19860fd9 100644 --- a/web/client/utils/ogc/__tests__/WMC-test.js +++ b/web/client/utils/ogc/__tests__/WMC-test.js @@ -13,6 +13,18 @@ import { omit, zip } from 'lodash'; import { toMapConfig, toWMC } from '../WMC'; describe('WMC tests', () => { + it('round-trips an optional linked WFS typeName', () => + axios.get('base/web/client/test-resources/wmc/context.wmc') + .then(({data}) => toMapConfig(data.replace('type="wfs"', 'type="wfs" typeName="workspace:linked"'))) + .then(config => { + expect(config.map.layers[1].search.typeName).toBe('workspace:linked'); + const exported = toWMC(config, {}); + expect(exported).toContain('typeName="workspace:linked"'); + return toMapConfig(exported); + }) + .then(config => { + expect(config.map.layers[1].search.typeName).toBe('workspace:linked'); + })); it('toMapConfig with a valid sample context', () => axios.get('base/web/client/test-resources/wmc/context.wmc').then(response => toMapConfig(response.data)).then(config => { expect(config).toExist();