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",