Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/developer-guide/maps-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
13 changes: 13 additions & 0 deletions web/client/actions/__tests__/layers-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 11 additions & 2 deletions web/client/actions/layerCapabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,24 @@ 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';

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 }));
Expand Down
11 changes: 11 additions & 0 deletions web/client/actions/layers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'
});
}
6 changes: 4 additions & 2 deletions web/client/api/WFS.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};

Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -175,4 +178,3 @@ export const getSupportedFormat = (url) => {
};
});
};

17 changes: 10 additions & 7 deletions web/client/api/WMS.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
23 changes: 22 additions & 1 deletion web/client/api/__tests__/WFS-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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');
Expand Down
1 change: 1 addition & 0 deletions web/client/api/__tests__/WMS-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 8 additions & 2 deletions web/client/api/catalog/WMS.js
Original file line number Diff line number Diff line change
Expand Up @@ -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] }
: {};
Expand All @@ -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"};
Expand Down Expand Up @@ -133,6 +138,7 @@ const recordToLayer = (record, {
...layerBaseConfig,
...serviceLayerOptions,
...recordLayerOptions,
...(!isEmpty(search) && { search }),
localizedLayerStyles: !isNil(localizedLayerStyles) ? localizedLayerStyles : undefined,
imageFormats: supportedGetMapFormats,
infoFormats: supportedGetFeatureInfoFormats,
Expand Down
26 changes: 26 additions & 0 deletions web/client/api/catalog/__tests__/WMS-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
12 changes: 4 additions & 8 deletions web/client/components/TOC/fragments/LayerFields/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
110 changes: 110 additions & 0 deletions web/client/components/TOC/fragments/settings/EditableTextField.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<FormGroup validationState={error ? 'error' : null}>
<ControlLabel><Message msgId={labelId} /></ControlLabel>
<InputGroup>
<FormControl
data-qa={dataQa}
value={currentValue}
type="text"
disabled={!editing || loading}
onChange={(event) => setCurrentValue(event.target.value)} />
<InputGroup.Addon
className="btn"
data-qa={`${dataQa}-edit`}
onClick={() => {
if (!loading) {
if (editing) {
confirm();
} else {
setError(false);
setEditing(true);
}
}
}}>
{loading
? <Spinner noFadeIn style={{width: '18px', height: '18px'}} spinnerName="circle"/>
: <Glyphicon glyph={editing ? 'ok' : 'pencil'} />}
</InputGroup.Addon>
</InputGroup>
</FormGroup>
);
};

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;
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
};

Expand Down
Loading
Loading