diff --git a/web/client/components/TOC/fragments/settings/EditableTextField.jsx b/web/client/components/TOC/fragments/settings/EditableTextField.jsx
index 942fc6e56b..0d079c2851 100644
--- a/web/client/components/TOC/fragments/settings/EditableTextField.jsx
+++ b/web/client/components/TOC/fragments/settings/EditableTextField.jsx
@@ -8,10 +8,15 @@
import PropTypes from 'prop-types';
import React, { useEffect, useState } from 'react';
-import { ControlLabel, FormControl, FormGroup, Glyphicon, InputGroup } from 'react-bootstrap';
+import { ControlLabel, FormControl, FormGroup, Glyphicon, InputGroup, Tooltip } from 'react-bootstrap';
import Spinner from 'react-spinkit';
import Message from '../../../I18N/Message';
+import OverlayTrigger from '../../../misc/OverlayTrigger';
+
+const REQUIRED_ERROR = 'required';
+const VALIDATION_ERROR = 'validation';
+const LAYER_LOAD_ERROR = 'layer-load';
/**
* Text field that requires an explicit confirmation before updating its value.
@@ -21,16 +26,23 @@ const EditableTextField = ({
labelId,
value = '',
onChange = () => {},
+ onDraftChange = () => {},
onValidate,
required = false,
formatValue = (currentValue) => currentValue ?? '',
- parseValue = (currentValue) => currentValue
+ parseValue = (currentValue) => currentValue,
+ waitForLayerLoad = false,
+ layerLoading = false,
+ layerLoadingError = false,
+ resetKey
}) => {
const formattedValue = formatValue(value);
const [editing, setEditing] = useState(false);
const [currentValue, setCurrentValue] = useState(formattedValue);
const [loading, setLoading] = useState(false);
- const [error, setError] = useState(false);
+ const [errorType, setErrorType] = useState();
+ const [waitingForLayerLoading, setWaitingForLayerLoading] = useState(false);
+ const [waitingForLayerLoad, setWaitingForLayerLoad] = useState(false);
useEffect(() => {
if (!editing) {
@@ -38,59 +50,120 @@ const EditableTextField = ({
}
}, [formattedValue, editing]);
+ useEffect(() => {
+ setCurrentValue(formattedValue);
+ setEditing(false);
+ setLoading(false);
+ setErrorType();
+ setWaitingForLayerLoading(false);
+ setWaitingForLayerLoad(false);
+ }, [resetKey]);
+
+ useEffect(() => {
+ if (waitingForLayerLoading && layerLoading) {
+ setWaitingForLayerLoading(false);
+ setWaitingForLayerLoad(true);
+ } else if (waitingForLayerLoad && !layerLoading) {
+ setWaitingForLayerLoad(false);
+ if (layerLoadingError) {
+ setErrorType(LAYER_LOAD_ERROR);
+ setEditing(true);
+ } else {
+ setErrorType();
+ setEditing(false);
+ }
+ }
+ }, [layerLoading, layerLoadingError, waitingForLayerLoad, waitingForLayerLoading]);
+
+ const busy = loading || waitingForLayerLoading || waitingForLayerLoad;
+
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);
+ setErrorType(REQUIRED_ERROR);
+ return;
+ }
+ if (errorType === LAYER_LOAD_ERROR) {
+ onChange(parsedValue, undefined, { forced: true });
+ setEditing(false);
+ setErrorType();
return;
}
if (currentValue === formattedValue) {
setEditing(false);
- setError(false);
+ setErrorType();
+ return;
+ }
+ if (errorType === VALIDATION_ERROR) {
+ onChange(parsedValue, undefined, { forced: true });
+ setEditing(false);
+ setErrorType();
return;
}
setLoading(true);
- setError(false);
+ setErrorType();
Promise.resolve()
.then(() => onValidate?.(parsedValue))
.then((validationResult) => {
- onChange(parsedValue, validationResult);
- setEditing(false);
+ if (waitForLayerLoad) {
+ setWaitingForLayerLoading(true);
+ }
+ onChange(parsedValue, validationResult, { forced: false });
+ if (!waitForLayerLoad) {
+ setEditing(false);
+ }
})
- .catch(() => setError(true))
+ .catch((error) => setErrorType(error?.required ? REQUIRED_ERROR : VALIDATION_ERROR))
.then(() => setLoading(false));
};
+ const tooltipId = errorType === REQUIRED_ERROR
+ ? 'layerProperties.tooltip.requiredValue'
+ : `layerProperties.tooltip.${editing ? 'confirmValue' : 'editValue'}`;
+
+ const editButton = (
+ {
+ if (!busy) {
+ if (editing) {
+ confirm();
+ } else {
+ setErrorType();
+ setEditing(true);
+ }
+ }
+ }}>
+ {busy
+ ?
+ : }
+
+ );
+
return (
-
+
setCurrentValue(event.target.value)} />
- {
- if (!loading) {
- if (editing) {
- confirm();
- } else {
- setError(false);
- setEditing(true);
- }
- }
- }}>
- {loading
- ?
- : }
-
+ disabled={!editing || busy}
+ onChange={(event) => {
+ const nextValue = event.target.value;
+ setCurrentValue(nextValue);
+ setErrorType();
+ onDraftChange(parseValue(nextValue));
+ }} />
+
+
+ }>
+ {editButton}
+
);
@@ -101,10 +174,15 @@ EditableTextField.propTypes = {
labelId: PropTypes.string.isRequired,
value: PropTypes.any,
onChange: PropTypes.func,
+ onDraftChange: PropTypes.func,
onValidate: PropTypes.func,
required: PropTypes.bool,
formatValue: PropTypes.func,
- parseValue: PropTypes.func
+ parseValue: PropTypes.func,
+ waitForLayerLoad: PropTypes.bool,
+ layerLoading: PropTypes.bool,
+ layerLoadingError: PropTypes.any,
+ resetKey: PropTypes.any
};
export default EditableTextField;
diff --git a/web/client/components/TOC/fragments/settings/General.jsx b/web/client/components/TOC/fragments/settings/General.jsx
index 58543a467a..05f3be0e21 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 { castArray, find, includes, isNil, isObject, uniqBy } from 'lodash';
+import { castArray, find, includes, isEqual, isNil, isObject, uniqBy } from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import { Checkbox, Col, ControlLabel, FormControl, FormGroup, Grid } from 'react-bootstrap';
@@ -35,6 +35,14 @@ const parseURL = (url) => {
const urls = url.split(',').map((value) => value.trim());
return urls.length > 1 ? urls : urls[0];
};
+const isEmptyRequiredValue = (value) => Array.isArray(value)
+ ? !value.length || value.some(isEmptyRequiredValue)
+ : isNil(value) || `${value}`.trim() === '';
+const rejectRequiredValue = () => {
+ const error = new Error('A service URL and layer or type name are required');
+ error.required = true;
+ return Promise.reject(error);
+};
const mergeArcGISFields = (fields = [], previousFields = []) => fields.map((field) => {
const previousField = previousFields.find(({name}) => name === field.name);
return {
@@ -76,6 +84,85 @@ class General extends React.Component {
currentLocale: 'en-US'
};
+ state = {
+ drafts: {},
+ nodeKey: this.props.element?.id ?? this.props.settings?.node
+ };
+
+ static getDerivedStateFromProps(props, state) {
+ const nodeKey = props.element?.id ?? props.settings?.node;
+ if (nodeKey !== state.nodeKey) {
+ return {
+ drafts: {},
+ nodeKey
+ };
+ }
+ const committedValues = {
+ name: props.element?.name,
+ url: props.element?.url,
+ searchUrl: props.element?.search?.url,
+ searchTypeName: props.element?.search?.typeName
+ };
+ const drafts = {...state.drafts};
+ let changed = false;
+ Object.keys(committedValues).forEach((property) => {
+ if (Object.prototype.hasOwnProperty.call(drafts, property)
+ && isEqual(drafts[property], committedValues[property])) {
+ delete drafts[property];
+ changed = true;
+ }
+ });
+ return changed ? {drafts} : null;
+ }
+
+ setDraft = (property, value) => this.setState(({drafts}) => ({
+ drafts: {
+ ...drafts,
+ [property]: value
+ }
+ }));
+
+ clearSearchDrafts = () => this.setState(({drafts}) => {
+ const nextDrafts = {...drafts};
+ delete nextDrafts.searchUrl;
+ delete nextDrafts.searchTypeName;
+ return {drafts: nextDrafts};
+ });
+
+ hasDraft = (property) => Object.prototype.hasOwnProperty.call(this.state.drafts, property);
+
+ getDraft = (property, fallback) => this.hasDraft(property)
+ ? this.state.drafts[property]
+ : fallback;
+
+ isDraftPending = (property, committedValue) => this.hasDraft(property)
+ && !isEqual(this.state.drafts[property], committedValue);
+
+ getCurrentLayer = (overrides = {}) => {
+ const {element = {}} = this.props;
+ const hasSearchDraft = this.hasDraft('searchUrl') || this.hasDraft('searchTypeName');
+ const currentSearch = (element.search || hasSearchDraft)
+ ? {
+ ...(element.search || {}),
+ url: this.getDraft('searchUrl', element.search?.url),
+ typeName: this.getDraft('searchTypeName', element.search?.typeName)
+ }
+ : element.search;
+ return {
+ ...element,
+ name: this.getDraft('name', element.name),
+ url: this.getDraft('url', element.url),
+ ...(currentSearch && {search: currentSearch}),
+ ...overrides,
+ ...(overrides.search && {
+ search: {
+ ...(currentSearch || {}),
+ ...overrides.search
+ }
+ })
+ };
+ };
+
getTitle = (label) => _getTitle(label, this.props.currentLocale);
getLabelName = (label, groups) => _getLabelName(this.getTitle(label), groups);
@@ -89,26 +176,28 @@ class General extends React.Component {
getLayerNameValidator = () => {
const {element = {}} = this.props;
- const usesLayerNameForWFS = element.type === 'wfs'
- || element.type === 'wms'
- && element.search?.type === 'wfs'
- && isNil(element.search.typeName);
- return usesLayerNameForWFS || element.type === 'arcgis-feature'
+ return includes(['wms', 'wfs', 'arcgis-feature'], element.type)
? this.validateLayerName
: undefined;
};
validateLayerName = (name) => {
- const {element = {}} = this.props;
- if (element.type === 'wfs' || element.type === 'wms') {
- return loadFields({...element, name}, true)
+ const nextLayer = this.getCurrentLayer({name});
+ if (nextLayer.type === 'wfs') {
+ return this.validateNativeWFS(nextLayer)
.then((fields) => ({fields}));
}
- if (element.type === 'arcgis-feature') {
- return getFeatureLayerSchema(element.url, name, {
- authSourceId: element.security?.sourceId
+ if (nextLayer.type === 'wms') {
+ return this.validateWMS(nextLayer)
+ .then(() => nextLayer.search?.type === 'wfs' && isNil(nextLayer.search.typeName)
+ ? this.validateLinkedWFSLayer(nextLayer).then((fields) => ({fields}))
+ : {});
+ }
+ if (nextLayer.type === 'arcgis-feature') {
+ return getFeatureLayerSchema(nextLayer.url, name, {
+ authSourceId: nextLayer.security?.sourceId
}).then(({fields, properties, geometryType}) => ({
- fields: mergeArcGISFields(fields, element.fields),
+ fields: mergeArcGISFields(fields, nextLayer.fields),
properties,
geometryType
}));
@@ -117,19 +206,20 @@ class General extends React.Component {
};
validateLayerURL = (url) => {
- const nextLayer = { ...this.props.element, url };
+ const nextLayer = this.getCurrentLayer({url});
if (nextLayer.type === 'wfs') {
- return loadFields({
- ...nextLayer,
- describeFeatureTypeURL: undefined,
- search: nextLayer.search && {
- ...nextLayer.search,
- url: undefined
- }
- }, true);
+ return this.validateNativeWFS(nextLayer);
}
- return Promise.all(castArray(url).map((currentUrl) =>
- getWMSLayerCapabilities({ ...nextLayer, url: currentUrl })
+ return this.validateWMS(nextLayer);
+ };
+
+ validateWMS = (layer) => {
+ const urls = castArray(layer.url);
+ if (!urls.length || urls.some(isEmptyRequiredValue) || isEmptyRequiredValue(layer.name)) {
+ return rejectRequiredValue();
+ }
+ return Promise.all(urls.map((currentUrl) =>
+ getWMSLayerCapabilities({ ...layer, url: currentUrl })
.toPromise()
.then((layerCapability) => {
if (!layerCapability) {
@@ -140,22 +230,41 @@ class General extends React.Component {
));
};
- 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'));
+ validateNativeWFS = (layer) => {
+ if (isEmptyRequiredValue(layer.url) || isEmptyRequiredValue(layer.name)) {
+ return rejectRequiredValue();
+ }
+ return loadFields({
+ ...layer,
+ describeFeatureTypeURL: undefined,
+ search: layer.search && {
+ ...layer.search,
+ url: undefined
+ }
+ }, true);
+ };
+
+ validateLinkedWFSLayer = (layer) => {
+ const typeName = layer.search?.typeName ?? layer.name;
+ if (isEmptyRequiredValue(layer.search?.url) || isEmptyRequiredValue(typeName)) {
+ return rejectRequiredValue();
}
return loadFields({
- ...this.props.element,
+ ...layer,
describeFeatureTypeURL: undefined,
search: {
- ...search,
+ ...layer.search,
typeName
}
}, true);
};
+ validateLinkedWFS = (search) => this.validateLinkedWFSLayer(
+ this.getCurrentLayer({search})
+ );
+
updateWFSPanel = (enabled) => {
+ this.clearSearchDrafts();
if (!enabled) {
this.props.onChange('search', undefined);
return;
@@ -199,6 +308,11 @@ class General extends React.Component {
const eleGroupLabel = this.findGroupLabel(this.props.element && this.props.element.group || DEFAULT_GROUP_ID);
const SelectCreatable = this.props.allowNew ? Select.Creatable : Select;
+ const editorResetKey = this.props.element?.id ?? this.props.settings?.node;
+ const waitForNameLayerLoad = this.props.enableLayerNameEditFeedback
+ && !this.isDraftPending('url', this.props.element.url);
+ const waitForURLLayerLoad = this.props.enableLayerNameEditFeedback
+ && !this.isDraftPending('name', this.props.element.name);
return (
@@ -216,8 +330,9 @@ class General extends React.Component {
{this.canEditLayerName() &&
this.setDraft('name', name)}
onValidationError={this.props.onLayerNameValidationError}
onUpdateEntry={this.updateLayerName}/>}
{includes(this.supportedURLEditLayerTypes, this.props.element.type) &&
@@ -228,10 +343,15 @@ class General extends React.Component {
formatValue={formatURL}
parseValue={parseURL}
required
+ resetKey={editorResetKey}
+ waitForLayerLoad={!!waitForURLLayerLoad}
+ layerLoading={!!this.props.element.loading}
+ layerLoadingError={this.props.element.loadingError}
onValidate={this.validateLayerURL}
- onChange={(url, fields) => this.props.onChange({
+ onDraftChange={(url) => this.setDraft('url', url)}
+ onChange={(url, fields, {forced} = {}) => this.props.onChange({
url,
- ...(this.props.element.type === 'wfs' && { fields })
+ ...(this.props.element.type === 'wfs' && { fields: forced ? undefined : fields })
})} />}
@@ -321,32 +441,30 @@ class General extends React.Component {
labelId="layerProperties.url"
value={this.props.element.search?.url}
required
- onValidate={(url) => this.validateLinkedWFS({
- ...this.props.element.search,
- url
- })}
- onChange={(url, fields) => this.props.onChange({
+ resetKey={editorResetKey}
+ onValidate={(url) => this.validateLinkedWFS({url})}
+ onDraftChange={(url) => this.setDraft('searchUrl', url)}
+ onChange={(url, fields, {forced} = {}) => this.props.onChange({
search: {
...this.props.element.search,
url
},
- fields
+ fields: forced ? undefined : fields
})} />
this.validateLinkedWFS({
- ...this.props.element.search,
- typeName
- })}
- onChange={(typeName, fields) => this.props.onChange({
+ resetKey={editorResetKey}
+ onValidate={(typeName) => this.validateLinkedWFS({typeName})}
+ onDraftChange={(typeName) => this.setDraft('searchTypeName', typeName)}
+ onChange={(typeName, fields, {forced} = {}) => this.props.onChange({
search: {
...this.props.element.search,
typeName
},
- fields
+ fields: forced ? undefined : fields
})} />
}
@@ -359,10 +477,18 @@ class General extends React.Component {
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({
- [key]: event.target.value,
- ...(properties || {})
- });
+ updateLayerName = (key, event, properties, {forced} = {}) => {
+ const {element = {}} = this.props;
+ const controlsWFSSchema = element.type === 'wfs'
+ || element.type === 'wms'
+ && element.search?.type === 'wfs'
+ && isNil(element.search.typeName);
+ this.props.onChange({
+ [key]: event.target.value,
+ ...(properties || {}),
+ ...(forced && controlsWFSSchema && {fields: undefined})
+ });
+ };
updateTitle = (title) => this.props.onChange("title", title);
findGroupLabel = () => {
diff --git a/web/client/components/TOC/fragments/settings/LayerNameEditField.jsx b/web/client/components/TOC/fragments/settings/LayerNameEditField.jsx
index 27bd6789f8..aa70dc8d37 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,
+ onDraftChange = () => {},
onValidationError = () => {},
onUpdateEntry = () => {}
}) => {
@@ -38,13 +39,19 @@ const LayerNameEditField = ({
}
if (editingLayerName) {
if (layerName !== element.name) {
+ if (layerError && !layerError?.required) {
+ onUpdateEntry('name', {target: {value: layerName}}, undefined, {forced: true});
+ setLayerError();
+ setEditingLayerName(false);
+ return;
+ }
setLayerError();
if (enableLayerNameEditFeedback || onValidate) {
setWaitingForLayerLoading(true);
}
- const updateLayerName = (validationResult) => {
- onUpdateEntry('name', {target: {value: layerName}}, validationResult);
- if (!enableLayerNameEditFeedback) {
+ const updateLayerName = (validationResult, metadata = {}) => {
+ onUpdateEntry('name', {target: {value: layerName}}, validationResult, metadata);
+ if (metadata.forced || !enableLayerNameEditFeedback) {
setWaitingForLayerLoading(false);
setEditingLayerName(false);
}
@@ -55,13 +62,17 @@ const LayerNameEditField = ({
.then(updateLayerName)
.catch((error) => {
setWaitingForLayerLoading(false);
- setLayerError(true);
+ setLayerError(error || true);
setEditingLayerName(true);
onValidationError(error);
});
} else {
updateLayerName();
}
+ } else if (layerError) {
+ onUpdateEntry('name', {target: {value: layerName}}, undefined, {forced: true});
+ setLayerError();
+ setEditingLayerName(false);
} else {
setLayerError();
setEditingLayerName(false);
@@ -80,7 +91,9 @@ const LayerNameEditField = ({
const overlayTriggerNameEdit = button => (
-
+
}>
{button}
@@ -96,7 +109,11 @@ const LayerNameEditField = ({
key="name"
type="text"
disabled={!editingLayerName}
- onChange={evt => setLayerName(evt.target.value)} />
+ onChange={evt => {
+ setLayerError();
+ setLayerName(evt.target.value);
+ onDraftChange(evt.target.value);
+ }} />
{enableOverlayTrigger ? overlayTriggerNameEdit(editButton) : editButton}
@@ -129,17 +146,27 @@ export default compose(
componentDidMount() {
this.props.setLayerName(this.props.element?.name);
},
- componentDidUpdate() {
+ componentDidUpdate(prevProps) {
const {
element = {},
waitingForLayerLoading,
waitingForLayerLoad,
+ setLayerName = () => {},
setWaitingForLayerLoad = () => {},
setWaitingForLayerLoading = () => {},
setEditingLayerName = () => {},
setLayerError = () => {}
} = this.props;
+ if (prevProps.element?.id !== element.id) {
+ setLayerName(element.name);
+ setWaitingForLayerLoading(false);
+ setWaitingForLayerLoad(false);
+ setEditingLayerName(false);
+ setLayerError();
+ return;
+ }
+
if (waitingForLayerLoading && element.loading) {
setWaitingForLayerLoading(false);
setWaitingForLayerLoad(true);
diff --git a/web/client/components/TOC/fragments/settings/__tests__/EditableTextField-test.jsx b/web/client/components/TOC/fragments/settings/__tests__/EditableTextField-test.jsx
new file mode 100644
index 0000000000..7d48830165
--- /dev/null
+++ b/web/client/components/TOC/fragments/settings/__tests__/EditableTextField-test.jsx
@@ -0,0 +1,145 @@
+/*
+ * 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 expect from 'expect';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import ReactTestUtils from 'react-dom/test-utils';
+import { waitFor } from '@testing-library/react';
+
+import EditableTextField from '../EditableTextField';
+
+describe('EditableTextField component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+
+ const getInput = () => document.querySelector('[data-qa="editable-value"]');
+ const getButton = () => document.querySelector('[data-qa="editable-value-edit"]');
+
+ const editValue = (value) => {
+ ReactTestUtils.Simulate.click(getButton());
+ ReactTestUtils.Simulate.change(getInput(), {target: {value}});
+ ReactTestUtils.Simulate.click(getButton());
+ };
+
+ it('force saves an unchanged value on the second click after validation fails', (done) => {
+ const validationError = new Error('Service unavailable');
+ const onValidate = expect.createSpy().andReturn(Promise.reject(validationError));
+ const onChange = expect.createSpy();
+ ReactDOM.render(, document.getElementById('container'));
+
+ editValue('new');
+
+ waitFor(() => expect(getInput().closest('.form-group').classList.contains('has-error')).toBe(true))
+ .then(() => {
+ expect(onValidate.calls.length).toBe(1);
+ expect(onChange).toNotHaveBeenCalled();
+ ReactTestUtils.Simulate.mouseOver(getButton());
+ return waitFor(() => expect(document.body.innerText)
+ .toContain('layerProperties.tooltip.confirmValue'));
+ })
+ .then(() => {
+ ReactTestUtils.Simulate.click(getButton());
+ expect(onValidate.calls.length).toBe(1);
+ expect(onChange).toHaveBeenCalledWith('new', undefined, {forced: true});
+ expect(getInput().getAttribute('disabled')).toNotBe(null);
+ done();
+ })
+ .catch(done);
+ });
+
+ it('validates again when the value changes after a failure', (done) => {
+ const onValidate = expect.createSpy().andCall((value) => value === 'bad'
+ ? Promise.reject(new Error('Invalid value'))
+ : Promise.resolve('metadata'));
+ const onChange = expect.createSpy();
+ ReactDOM.render(, document.getElementById('container'));
+
+ editValue('bad');
+
+ waitFor(() => expect(getInput().closest('.form-group').classList.contains('has-error')).toBe(true))
+ .then(() => {
+ ReactTestUtils.Simulate.change(getInput(), {target: {value: 'good'}});
+ ReactTestUtils.Simulate.click(getButton());
+ return waitFor(() => expect(onChange).toHaveBeenCalled());
+ })
+ .then(() => {
+ expect(onValidate.calls.length).toBe(2);
+ expect(onChange).toHaveBeenCalledWith('good', 'metadata', {forced: false});
+ done();
+ })
+ .catch(done);
+ });
+
+ it('does not force save an empty required value', () => {
+ const onValidate = expect.createSpy();
+ const onChange = expect.createSpy();
+ ReactDOM.render(, document.getElementById('container'));
+
+ editValue('');
+ ReactTestUtils.Simulate.click(getButton());
+
+ expect(onValidate).toNotHaveBeenCalled();
+ expect(onChange).toNotHaveBeenCalled();
+ expect(getInput().closest('.form-group').classList.contains('has-error')).toBe(true);
+ });
+
+ it('keeps editing until the updated layer finishes loading', (done) => {
+ const onChange = expect.createSpy();
+ const render = (props = {}) => ReactDOM.render( Promise.resolve()}
+ onChange={onChange}
+ {...props}/>, document.getElementById('container'));
+ render();
+ editValue('new');
+
+ waitFor(() => expect(onChange).toHaveBeenCalled())
+ .then(() => {
+ render({value: 'new', layerLoading: true});
+ render({value: 'new', layerLoading: false, layerLoadingError: true});
+ return waitFor(() => expect(getInput().closest('.form-group').classList.contains('has-error')).toBe(true));
+ })
+ .then(() => {
+ expect(getInput().getAttribute('disabled')).toBe(null);
+ ReactTestUtils.Simulate.click(getButton());
+ expect(getInput().getAttribute('disabled')).toNotBe(null);
+ expect(onChange.calls.length).toBe(2);
+ expect(onChange.calls[1].arguments).toEqual(['new', undefined, {forced: true}]);
+ done();
+ })
+ .catch(done);
+ });
+});
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 70166f36e9..d746949f53 100644
--- a/web/client/components/TOC/fragments/settings/__tests__/General-test.jsx
+++ b/web/client/components/TOC/fragments/settings/__tests__/General-test.jsx
@@ -24,12 +24,20 @@ const WMS_CAPABILITIES = `
`;
+const getWMSCapabilities = (name) => `
+
+
+ image/png
+ ${name}Layer
+
+`;
+
const WFS_PROPERTIES = [
{name: 'shared', localType: 'string'},
{name: 'new-field', localType: 'number'}
];
const WFS_DESCRIBE = {
- featureTypes: ['workspace:linked', 'workspace:layer', 'workspace:renamed']
+ featureTypes: ['workspace:linked', 'workspace:layer', 'workspace:renamed', 'workspace:new']
.map((typeName) => ({typeName, properties: WFS_PROPERTIES}))
};
@@ -166,6 +174,9 @@ describe('test Layer Properties General module component', () => {
});
it('refreshes linked WFS fields when its type name follows the WMS layer name', (done) => {
mockAxios.onGet().reply((config) => {
+ if (decodeURIComponent(config.url).includes('GetCapabilities')) {
+ return [200, getWMSCapabilities('topp:new')];
+ }
expect(decodeURIComponent(config.url)).toContain('typeName=topp:new');
return [200, {
featureTypes: [{
@@ -198,7 +209,8 @@ describe('test Layer Properties General module component', () => {
done(error);
});
});
- it('does not refresh linked WFS fields when it has an explicit type name', () => {
+ it('does not refresh linked WFS fields when it has an explicit type name', (done) => {
+ mockAxios.onGet().reply(200, getWMSCapabilities('topp:new'));
const handlers = {onChange: () => {}};
const spy = expect.spyOn(handlers, 'onChange');
const element = {
@@ -211,7 +223,13 @@ describe('test Layer Properties General module component', () => {
editLayerName('topp:new');
- expect(spy.calls[0].arguments).toEqual([{name: 'topp:new'}]);
+ waitFor(() => expect(spy).toHaveBeenCalled())
+ .then(() => {
+ expect(spy.calls[0].arguments).toEqual([{name: 'topp:new'}]);
+ expect(mockAxios.history.get.length).toBe(1);
+ done();
+ })
+ .catch(done);
});
it('refreshes ArcGIS FeatureServer schema when changing the layer name', (done) => {
mockAxios.onGet('/arcgis/rest/services/SchemaRefresh/FeatureServer/1').reply(200, {
@@ -470,6 +488,74 @@ describe('test Layer Properties General module component', () => {
done();
});
});
+ it('validates a WMS URL with the current Name draft', (done) => {
+ mockAxios.onGet().reply((config) => {
+ expect(config.url).toContain('new-wms-url');
+ return [200, getWMSCapabilities('workspace:new')];
+ });
+ const onChange = expect.createSpy();
+ ReactDOM.render(, document.getElementById('container'));
+
+ const nameInput = document.querySelector('[data-qa="layer-properties-name"]');
+ const nameEdit = nameInput.parentElement.querySelector('.input-group-addon');
+ ReactTestUtils.Simulate.click(nameEdit);
+ ReactTestUtils.Simulate.change(nameInput, {target: {value: 'workspace:new'}});
+
+ const urlInput = document.querySelector('[data-qa="layer-properties-url"]');
+ const urlEdit = document.querySelector('[data-qa="layer-properties-url-edit"]');
+ ReactTestUtils.Simulate.click(urlEdit);
+ ReactTestUtils.Simulate.change(urlInput, {target: {value: 'new-wms-url'}});
+ ReactTestUtils.Simulate.click(urlEdit);
+
+ waitFor(() => expect(onChange).toHaveBeenCalledWith({url: 'new-wms-url'}))
+ .then(() => done())
+ .catch(done);
+ });
+ it('validates a WMS Name with the current URL draft', (done) => {
+ mockAxios.onGet().reply((config) => {
+ expect(config.url).toContain('new-wms-url');
+ return [200, getWMSCapabilities('workspace:new')];
+ });
+ const onChange = expect.createSpy();
+ ReactDOM.render(, document.getElementById('container'));
+
+ const urlInput = document.querySelector('[data-qa="layer-properties-url"]');
+ ReactTestUtils.Simulate.click(document.querySelector('[data-qa="layer-properties-url-edit"]'));
+ ReactTestUtils.Simulate.change(urlInput, {target: {value: 'new-wms-url'}});
+ editLayerName('workspace:new');
+
+ waitFor(() => expect(onChange).toHaveBeenCalledWith({name: 'workspace:new'}))
+ .then(() => done())
+ .catch(done);
+ });
+ it('does not force save a WMS Name while its required URL is empty', (done) => {
+ const onChange = expect.createSpy();
+ ReactDOM.render(, document.getElementById('container'));
+
+ editLayerName('workspace:new');
+ const nameInput = document.querySelector('[data-qa="layer-properties-name"]');
+ const nameEdit = nameInput.parentElement.querySelector('.input-group-addon');
+ waitFor(() => expect(nameInput.closest('.form-group').classList.contains('has-error')).toBe(true))
+ .then(() => {
+ ReactTestUtils.Simulate.click(nameEdit);
+ return waitFor(() => expect(nameInput.closest('.form-group').classList.contains('has-error')).toBe(true));
+ })
+ .then(() => {
+ expect(onChange).toNotHaveBeenCalled();
+ expect(mockAxios.history.get.length).toBe(0);
+ done();
+ })
+ .catch(done);
+ });
it('edits native WFS name and URL without adding a linked TypeName editor', () => {
ReactDOM.render( {
});
it('validates a native WFS URL and refreshes its fields', (done) => {
mockAxios.onGet().reply((config) => {
- expect(config.url).toContain('new-wfs-url');
+ const requestURL = decodeURIComponent(config.url);
+ expect(requestURL).toContain('new-wfs-url');
+ expect(requestURL).toContain('workspace:new');
expect(config.url).toNotContain('old-describe-url');
expect(config.url).toNotContain('old-search-url');
return [200, WFS_DESCRIBE];
@@ -498,6 +586,9 @@ describe('test Layer Properties General module component', () => {
}}
settings={{options: {opacity: 1}}}
onChange={onChange}/>, document.getElementById("container"));
+ const nameInput = document.querySelector('[data-qa="layer-properties-name"]');
+ ReactTestUtils.Simulate.click(nameInput.parentElement.querySelector('.input-group-addon'));
+ ReactTestUtils.Simulate.change(nameInput, {target: {value: 'workspace:new'}});
const input = document.querySelector('[data-qa="layer-properties-url"]');
const edit = document.querySelector('[data-qa="layer-properties-url-edit"]');
ReactTestUtils.Simulate.click(edit);
@@ -514,6 +605,93 @@ describe('test Layer Properties General module component', () => {
done();
});
});
+ it('validates a native WFS Name with the current URL draft', (done) => {
+ mockAxios.onGet().reply((config) => {
+ const requestURL = decodeURIComponent(config.url);
+ expect(requestURL).toContain('new-wfs-url');
+ expect(requestURL).toContain('workspace:new');
+ return [200, WFS_DESCRIBE];
+ });
+ const onChange = expect.createSpy();
+ ReactDOM.render(, document.getElementById('container'));
+
+ const urlInput = document.querySelector('[data-qa="layer-properties-url"]');
+ ReactTestUtils.Simulate.click(document.querySelector('[data-qa="layer-properties-url-edit"]'));
+ ReactTestUtils.Simulate.change(urlInput, {target: {value: 'new-wfs-url'}});
+ editLayerName('workspace:new');
+
+ waitFor(() => expect(onChange).toHaveBeenCalledWith({
+ name: 'workspace:new',
+ fields: [
+ {name: 'shared', type: 'string'},
+ {name: 'new-field', type: 'number'}
+ ]
+ }))
+ .then(() => done())
+ .catch(done);
+ });
+ it('force saves an invalid native WFS URL and clears its fields', (done) => {
+ mockAxios.onGet().reply(500);
+ const onChange = expect.createSpy();
+ ReactDOM.render(, document.getElementById('container'));
+ const urlInput = document.querySelector('[data-qa="layer-properties-url"]');
+ const urlEdit = document.querySelector('[data-qa="layer-properties-url-edit"]');
+ ReactTestUtils.Simulate.click(urlEdit);
+ ReactTestUtils.Simulate.change(urlInput, {target: {value: 'invalid-wfs-url'}});
+ ReactTestUtils.Simulate.click(urlEdit);
+
+ waitFor(() => expect(urlInput.closest('.form-group').classList.contains('has-error')).toBe(true))
+ .then(() => {
+ ReactTestUtils.Simulate.click(urlEdit);
+ expect(onChange).toHaveBeenCalledWith({
+ url: 'invalid-wfs-url',
+ fields: undefined
+ });
+ done();
+ })
+ .catch(done);
+ });
+ it('clears fields when force saving a WMS Name used as the linked WFS TypeName', (done) => {
+ mockAxios.onGet().reply(500);
+ const onChange = expect.createSpy();
+ ReactDOM.render(, document.getElementById('container'));
+
+ editLayerName('workspace:invalid');
+ const nameInput = document.querySelector('[data-qa="layer-properties-name"]');
+ const nameEdit = nameInput.parentElement.querySelector('.input-group-addon');
+ waitFor(() => expect(nameInput.closest('.form-group').classList.contains('has-error')).toBe(true))
+ .then(() => {
+ ReactTestUtils.Simulate.click(nameEdit);
+ expect(onChange).toHaveBeenCalledWith({
+ name: 'workspace:invalid',
+ fields: undefined
+ });
+ done();
+ })
+ .catch(done);
+ });
it('detects and removes a linked WFS service', (done) => {
mockAxios.onGet().reply(({url}) => url.includes('DescribeLayer')
? [200, {
@@ -575,13 +753,18 @@ describe('test Layer Properties General module component', () => {
settings={{options: {opacity: 1}}}
onChange={onChange}/>, document.getElementById("container"));
expect(document.querySelector('[data-qa="layer-properties-search-type-name"]').value).toBe('workspace:layer');
+ 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'}});
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(decodeURIComponent(requestedURLs[0])).toContain('new-wfs-url');
+ expect(decodeURIComponent(requestedURLs[0])).toContain('workspace:linked');
expect(requestedURLs[0]).toNotContain('old-describe-url');
expect(onChange).toHaveBeenCalledWith({
search: {
@@ -594,13 +777,10 @@ describe('test Layer Properties General module component', () => {
{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(decodeURIComponent(requestedURLs[1])).toContain('new-wfs-url');
+ expect(decodeURIComponent(requestedURLs[1])).toContain('workspace:linked');
expect(requestedURLs[1]).toNotContain('old-describe-url');
expect(onChange).toHaveBeenCalledWith({
search: {
@@ -618,7 +798,7 @@ describe('test Layer Properties General module component', () => {
});
});
});
- it('rejects empty and invalid linked WFS values', (done) => {
+ it('blocks an empty linked WFS value and force saves an invalid value on the second click', (done) => {
mockAxios.onGet().reply(500);
const onChange = expect.createSpy();
ReactDOM.render( {
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();
- });
+ waitFor(() => expect(urlInput.closest('.form-group').classList.contains('has-error')).toBe(true))
+ .then(() => {
+ expect(onChange).toNotHaveBeenCalled();
+ ReactTestUtils.Simulate.click(urlEdit);
+ expect(onChange).toHaveBeenCalledWith({
+ search: {
+ type: 'wfs',
+ url: 'invalid-wfs-url',
+ typeName: 'workspace:layer'
+ },
+ fields: undefined
+ });
+ done();
+ })
+ .catch(done);
+ });
+ it('does not force save a linked WFS field while its related required field is empty', (done) => {
+ 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: 'new-wfs-url'}});
+ ReactTestUtils.Simulate.click(urlEdit);
+
+ waitFor(() => expect(urlInput.closest('.form-group').classList.contains('has-error')).toBe(true))
+ .then(() => {
+ ReactTestUtils.Simulate.click(urlEdit);
+ return waitFor(() => expect(urlInput.closest('.form-group').classList.contains('has-error')).toBe(true));
+ })
+ .then(() => {
+ expect(onChange).toNotHaveBeenCalled();
+ expect(mockAxios.history.get.length).toBe(0);
+ done();
+ })
+ .catch(done);
});
it('leaves linked WFS fields empty when DescribeLayer is unsupported', (done) => {
mockAxios.onGet().reply(500);
@@ -665,7 +885,9 @@ describe('test Layer Properties General module component', () => {
});
});
it('refreshes merged fields when the WMS name supplies the legacy WFS typeName', (done) => {
- mockAxios.onGet().reply(200, WFS_DESCRIBE);
+ mockAxios.onGet().reply(({url}) => decodeURIComponent(url).includes('GetCapabilities')
+ ? [200, getWMSCapabilities('workspace:renamed')]
+ : [200, WFS_DESCRIBE]);
const onChange = expect.createSpy();
ReactDOM.render( {
})
.catch(done);
});
+ it('reports the draft and force saves it on the second click after validation fails', (done) => {
+ const handlers = {
+ onValidate: expect.createSpy().andReturn(Promise.reject(new Error('Invalid layer name'))),
+ onDraftChange: expect.createSpy(),
+ onUpdateEntry: expect.createSpy()
+ };
+ ReactDOM.render(
+ ,
+ document.getElementById('container')
+ );
+ ReactTestUtils.Simulate.click(document.querySelector('.input-group-addon'));
+ ReactTestUtils.Simulate.change(document.querySelector('input'), {target: {value: 'invalid-name'}});
+ ReactTestUtils.Simulate.click(document.querySelector('.input-group-addon'));
+
+ waitFor(() => expect(document.querySelector('.form-group').classList.contains('has-error')).toBe(true))
+ .then(() => {
+ expect(handlers.onDraftChange).toHaveBeenCalledWith('invalid-name');
+ ReactTestUtils.Simulate.click(document.querySelector('.input-group-addon'));
+ expect(handlers.onValidate.calls.length).toBe(1);
+ expect(handlers.onUpdateEntry).toHaveBeenCalledWith(
+ 'name',
+ {target: {value: 'invalid-name'}},
+ undefined,
+ {forced: true}
+ );
+ done();
+ })
+ .catch(done);
+ });
+ it('does not force save a value when validation reports a required field', (done) => {
+ const requiredError = new Error('Required value');
+ requiredError.required = true;
+ const handlers = {
+ onValidate: expect.createSpy().andReturn(Promise.reject(requiredError)),
+ onUpdateEntry: expect.createSpy()
+ };
+ ReactDOM.render(
+ ,
+ document.getElementById('container')
+ );
+ ReactTestUtils.Simulate.click(document.querySelector('.input-group-addon'));
+ ReactTestUtils.Simulate.change(document.querySelector('input'), {target: {value: 'new-name'}});
+ ReactTestUtils.Simulate.click(document.querySelector('.input-group-addon'));
+
+ waitFor(() => expect(document.querySelector('.form-group').classList.contains('has-error')).toBe(true))
+ .then(() => {
+ ReactTestUtils.Simulate.click(document.querySelector('.input-group-addon'));
+ return waitFor(() => expect(handlers.onValidate.calls.length).toBe(2));
+ })
+ .then(() => {
+ expect(handlers.onUpdateEntry).toNotHaveBeenCalled();
+ expect(document.querySelector('input').getAttribute('disabled')).toBe(null);
+ done();
+ })
+ .catch(done);
+ });
});
diff --git a/web/client/translations/data.da-DK.json b/web/client/translations/data.da-DK.json
index 7f983ec467..ace5f657ab 100644
--- a/web/client/translations/data.da-DK.json
+++ b/web/client/translations/data.da-DK.json
@@ -220,7 +220,10 @@
"bottom": "Nederst",
"top": "Øverst",
"editLayerName": "Rediger lagets navn",
- "confirmLayerName": "Bekræft ændring af lagets navn"
+ "confirmLayerName": "Bekræft ændring af lagets navn",
+ "editValue": "Rediger værdi",
+ "confirmValue": "Bekræft ændring af værdi",
+ "requiredValue": "En værdi er påkrævet"
},
"legendOptions": {
"title": "Signaturforklaring",
diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json
index e1378946ab..c479ddd201 100644
--- a/web/client/translations/data.de-DE.json
+++ b/web/client/translations/data.de-DE.json
@@ -267,7 +267,10 @@
"bottom": "Unten",
"top": "Oben",
"editLayerName": "Ebenennamen bearbeiten",
- "confirmLayerName": "Bestätigen Sie die Änderung des Ebenennamens"
+ "confirmLayerName": "Bestätigen Sie die Änderung des Ebenennamens",
+ "editValue": "Wert bearbeiten",
+ "confirmValue": "Wertänderung bestätigen",
+ "requiredValue": "Ein Wert ist erforderlich"
},
"legendOptions": {
"title": "Legende",
diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json
index e0ffa72ace..650d8592b4 100644
--- a/web/client/translations/data.en-US.json
+++ b/web/client/translations/data.en-US.json
@@ -267,7 +267,10 @@
"bottom": "Bottom",
"top": "Top",
"editLayerName": "Edit layer name",
- "confirmLayerName": "Confirm layer name change"
+ "confirmLayerName": "Confirm layer name change",
+ "editValue": "Edit value",
+ "confirmValue": "Confirm value change",
+ "requiredValue": "A value is required"
},
"legendOptions": {
"title": "Legend",
diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json
index 81022d722c..138d7b895d 100644
--- a/web/client/translations/data.es-ES.json
+++ b/web/client/translations/data.es-ES.json
@@ -266,7 +266,10 @@
"bottom": "Bottom",
"top": "Cima",
"editLayerName": "Editar el nombre de la capa",
- "confirmLayerName": "Confirmar cambio de nombre de capa"
+ "confirmLayerName": "Confirmar cambio de nombre de capa",
+ "editValue": "Editar valor",
+ "confirmValue": "Confirmar cambio de valor",
+ "requiredValue": "Se requiere un valor"
},
"legendOptions": {
"title": "Leyenda",
diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json
index 11b9183e0d..a0c2a17459 100644
--- a/web/client/translations/data.fr-FR.json
+++ b/web/client/translations/data.fr-FR.json
@@ -267,7 +267,10 @@
"bottom": "Bas",
"top": "Haut",
"editLayerName": "Modifier le nom du calque",
- "confirmLayerName": "Confirmer le changement de nom du calque"
+ "confirmLayerName": "Confirmer le changement de nom du calque",
+ "editValue": "Modifier la valeur",
+ "confirmValue": "Confirmer la modification de la valeur",
+ "requiredValue": "Une valeur est requise"
},
"legendOptions": {
"title": "Légende",
diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json
index b7b1ceeb4b..bd7af5de7a 100644
--- a/web/client/translations/data.it-IT.json
+++ b/web/client/translations/data.it-IT.json
@@ -267,7 +267,10 @@
"bottom": "Sotto",
"top": "Sopra",
"editLayerName": "Modifica il nome del livello",
- "confirmLayerName": "Conferma la modifica del nome del livello"
+ "confirmLayerName": "Conferma la modifica del nome del livello",
+ "editValue": "Modifica valore",
+ "confirmValue": "Conferma modifica del valore",
+ "requiredValue": "È richiesto un valore"
},
"legendOptions": {
"title": "Legenda",