diff --git a/package.json b/package.json index cb08d2f15e..284bf01fba 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,6 @@ "@carnesen/redux-add-action-listener-enhancer": "0.0.1", "@geosolutions/geostyler-geocss-parser": "1.0.0", "@geosolutions/geostyler-sld-parser": "2.0.1-2", - "proj4": "2.19.10", "@geosolutions/react-joyride": "1.10.2", "@googlemaps/js-api-loader": "1.12.9", "@mapbox/geojsonhint": "3.3.0", @@ -142,6 +141,7 @@ "@turf/point-on-surface": "4.1.0", "@turf/polygon-to-linestring": "4.1.0", "@znemz/cesium-navigation": "4.0.0", + "ajv": "8.17.1", "assert": "2.0.0", "axios": "0.30.0", "babel-polyfill": "6.8.0", @@ -201,6 +201,7 @@ "ol": "7.4.0", "pdfmake": "0.2.7", "plotly.js-cartesian-dist": "2.35.2", + "proj4": "2.19.10", "prop-types": "15.7.2", "qrcode.react": "0.9.3", "query-string": "6.9.0", diff --git a/web/client/components/I18N/IntlNumberFormControl.jsx b/web/client/components/I18N/IntlNumberFormControl.jsx index 8dfea3fd98..729ff162d0 100644 --- a/web/client/components/I18N/IntlNumberFormControl.jsx +++ b/web/client/components/I18N/IntlNumberFormControl.jsx @@ -137,7 +137,7 @@ class IntlNumberFormControl extends React.Component { parse = value => { let formatValue = value; // eslint-disable-next-line use-isnan - if (formatValue !== NaN && formatValue !== "NaN") { // Allow locale string to parse + if (formatValue !== '' && formatValue !== NaN && formatValue !== "NaN") { // Allow locale string to parse const locale = this.context && this.context.intl && this.context.intl.locale || "en-US"; const format = new Intl.NumberFormat(locale); const parts = format.formatToParts(12345.6); @@ -164,7 +164,7 @@ class IntlNumberFormControl extends React.Component { }; format = val => { - if (!isNaN(val) && val !== "NaN") { + if (val !== '' && !isNaN(val) && val !== "NaN") { const locale = this.context && this.context.intl && this.context.intl.locale || "en-US"; const formatter = new Intl.NumberFormat(locale, {minimumFractionDigits: 0, maximumFractionDigits: 20}); return formatter.format(val); diff --git a/web/client/components/data/featuregrid/FeatureGrid.jsx b/web/client/components/data/featuregrid/FeatureGrid.jsx index 19168b7e20..083ae38ece 100644 --- a/web/client/components/data/featuregrid/FeatureGrid.jsx +++ b/web/client/components/data/featuregrid/FeatureGrid.jsx @@ -80,7 +80,18 @@ class FeatureGrid extends React.PureComponent { this.props.changes[id].hasOwnProperty(key); }, isProperty: (k) => k === "geometry" || isProperty(k, this.props.describeFeatureType), - isValid: (val, key) => this.props.describeFeatureType ? isValidValueForPropertyName(val, key, this.props.describeFeatureType) : true + isValid: (val, key, rowId) => { + const { errors = [], changed } = (this.props?.validationErrors?.[rowId] || {}); + // Extract field name from instancePath or dataPath (e.g., "/fid" -> "fid") + const error = errors.find((err) => { + const path = err.instancePath || err.dataPath || ''; + return path.replace(/^[./]/, '') === key; + }); + if (error) { + return { valid: false, message: error?.message, changed }; + } + return { valid: this.props.describeFeatureType ? isValidValueForPropertyName(val, key, this.props.describeFeatureType) : false }; + } }; } render() { diff --git a/web/client/components/data/featuregrid/editors/EnumerateEditor.jsx b/web/client/components/data/featuregrid/editors/EnumerateEditor.jsx new file mode 100644 index 0000000000..eafed1640f --- /dev/null +++ b/web/client/components/data/featuregrid/editors/EnumerateEditor.jsx @@ -0,0 +1,74 @@ +/* + * Copyright 2025, 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 React from 'react'; +import PropTypes from 'prop-types'; +import { Combobox } from 'react-widgets'; +import AttributeEditor from './AttributeEditor'; +import { isNil } from 'lodash'; + +const EnumerateEditorItem = (props) => { + const { value, label } = props.item || {}; + return value === null ? : label; +}; +/** + * Editor of the FeatureGrid, that allows to enumerate options for current property + * @memberof components.data.featuregrid.editors + * @name EnumerateEditor + * @class + */ +export default class EnumerateEditor extends AttributeEditor { + static propTypes = { + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.null + ]), + schema: PropTypes.object, + column: PropTypes.object, + onTemporaryChanges: PropTypes.func + }; + + static defaultProps = { + column: {} + }; + + constructor(props) { + super(props); + this.state = { selected: this.getOption(props.value) }; + } + + getOption = (value) => { + return { value, label: isNil(value) ? '' : `${value}` }; + } + + getValue = () => { + return { + [this.props.column.key]: this.state?.selected?.value + }; + } + + render() { + const options = (this.props?.schema?.enum || []); + const isValid = options.includes(this.state?.selected?.value); + return ( +
+ { + this.setState({ selected: selected ? selected : this.getOption(null) }); + }} + /> +
+ ); + } +} diff --git a/web/client/components/data/featuregrid/editors/NumberEditor.jsx b/web/client/components/data/featuregrid/editors/NumberEditor.jsx index 4e9c506fae..8a98625b19 100644 --- a/web/client/components/data/featuregrid/editors/NumberEditor.jsx +++ b/web/client/components/data/featuregrid/editors/NumberEditor.jsx @@ -8,7 +8,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {isNumber} from 'lodash'; +import { isNumber, castArray } from 'lodash'; import IntlNumberFormControl from '../../../I18N/IntlNumberFormControl'; import { editors } from 'react-data-grid'; @@ -29,7 +29,9 @@ export default class NumberEditor extends editors.SimpleTextEditor { static propTypes = { value: PropTypes.oneOfType([ PropTypes.string, - PropTypes.number]), + PropTypes.number, + PropTypes.null + ]), inputProps: PropTypes.object, dataType: PropTypes.string, minValue: PropTypes.number, @@ -45,12 +47,14 @@ export default class NumberEditor extends editors.SimpleTextEditor { constructor(props) { super(props); - - this.state = {inputText: props.value?.toString?.() ?? ''}; + const value = props.value?.toString?.() ?? ''; + this.state = { + inputText: value, + isValid: this.validateTextValue(value), + validated: true + }; } - state = {inputText: ''}; - componentDidMount() { this.props.onTemporaryChanges?.(true); } @@ -62,9 +66,9 @@ export default class NumberEditor extends editors.SimpleTextEditor { getValue() { try { - const numberValue = parsers[this.props.dataType](this.state.inputText); + const numberValue = this.state.inputText === '' ? null : parsers[this.props.dataType](this.state.inputText); return { - [this.props.column.key]: this.validateNumberValue(numberValue) ? numberValue : this.props.value + [this.props.column.key]: numberValue }; } catch (e) { return { @@ -73,16 +77,21 @@ export default class NumberEditor extends editors.SimpleTextEditor { } } + getMinValue() { + return this.props?.column?.schema?.minimum ?? this.props.minValue; + } + + getMaxValue() { + return this.props?.column?.schema?.maximum ?? this.props.maxValue; + } + render() { - return (); + />); } validateTextValue = (value) => { + if (value === '') { + return castArray(this.props?.column?.schema?.type || []).includes('null'); + } if (!parsers[this.props.dataType]) { return false; } - try { const numberValue = parsers[this.props.dataType](value); @@ -112,9 +123,11 @@ export default class NumberEditor extends editors.SimpleTextEditor { }; validateNumberValue = (value) => { + const minValue = this.getMinValue(); + const maxValue = this.getMaxValue(); return isNumber(value) && !isNaN(value) && - (!isNumber(this.props.minValue) || this.props.minValue <= value) && - (!isNumber(this.props.maxValue) || this.props.maxValue >= value); + (!isNumber(minValue) || minValue <= value) && + (!isNumber(maxValue) || maxValue >= value); }; } diff --git a/web/client/components/data/featuregrid/editors/__tests__/EnumerateEditor-test.jsx b/web/client/components/data/featuregrid/editors/__tests__/EnumerateEditor-test.jsx new file mode 100644 index 0000000000..d419809473 --- /dev/null +++ b/web/client/components/data/featuregrid/editors/__tests__/EnumerateEditor-test.jsx @@ -0,0 +1,341 @@ +/* + * Copyright 2025, 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 EnumerateEditor from '../EnumerateEditor'; + +let testColumn = { + key: 'columnKey' +}; + +describe('FeatureGrid EnumerateEditor component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('should render with valid value from enum', () => { + const schema = { + 'enum': ['option1', 'option2', 'option3'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe('option1'); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + expect(editorDiv.className).toNotInclude('invalid'); + }); + + it('should render with invalid value not in enum', () => { + const schema = { + 'enum': ['option1', 'option2', 'option3'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe('invalidOption'); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + expect(editorDiv.className).toInclude('invalid'); + }); + + it('should handle null value', () => { + const schema = { + 'enum': ['option1', 'option2', null] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe(null); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + // null is in enum, so should be valid + expect(editorDiv.className).toNotInclude('invalid'); + }); + + it('should handle null value when not in enum', () => { + const schema = { + 'enum': ['option1', 'option2'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe(null); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + // null is not in enum, so should be invalid + expect(editorDiv.className).toInclude('invalid'); + }); + + it('should handle number values in enum', () => { + const schema = { + 'enum': [1, 2, 3] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe(2); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + expect(editorDiv.className).toNotInclude('invalid'); + }); + + it('should handle empty enum array', () => { + const schema = { + 'enum': [] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe('anyValue'); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + // Empty enum means no valid options, so any value is invalid + expect(editorDiv.className).toInclude('invalid'); + }); + + it('should handle missing schema', () => { + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe('value'); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + // No enum means no valid options + expect(editorDiv.className).toInclude('invalid'); + }); + + it('should handle undefined value', () => { + const schema = { + 'enum': ['option1', 'option2'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe(undefined); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + // undefined is not in enum, so should be invalid + expect(editorDiv.className).toInclude('invalid'); + }); + + it('should call getOption correctly for null value', () => { + const schema = { + 'enum': ['option1', null] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + const option = cmp.getOption(null); + expect(option.value).toBe(null); + expect(option.label).toBe(''); + }); + + it('should call getOption correctly for string value', () => { + const schema = { + 'enum': ['option1', 'option2'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + const option = cmp.getOption('test'); + expect(option.value).toBe('test'); + expect(option.label).toBe('test'); + }); + + it('should call getOption correctly for number value', () => { + const schema = { + 'enum': [1, 2, 3] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + const option = cmp.getOption(42); + expect(option.value).toBe(42); + expect(option.label).toBe('42'); + }); + + it('should handle onTemporaryChanges callback', (done) => { + const onTemporaryChanges = () => done(); + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + it('should handle column without key', () => { + const schema = { + 'enum': ['option1', 'option2'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + const result = cmp.getValue(); + expect(result).toBeTruthy(); + expect(result.undefined).toBe('option1'); + }); + + it('should initialize state with value prop', () => { + const schema = { + 'enum': ['option1', 'option2', 'option3'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp.state.selected.value).toBe('option1'); + }); + + it('should initialize state with different value when remounted', () => { + const schema = { + 'enum': ['option1', 'option2', 'option3'] + }; + let cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp.state.selected.value).toBe('option1'); + + // Unmount to create a fresh instance + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + + // Remount with different value + cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp.state.selected.value).toBe('option2'); + }); + + it('should handle mixed enum types (string and number)', () => { + const schema = { + 'enum': ['option1', 2, 'option3'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe(2); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + expect(editorDiv.className).toNotInclude('invalid'); + }); +}); + diff --git a/web/client/components/data/featuregrid/editors/__tests__/NumberEditor-test.jsx b/web/client/components/data/featuregrid/editors/__tests__/NumberEditor-test.jsx index 685659f18d..01d72359d6 100644 --- a/web/client/components/data/featuregrid/editors/__tests__/NumberEditor-test.jsx +++ b/web/client/components/data/featuregrid/editors/__tests__/NumberEditor-test.jsx @@ -61,8 +61,9 @@ describe('FeatureGrid NumberEditor/IntegerEditor component', () => { expect(inputElement.value).toBe('1.1'); TestUtils.Simulate.change(inputElement, {target: {value: '1.6'}}); - expect(cmp.getValue().columnKey).toBe(1.1); + expect(cmp.getValue().columnKey).toBe(1.6); expect(cmp.state.isValid).toBe(false); + expect(cmp.state.validated).toBe(true); }); it('Number Editor passed validation', () => { const cmp = ReactDOM.render( { + return !!props?.schema?.enum?.length; +}; + +// Create number editor (int or number type) +const createNumberEditor = (dataType) => (props) => { + return shouldUseEnumeratorComponent(props) + ? + : ; +}; + +// Create string editor +const createStringEditor = (props) => { + if (shouldUseEnumeratorComponent(props)) { + return ; + } + if (props.autocompleteEnabled) { + return ; + } + return ; +}; const types = { - "defaultEditor": (props) => , - "int": (props) => , - "number": (props) => , - "string": (props) => props.autocompleteEnabled ? - : - , - "boolean": (props) => , + "defaultEditor": (props) => , + "int": createNumberEditor("int"), + "number": createNumberEditor("number"), + "string": createStringEditor, + "boolean": (props) => ( + + ), "date-time": (props) => , - "date": (props) => , + "date": (props) => , "time": (props) => }; + export default (type, props) => types[type] ? types[type](props) : types.defaultEditor(props); diff --git a/web/client/components/data/featuregrid/enhancers/editor.js b/web/client/components/data/featuregrid/enhancers/editor.js index c5a3635344..734553772e 100644 --- a/web/client/components/data/featuregrid/enhancers/editor.js +++ b/web/client/components/data/featuregrid/enhancers/editor.js @@ -128,15 +128,18 @@ const featuresToGrid = compose( withPropsOnChange( ["features", "newFeatures", "isFocused", "virtualScroll", "pagination"], props => { - const rowsCount = (props.isFocused || !props.virtualScroll) && props.rows && props.rows.length || (props.pagination && props.pagination.totalFeatures) || 0; + const rowsCount = (props.isFocused || !props.virtualScroll) && props.rows && props.rows.length + || (props.pagination && props.pagination.totalFeatures) + || 0; + const newFeaturesLength = props?.newFeatures?.length || 0; return { - rowsCount + rowsCount: rowsCount + newFeaturesLength }; } ), withHandlers({rowGetter: props => props.virtualScroll && (i => getRowVirtual(i, props.rows, props.pages, props.size)) || (i => getRow(i, props.rows))}), withPropsOnChange( - ["describeFeatureType", "fields", "columnSettings", "tools", "actionOpts", "mode", "isFocused", "sortable"], + ["describeFeatureType", "fields", "columnSettings", "tools", "actionOpts", "mode", "isFocused", "sortable", "featurePropertiesJSONSchema", "primaryKeyAttributes"], props => { const getFilterRendererFunc = ({name}) => { if (props.filterRenderers && props.filterRenderers[name]) { @@ -145,22 +148,23 @@ const featuresToGrid = compose( // return empty component if no filter renderer is defined, to avoid failures return () => null; }; - const result = ({ columns: getToolColumns(props.tools, props.rowGetter, props.describeFeatureType, props.actionOpts, getFilterRendererFunc) - .concat(featureTypeToGridColumns(props.describeFeatureType, props.columnSettings, props.fields, { + .concat(featureTypeToGridColumns(props.describeFeatureType, props.featurePropertiesJSONSchema, props.columnSettings, props.fields, { editable: props.mode === "EDIT", sortable: props.sortable && !props.isFocused, defaultSize: props.defaultSize, - options: props.options?.propertyName + options: props.options?.propertyName, + primaryKeyAttributes: props.primaryKeyAttributes || [] }, { getHeaderRenderer, - getEditor: (desc) => { + getEditor: (desc, filed, schema) => { const generalProps = { onTemporaryChanges: props.gridEvents && props.gridEvents.onTemporaryChanges, autocompleteEnabled: props.autocompleteEnabled, url: props.url, - typeName: props.typeName + typeName: props.typeName, + schema }; const regexProps = {attribute: desc.name, url: props.url, typeName: props.typeName}; const rules = props.customEditorsOptions && props.customEditorsOptions.rules || []; diff --git a/web/client/components/data/featuregrid/renderers/CellRenderer.jsx b/web/client/components/data/featuregrid/renderers/CellRenderer.jsx index d8157f745c..e3834d06b1 100644 --- a/web/client/components/data/featuregrid/renderers/CellRenderer.jsx +++ b/web/client/components/data/featuregrid/renderers/CellRenderer.jsx @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Cell } from 'react-data-grid'; +import CellValidationErrorMessage from './CellValidationErrorMessage'; class CellRenderer extends React.Component { static propTypes = { @@ -11,7 +12,8 @@ class CellRenderer extends React.Component { static contextTypes = { isModified: PropTypes.func, isProperty: PropTypes.func, - isValid: PropTypes.func + isValid: PropTypes.func, + cellControls: PropTypes.any }; static defaultProps = { value: null, @@ -23,12 +25,36 @@ class CellRenderer extends React.Component { this.setScrollLeft = (scrollBy) => this.refs.cell.setScrollLeft(scrollBy); } render() { + const value = this.props.rowData.get(this.props.column.key); const isProperty = this.context.isProperty(this.props.column.key); const isModified = (this.props.rowData._new && isProperty) || this.context.isModified(this.props.rowData.id, this.props.column.key); - const isValid = isProperty ? this.context.isValid(this.props.rowData.get(this.props.column.key), this.props.column.key) : true; - const className = (isModified ? ['modified'] : []) - .concat(isValid ? [] : ['invalid']).join(" "); - return ; + const { valid, message, changed } = isProperty + ? this.context.isValid(value, this.props.column.key, this.props.rowData.id) + : { valid: true }; + const isPrimaryKey = this.props.column?.isPrimaryKey; + const className = [ + ...(isModified ? ['modified'] : []), + ...(valid ? [] : ['invalid']), + ...(isPrimaryKey ? ['primary-key'] : []) + ].join(" "); + return ( + + {this.props.cellControls} + + } + /> + ); } } diff --git a/web/client/components/data/featuregrid/renderers/CellValidationErrorMessage.jsx b/web/client/components/data/featuregrid/renderers/CellValidationErrorMessage.jsx new file mode 100644 index 0000000000..87fe7a4ad5 --- /dev/null +++ b/web/client/components/data/featuregrid/renderers/CellValidationErrorMessage.jsx @@ -0,0 +1,63 @@ +/* + * Copyright 2025, 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 React from 'react'; +import PropTypes from 'prop-types'; +import withTooltip from '../../../misc/enhancers/tooltip'; +import { Glyphicon } from 'react-bootstrap'; +import { isNil } from 'lodash'; +import { getRestrictionsMessageInfo } from '../../../../utils/FeatureGridUtils'; +import Message from '../../../I18N/Message'; + +const GlyphiconIndicator = withTooltip(Glyphicon); + +const CellValidationErrorMessage = ({ + value, + valid, + column, + changed +}) => { + + if (valid || column.key === 'geometry') { + return null; + } + const restrictionsMessageInfo = getRestrictionsMessageInfo(column?.schema, column?.schemaRequired); + const isPrimaryKey = column?.isPrimaryKey; + return ( + <> + {/* when the value is empty we need a placeholder to fill the height of the field */} + {value === '' || isNil(value) ? : null} + + :
+ {(restrictionsMessageInfo?.msgIds || []).map(msgId => +
)} +
+ } + glyph="exclamation-mark" + /> + + ); +}; + +CellValidationErrorMessage.propTypes = { + value: PropTypes.any, + valid: PropTypes.bool, + changed: PropTypes.bool, + column: PropTypes.object +}; + +CellValidationErrorMessage.defaultProps = { + value: null, + column: {} +}; + +export default CellValidationErrorMessage; diff --git a/web/client/components/data/featuregrid/renderers/__tests__/CellValidationErrorMessage-test.jsx b/web/client/components/data/featuregrid/renderers/__tests__/CellValidationErrorMessage-test.jsx new file mode 100644 index 0000000000..d3e5412a1f --- /dev/null +++ b/web/client/components/data/featuregrid/renderers/__tests__/CellValidationErrorMessage-test.jsx @@ -0,0 +1,226 @@ +/* + * Copyright 2025, 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 React from 'react'; +import ReactDOM from 'react-dom'; +import expect from 'expect'; + +import CellValidationErrorMessage from '../CellValidationErrorMessage'; + +describe('Tests CellValidationErrorMessage component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('should return null when valid is true', () => { + const props = { + value: 'test', + valid: true, + column: { key: 'testColumn' }, + changed: false + }; + const comp = ReactDOM.render(, document.getElementById("container")); + expect(comp).toBe(null); + const container = document.getElementById("container"); + expect(container.querySelector('.ms-cell-validation-indicator')).toNotExist(); + }); + + it('should return null when column.key is geometry', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'geometry' }, + changed: false + }; + const comp = ReactDOM.render(, document.getElementById("container")); + expect(comp).toBe(null); + const container = document.getElementById("container"); + expect(container.querySelector('.ms-cell-validation-indicator')).toNotExist(); + }); + + it('should render validation error indicator when valid is false', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn' }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-warning-text'); + }); + + it('should show placeholder span when value is empty string', () => { + const props = { + value: '', + valid: false, + column: { key: 'testColumn' }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const placeholder = container.querySelector('span[style*="height: 1em"]'); + expect(placeholder).toBeTruthy(); + expect(placeholder.style.height).toBe('1em'); + expect(placeholder.style.display).toBe('inline-block'); + }); + + it('should show placeholder span when value is null', () => { + const props = { + value: null, + valid: false, + column: { key: 'testColumn' }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const placeholder = container.querySelector('span[style*="height: 1em"]'); + expect(placeholder).toBeTruthy(); + }); + + it('should show danger class when changed is true', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn' }, + changed: true + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-danger-text'); + expect(indicator.getAttribute('class')).toNotInclude('ms-warning-text'); + }); + + it('should show warning class when changed is false and not primary key', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn', isPrimaryKey: false }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-warning-text'); + expect(indicator.getAttribute('class')).toNotInclude('ms-danger-text'); + expect(indicator.getAttribute('class')).toNotInclude('ms-info-text'); + }); + + it('should show info class when isPrimaryKey is true', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn', isPrimaryKey: true }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-info-text'); + expect(indicator.getAttribute('class')).toNotInclude('ms-warning-text'); + }); + + it('should show danger class when changed is true even if isPrimaryKey is true', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn', isPrimaryKey: true }, + changed: true + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-danger-text'); + expect(indicator.getAttribute('class')).toNotInclude('ms-info-text'); + }); + + it('should render with default props', () => { + ReactDOM.render(, document.getElementById("container")); + // When valid is undefined (falsy) and column.key is undefined (not 'geometry'), it should render + // But since column is {} by default, column.key is undefined, so it will render + const container = document.getElementById("container"); + // Component should render because valid is falsy and column.key is not 'geometry' + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + }); + + it('should handle missing column prop gracefully', () => { + const props = { + value: 'test', + valid: false, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + // Should render because valid is false and column.key is undefined (not 'geometry') + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + }); + + it('should render glyphicon with exclamation-mark', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn' }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('glyphicon'); + expect(indicator.getAttribute('class')).toInclude('glyphicon-exclamation-mark'); + }); + + it('should handle column with schema and schemaRequired', () => { + const props = { + value: 'test', + valid: false, + column: { + key: 'testColumn', + schema: { type: 'string', minLength: 5 }, + schemaRequired: true + }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + }); + + it('should handle column without schema', () => { + const props = { + value: 'test', + valid: false, + column: { + key: 'testColumn' + }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + }); +}); + diff --git a/web/client/components/data/featuregrid/toolbars/Toolbar.jsx b/web/client/components/data/featuregrid/toolbars/Toolbar.jsx index 47a5b15e19..a9571203f1 100644 --- a/web/client/components/data/featuregrid/toolbars/Toolbar.jsx +++ b/web/client/components/data/featuregrid/toolbars/Toolbar.jsx @@ -21,10 +21,13 @@ const getDrawFeatureTooltip = (isDrawing, isSimpleGeom) => { } return isSimpleGeom ? "featuregrid.toolbar.drawGeom" : "featuregrid.toolbar.addGeom"; }; -const getSaveMessageId = ({saving, saved}) => { +const getSaveMessageId = ({ saving, saved, error }) => { if (saving || saved) { return "featuregrid.toolbar.saving"; } + if (error) { + return "featuregrid.toolbar.validationError"; + } return "featuregrid.toolbar.saveChanges"; }; const standardButtons = { @@ -86,15 +89,20 @@ const standardButtons = { visible={mode === "EDIT" && selectedCount > 0 && !hasChanges && !hasNewFeatures} onClick={events.deleteFeatures} glyph="trash-square"/>), - saveFeature: ({saving = false, saved = false, disabled, mode, hasChanges, hasNewFeatures, events = {}}) => (), + saveFeature: ({saving = false, saved = false, disabled, mode, hasChanges, hasNewFeatures, events = {}, validationErrors = {} }) => { + const hasValidationErrors = Object.keys(validationErrors).some(key => validationErrors[key].changed); + return (); + }, cancelEditing: ({disabled, mode, hasChanges, hasNewFeatures, events = {}}) => ( { return conf; }), original: data, + attributesJSONSchema: describeFeatureTypeToJSONSchema(data), attributes: describeFeatureTypeToAttributes(data, fields) }; }; diff --git a/web/client/plugins/featuregrid/FeatureEditor.jsx b/web/client/plugins/featuregrid/FeatureEditor.jsx index 6479667a0a..835d794049 100644 --- a/web/client/plugins/featuregrid/FeatureEditor.jsx +++ b/web/client/plugins/featuregrid/FeatureEditor.jsx @@ -5,7 +5,7 @@ * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ -import React, {useMemo} from 'react'; +import React, { useMemo } from 'react'; import {connect} from 'react-redux'; import {createSelector, createStructuredSelector} from 'reselect'; import {bindActionCreators} from 'redux'; @@ -19,16 +19,16 @@ import BorderLayout from '../../components/layout/BorderLayout'; import { toChangesMap} from '../../utils/FeatureGridUtils'; import { sizeChange, setUp, setSyncTool } from '../../actions/featuregrid'; import {mapLayoutValuesSelector} from '../../selectors/maplayout'; -import {paginationInfo, describeSelector, wfsURLSelector, typeNameSelector, isSyncWmsActive} from '../../selectors/query'; +import {paginationInfo, describeSelector, attributesJSONSchemaSelector, wfsURLSelector, typeNameSelector, isSyncWmsActive} from '../../selectors/query'; import {modeSelector, changesSelector, newFeaturesSelector, hasChangesSelector, selectedLayerFieldsSelector, selectedFeaturesSelector, getDockSize} from '../../selectors/featuregrid'; import {getPanels, getHeader, getFooter, getDialogs, getEmptyRowsView, getFilterRenderers} from './panels/index'; import {gridTools, gridEvents, pageEvents, toolbarEvents} from './index'; +import useFeatureValidation from './hooks/useFeatureValidation'; const EMPTY_ARR = []; const EMPTY_OBJ = {}; - const Dock = connect(createSelector( getDockSize, state => mapLayoutValuesSelector(state, {transform: true}), @@ -97,6 +97,7 @@ const Dock = connect(createSelector( * @prop {object} cfg.dateFormats object containing custom formats for one of the date/time attribute types. Following keys are supported: "date-time", "date", "time" * @prop {boolean} cfg.useUTCOffset avoid using UTC dates in attribute table and datetime editor, should be kept consistent with dateFormats, default is true * @prop {boolean} cfg.showPopoverSync default false. Hide the popup of map sync if false, shows the popup of map sync if true + * @prop {string[]} cfg.primaryKeyAttributes array of attribute names that should be considered primary keys. Default is an empty array * * @classdesc * `FeatureEditor` Plugin, also called *FeatureGrid*, provides functionalities to browse/edit data via WFS. The grid can be configured to use paging or @@ -193,6 +194,19 @@ const FeatureDock = (props = { const filterRenderers = useMemo(() => { return getFilterRenderers(props.describe, props.fields, props.isWithinAttrTbl); }, [props.describe, props.fields]); + + // changes compute using useMemo to reduce the re-render of the component + const changes = useMemo(() => toChangesMap(props.changes), [props.changes]); + + const primaryKeyAttributes = useMemo(() => props?.primaryKeyAttributes ?? [], [props?.primaryKeyAttributes]); + const validationErrors = useFeatureValidation({ + featurePropertiesJSONSchema: props.featurePropertiesJSONSchema, + features: props.features, + newFeatures: props.newFeatures, + changes, + primaryKeyAttributes + }); + return (
{ props.onSizeChange(size, dockProps); }}> @@ -208,7 +222,8 @@ const FeatureDock = (props = { toolbarItems, hideCloseButton: props.hideCloseButton, hideLayerTitle: props.hideLayerTitle, - pluginCfg: props.pluginCfg + pluginCfg: props.pluginCfg, + validationErrors })} columns={getPanels(props.tools)} footer={getFooter(props)}> @@ -226,7 +241,7 @@ const FeatureDock = (props = { emptyRowsView={getEmptyRowsView()} focusOnEdit={props.focusOnEdit} newFeatures={props.newFeatures} - changes={props.changes} + changes={changes} mode={props.mode} select={props.select} key={"feature-grid-container"} @@ -248,6 +263,9 @@ const FeatureDock = (props = { actionOpts={{maxZoom}} dateFormats={props.dateFormats} useUTCOffset={props.useUTCOffset} + validationErrors={validationErrors} + featurePropertiesJSONSchema={props.featurePropertiesJSONSchema} + primaryKeyAttributes={primaryKeyAttributes} /> } @@ -264,12 +282,13 @@ export const selector = createStructuredSelector({ typeName: state => typeNameSelector(state), features: state => get(state, 'featuregrid.features') || EMPTY_ARR, describe: describeSelector, + featurePropertiesJSONSchema: attributesJSONSchemaSelector, fields: selectedLayerFieldsSelector, attributes: state => get(state, "featuregrid.attributes"), tools: state => get(state, "featuregrid.tools"), select: selectedFeaturesSelector, mode: modeSelector, - changes: state => toChangesMap(changesSelector(state)), + changes: state => changesSelector(state), newFeatures: state => newFeaturesSelector(state) || EMPTY_ARR, hasChanges: hasChangesSelector, focusOnEdit: state => get(state, 'featuregrid.focusOnEdit', false), diff --git a/web/client/plugins/featuregrid/__tests__/FeatureEditor-test.jsx b/web/client/plugins/featuregrid/__tests__/FeatureEditor-test.jsx index d8e399fdb7..d748c5ae53 100644 --- a/web/client/plugins/featuregrid/__tests__/FeatureEditor-test.jsx +++ b/web/client/plugins/featuregrid/__tests__/FeatureEditor-test.jsx @@ -14,7 +14,7 @@ describe('FeatureEditor plugin component', () => { canEdit: false, focusOnEdit: false, mode: "view", - changes: [], + changes: {}, pagination: { page: 0, size: 20 @@ -32,11 +32,13 @@ describe('FeatureEditor plugin component', () => { }; const BASE_EXPECTED = { open: false, + customEditorsOptions: undefined, autocompleteEnabled: undefined, url: undefined, typeName: undefined, features: [], describe: undefined, + featurePropertiesJSONSchema: undefined, fields: [], attributes: undefined, tools: undefined, diff --git a/web/client/plugins/featuregrid/hooks/__tests__/useFeatureValidation-test.js b/web/client/plugins/featuregrid/hooks/__tests__/useFeatureValidation-test.js new file mode 100644 index 0000000000..03061ff74e --- /dev/null +++ b/web/client/plugins/featuregrid/hooks/__tests__/useFeatureValidation-test.js @@ -0,0 +1,483 @@ +/* + * Copyright 2025, 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 React from 'react'; +import ReactDOM from 'react-dom'; +import expect from 'expect'; +import { act } from 'react-dom/test-utils'; +import useFeatureValidation from '../useFeatureValidation'; + +describe('useFeatureValidation', () => { + let validationResults = null; + + const Component = (props) => { + validationResults = useFeatureValidation(props); + return
{JSON.stringify(validationResults)}
; + }; + + beforeEach((done) => { + document.body.innerHTML = '
'; + validationResults = null; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + validationResults = null; + setTimeout(done); + }); + + it('should return empty object when schema is not provided', () => { + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + expect(validationResults).toEqual({}); + }); + + it('should return empty object when schema is null', () => { + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + expect(validationResults).toEqual({}); + }); + + it('should return empty object when all features are valid', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + } + }; + const features = [ + { id: '1', properties: { name: 'John', age: 30 } }, + { id: '2', properties: { name: 'Jane', age: 25 } } + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + expect(validationResults).toEqual({}); + }); + + it('should return validation errors for invalid features', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 }, + age: { type: 'number', minimum: 0 } + }, + required: ['name'] + }; + const features = [ + { id: '1', properties: { name: 'Jo', age: 30 } }, // name too short + { id: '2', properties: { name: 'Jane', age: -5 } }, // age negative + { id: '3', properties: { age: 25 } } // missing required name + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(Object.keys(validationResults).length).toBe(3); + expect(validationResults['1']).toBeTruthy(); + expect(validationResults['1'].errors).toBeTruthy(); + expect(validationResults['1'].errors.length).toBeGreaterThan(0); + expect(validationResults['1'].changed).toBeFalsy(); + expect(validationResults['2']).toBeTruthy(); + expect(validationResults['2'].errors).toBeTruthy(); + expect(validationResults['2'].errors.length).toBeGreaterThan(0); + expect(validationResults['2'].changed).toBeFalsy(); + expect(validationResults['3']).toBeTruthy(); + expect(validationResults['3'].errors).toBeTruthy(); + expect(validationResults['3'].errors.length).toBeGreaterThan(0); + expect(validationResults['3'].changed).toBeFalsy(); + }); + + it('should filter out primary key errors', () => { + const schema = { + type: 'object', + properties: { + fid: { type: 'string' }, + name: { type: 'string', minLength: 3 } + } + }; + const features = [ + { id: '1', properties: { fid: 'invalid', name: 'Jo' } } // fid is primary key, name is invalid + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(Object.keys(validationResults).length).toBe(1); + expect(validationResults['1']).toBeTruthy(); + // Should only have errors for 'name', not 'fid' + const errorFields = validationResults['1'].errors.map(e => { + const path = e.instancePath || e.dataPath || ''; + return path.replace(/^[./]/, ''); + }); + expect(errorFields).toNotContain('fid'); + expect(errorFields).toContain('name'); + }); + + it('should mark changed features correctly', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 } + } + }; + const features = [ + { id: '1', properties: { name: 'John' } } // Valid initially + ]; + const changes = { + '1': { name: 'Jo' } // Invalid change - name too short + }; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(validationResults['1']).toBeTruthy(); + expect(validationResults['1'].errors).toBeTruthy(); + expect(validationResults['1'].errors.length).toBeGreaterThan(0); + expect(validationResults['1'].changed).toBe(true); + }); + + it('should mark new features correctly', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 } + } + }; + const newFeatures = [ + { id: 'new1', properties: { name: 'Jo' }, _new: true } + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(validationResults.new1).toBeTruthy(); + expect(validationResults.new1.changed).toBe(true); + }); + + it('should combine newFeatures and features', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 } + } + }; + const features = [ + { id: '1', properties: { name: 'Jo' } } + ]; + const newFeatures = [ + { id: 'new1', properties: { name: 'Ja' }, _new: true } + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(Object.keys(validationResults).length).toBe(2); + expect(validationResults['1']).toBeTruthy(); + expect(validationResults.new1).toBeTruthy(); + }); + + it('should handle features without id', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' } + } + }; + const features = [ + { properties: { name: 'John' } }, // no id + { id: '1', properties: { name: 'Jane' } } + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + // Should only include feature with id + expect(Object.keys(validationResults).length).toBe(0); + }); + + it('should handle empty features array', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' } + } + }; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(validationResults).toEqual({}); + }); + + it('should handle empty newFeatures array', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' } + } + }; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(validationResults).toEqual({}); + }); + + it('should exclude features with only primary key errors', () => { + const schema = { + type: 'object', + properties: { + fid: { type: 'string', minLength: 5 }, + name: { type: 'string' } + } + }; + const features = [ + { id: '1', properties: { fid: 'ab', name: 'John' } } // only fid has error, which is primary key + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + // Should not include this feature since only primary key has errors + expect(validationResults).toEqual({}); + }); + + it('should handle multiple primary key attributes', () => { + const schema = { + type: 'object', + properties: { + fid: { type: 'string' }, + ogc_fid: { type: 'string' }, + name: { type: 'string', minLength: 3 } + } + }; + const features = [ + { id: '1', properties: { fid: 'invalid', ogc_fid: 'invalid', name: 'Jo' } } + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(Object.keys(validationResults).length).toBe(1); + const errorFields = validationResults['1'].errors.map(e => { + const path = e.instancePath || e.dataPath || ''; + return path.replace(/^[./]/, ''); + }); + expect(errorFields).toNotContain('fid'); + expect(errorFields).toNotContain('ogc_fid'); + expect(errorFields).toContain('name'); + }); + + it('should handle features with null properties', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + } + }; + const features = [ + { id: '1', properties: null } + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + // Should handle gracefully, likely will have validation errors for required fields or type mismatches + expect(validationResults).toBeTruthy(); + }); + + it('should update validation when changes are applied', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 } + } + }; + const features = [ + { id: '1', properties: { name: 'John' } } + ]; + let changes = {}; + + const TestComponent = (props) => { + validationResults = useFeatureValidation({ + featurePropertiesJSONSchema: schema, + features: features, + changes: props.changes + }); + return
{JSON.stringify(validationResults)}
; + }; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(validationResults).toEqual({}); + + // Apply invalid change + changes = { '1': { name: 'Jo' } }; + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(Object.keys(validationResults).length).toBe(1); + expect(validationResults['1']).toBeTruthy(); + expect(validationResults['1'].changed).toBe(true); + }); + + it('should handle schema with no properties', () => { + const schema = { + type: 'object' + }; + const features = [ + { id: '1', properties: { name: 'John' } } + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + // Should handle gracefully + expect(validationResults).toBeTruthy(); + }); +}); + diff --git a/web/client/plugins/featuregrid/hooks/useFeatureValidation.js b/web/client/plugins/featuregrid/hooks/useFeatureValidation.js new file mode 100644 index 0000000000..8c0efa0254 --- /dev/null +++ b/web/client/plugins/featuregrid/hooks/useFeatureValidation.js @@ -0,0 +1,93 @@ +/* + * Copyright 2025, 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 { useMemo } from 'react'; +import Ajv from 'ajv'; +import { applyAllChanges, isPrimaryKeyField } from '../../../utils/FeatureGridUtils'; + +const ajv = new Ajv({ allErrors: true }); + +// Cache compiled validators to avoid recompiling on every render +const validatorCache = new Map(); + +/** + * Get or compile a validator for the given schema + * @param {object} schema - The JSON schema to validate against + * @returns {function} The compiled AJV validator function + */ +const getValidator = (schema) => { + if (!schema) return null; + + const key = JSON.stringify(schema); + if (validatorCache.has(key)) return validatorCache.get(key); + try { + const validate = ajv.compile(schema); + validatorCache.set(key, validate); + return validate; + } catch (error) { + console.error('Error compiling JSON schema validator:', error); + return null; + } +}; + +const useFeatureValidation = ({ + featurePropertiesJSONSchema, + newFeatures, + features, + changes, + primaryKeyAttributes = [] +}) => { + const validate = useMemo(() => getValidator(featurePropertiesJSONSchema), [featurePropertiesJSONSchema]); + + const allFeatures = useMemo(() => { + return newFeatures && newFeatures.length > 0 + ? [...newFeatures, ...features] + : features; + }, [newFeatures, features]); + + const validationErrors = useMemo(() => { + if (!validate || !featurePropertiesJSONSchema) { + return {}; + } + + // Create default null properties + const defaultNullProperties = featurePropertiesJSONSchema?.properties + ? Object.fromEntries(Object.keys(featurePropertiesJSONSchema.properties).map(key => [key, null])) + : {}; + + return Object.fromEntries( + allFeatures + .map((feature) => { + const { id, properties } = applyAllChanges(feature, changes) || {}; + if (!id) return null; + + const valid = validate({ ...defaultNullProperties, ...properties }); + if (!valid) { + // Filter out primary key errors + const errors = (validate.errors || []).filter(error => { + // Extract field name from instancePath (e.g., "/fid" -> "fid") + const path = error.instancePath || error.dataPath || ''; + const fieldName = path.replace(/^[./]/, ''); + return !isPrimaryKeyField(fieldName, primaryKeyAttributes); + }); + + // Only include this feature if there are non-primary-key errors + if (errors.length > 0) { + return [id, { errors, changed: !!changes[id] || feature._new }]; + } + } + return null; + }) + .filter(value => value) + ); + }, [validate, allFeatures, changes, featurePropertiesJSONSchema, primaryKeyAttributes]); + + return validationErrors; +}; + +export default useFeatureValidation; diff --git a/web/client/plugins/featuregrid/panels/index.jsx b/web/client/plugins/featuregrid/panels/index.jsx index d07fc5af1b..beb296a202 100644 --- a/web/client/plugins/featuregrid/panels/index.jsx +++ b/web/client/plugins/featuregrid/panels/index.jsx @@ -175,8 +175,8 @@ export const getPanels = (tools = {}) => const Panel = panels[t]; return ; }); -export const getHeader = ({ hideCloseButton, hideLayerTitle, toolbarItems, pluginCfg }) => { - return
; +export const getHeader = ({ hideCloseButton, hideLayerTitle, toolbarItems, pluginCfg, validationErrors }) => { + return
; }; export const getFooter = (props) => { return ( props.focusOnEdit && props.hasChanges || props.newFeatures.length > 0) ? null :