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();