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 : ;
diff --git a/web/client/selectors/query.js b/web/client/selectors/query.js
index e68e496101..07c195f34b 100644
--- a/web/client/selectors/query.js
+++ b/web/client/selectors/query.js
@@ -66,6 +66,7 @@ export const isDescribeLoaded = (state, name) => {
return false;
};
export const describeSelector = (state) => layerDescribeSelector(state, queryFeatureTypeName(state));
+export const attributesJSONSchemaSelector = state => get(featureTypeSelectorCreator(queryFeatureTypeName(state))(state), `attributesJSONSchema`);
export const featureLoadingSelector = (state) => get(state, "query.featureLoading");
export const isSyncWmsActive = (state) => get(state, "query.syncWmsFilter", false);
/**
diff --git a/web/client/themes/default/less/react-data-grid.less b/web/client/themes/default/less/react-data-grid.less
index 3bf975ccd8..0eb61fc3a5 100644
--- a/web/client/themes/default/less/react-data-grid.less
+++ b/web/client/themes/default/less/react-data-grid.less
@@ -84,22 +84,32 @@
&.row-selected,
.row-selected,
.row-selected .react-grid-Cell {
- .color-var(@theme-vars[main-hover-color], true);
- .background-color-var(@theme-vars[main-hover-bg], true);
+ .color-var(@theme-vars[main-color], true);
+ .background-color-var(@theme-vars[selected-hover-bg], true);
+ }
+
+ .react-grid-Cell--frozen,
+ .row-selected .react-grid-Cell--frozen {
+ .color-var(@theme-vars[main-color], true);
+ .background-color-var(@theme-vars[main-bg], true);
}
.row-context-menu .react-grid-Cell,
&:hover .react-grid-Cell {
- .color-var(@theme-vars[main-hover-color]);
- .background-color-var(@theme-vars[main-hover-bg], true);
+ .color-var(@theme-vars[main-color]);
+ .background-color-var(@theme-vars[selected-hover-bg], true);
+ }
+
+ .row-context-menu .react-grid-Cell--frozen,
+ &:hover .react-grid-Cell--frozen {
+ .color-var(@theme-vars[main-color]);
+ .background-color-var(@theme-vars[main-bg], true);
}
.react-grid-Cell.modified,
&.row-selected .react-grid-Cell.modified,
&.row-selected:active .react-grid-Cell.modified,
&:hover .react-grid-Cell.modified {
- .color-var(@theme-vars[main-variant-color], true);
- .background-color-var(@theme-vars[main-variant-bg], true);
.border-bottom-color-var(@theme-vars[primary]);
}
@@ -107,17 +117,21 @@
.react-grid-Cell.invalid,
&.row-selected .react-grid-Cell.invalid,
&.row-selected .react-grid-Cell.modified.invalid {
- .color-var(@theme-vars[main-variant-color], true);
- .background-color-var(@theme-vars[main-variant-bg], true);
+ &.primary-key {
+ .border-bottom-color-var(@theme-vars[primary]);
+ &:has(.ms-warning-text) {
+ .border-bottom-color-var(@theme-vars[primary]);
+ }
+ }
.border-bottom-color-var(@theme-vars[danger]);
+ &:has(.ms-warning-text) {
+ .border-bottom-color-var(@theme-vars[warning]);
+ }
}
+ }
- .react-grid-Cell.modified.invalid:after,
- .react-grid-Cell.invalid:after,
- &.row-selected .react-grid-Cell.invalid:after,
- &.row-selected .react-grid-Cell.modified.invalid:after {
- .color-var(@theme-vars[danger]);
- }
+ .ms-cell-editor.invalid {
+ .border-bottom-color-var(@theme-vars[danger]);
}
.data-grid-bottom-toolbar {
@@ -136,7 +150,7 @@
.ms2-border-layout-body {
.react-grid-HeaderRow {
.react-grid-HeaderCell {
- .rw-datetimepicker.rw-widget input:disabled,
+ .rw-datetimepicker.rw-widget input:disabled,
.rw-datetimepicker.rw-widget .rw-select button:disabled {
.background-color-var(@theme-vars[disabled-bg], true);
}
@@ -218,6 +232,28 @@
border-right-style: solid;
}
+.ms-cell-editor {
+ height: 100%;
+ .react-numeric-input {
+ height: 100%;
+ input {
+ height: 100%;
+ border-radius: 0;
+ }
+ }
+ .rw-combobox {
+ border-radius: 0;
+ height: 100%;
+ input {
+ height: 100% !important;
+ }
+ }
+ &.invalid {
+ border-bottom-width: 3px;
+ border-bottom-style: solid;
+ }
+}
+
.react-grid-Row {
&.row-selected,
@@ -246,21 +282,18 @@
&.row-selected .react-grid-Cell.modified.invalid {
border-bottom-width: 3px;
border-bottom-style: solid;
+ &.primary-key {
+ border-bottom-width: 2px;
+ &:has(.ms-warning-text) {
+ border-bottom-width: 2px;
+ }
+ }
}
- .react-grid-Cell.modified.invalid:after,
- .react-grid-Cell.invalid:after,
- &.row-selected .react-grid-Cell.invalid:after,
- &.row-selected .react-grid-Cell.modified.invalid:after {
- content: "*";
- font-weight: normal;
+ .ms-cell-validation-indicator {
position: absolute;
- top: 0;
right: 0;
- font-size: 32px;
- margin-right: 4px;
}
-
}
.data-grid-top-toolbar {
@@ -323,7 +356,7 @@
.autocompleteField.d-flex div.checkbox-any-field {
position: relative;
top: 15%;
- }
+ }
}
}
}
@@ -336,4 +369,4 @@
.rw-datetimepicker ~ .glyphicon-remove {
display: none;
}
-}
\ No newline at end of file
+}
diff --git a/web/client/translations/data.ca-ES.json b/web/client/translations/data.ca-ES.json
index 6ddc51c269..cd0779a863 100644
--- a/web/client/translations/data.ca-ES.json
+++ b/web/client/translations/data.ca-ES.json
@@ -1975,7 +1975,19 @@
"loadingStrategy": "Estrat\u00e8gia de c\u00e0rrega"
},
"disableViewportFilter": "No filtreu les dades mitjan\u00e7ant la visualitzaci\u00f3 actual",
- "enableViewportFilter": "Filtra les dades de la visualitzaci\u00f3 actual"
+ "enableViewportFilter": "Filtra les dades de la visualitzaci\u00f3 actual",
+ "validationError": "Hi ha errors de validaci\u00f3 en les modificacions aplicades. Si us plau, comproveu les modificacions abans de desar."
+ },
+ "primaryKey": {
+ "tooltip": "Aquest camp \u00e9s una clau prim\u00e0ria i no es pot editar"
+ },
+ "restrictions": {
+ "required": "Aquest atribut \u00e9s requerit.",
+ "range": "El valor ha de ser superior o igual a {minimum} i inferior o igual a {maximum}.",
+ "greaterEqualThan": "El valor ha de ser superior o igual a {minimum}.",
+ "lessEqualThan": "El valor ha de ser inferior o igual a {maximum}.",
+ "options": "El valor ha de ser un dels seg\u00fcents: {options}.",
+ "nillable": "El valor pot ser null."
}
},
"layerdownload": {
diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json
index f8ee41c0d4..e59c6658d6 100644
--- a/web/client/translations/data.de-DE.json
+++ b/web/client/translations/data.de-DE.json
@@ -2029,7 +2029,19 @@
"loadingStrategy": "Ladestrategie"
},
"disableViewportFilter": "Daten nicht nach dem aktuellen Darstellungsbereich filtern",
- "enableViewportFilter": "Filtern Sie Daten nach dem aktuellen Ansichtsfenster"
+ "enableViewportFilter": "Filtern Sie Daten nach dem aktuellen Ansichtsfenster",
+ "validationError": "Es gibt Validierungsfehler bei den angewendeten Änderungen. Bitte überprüfen Sie die geänderten Werte vor dem Speichern."
+ },
+ "primaryKey": {
+ "tooltip": "Dieses Feld ist eine Primärschlüssel und kann nicht bearbeitet werden"
+ },
+ "restrictions": {
+ "required": "Dieses Attribut ist erforderlich.",
+ "range": "Der Wert muss größer oder gleich {minimum} und kleiner oder gleich {maximum} sein.",
+ "greaterEqualThan": "Der Wert muss größer oder gleich {minimum} sein.",
+ "lessEqualThan": "Der Wert muss kleiner oder gleich {maximum} sein.",
+ "options": "Der Wert muss einer der folgenden sein: {options}.",
+ "nillable": "Der Wert kann null sein."
}
},
"layerdownload": {
diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json
index aedf92ea49..6f4335ef07 100644
--- a/web/client/translations/data.en-US.json
+++ b/web/client/translations/data.en-US.json
@@ -1990,7 +1990,19 @@
"loadingStrategy": "Loading strategy"
},
"disableViewportFilter": "Do not filter data by the current viewport",
- "enableViewportFilter": "Filter data by the current viewport"
+ "enableViewportFilter": "Filter data by the current viewport",
+ "validationError": "There are validation errors on applied changes. Please double check changed values before saving."
+ },
+ "primaryKey": {
+ "tooltip": "This field is a primary key and cannot be edited"
+ },
+ "restrictions": {
+ "required": "This attribute is required.",
+ "range": "The value must be greater than or equal to {minimum} and less than or equal to {maximum}.",
+ "greaterEqualThan": "The value must be greater than or equal to {minimum}.",
+ "lessEqualThan": "The value must be less than or equal to {maximum}.",
+ "options": "The value must be one of: {options}.",
+ "nillable": "The value can be null."
}
},
"layerdownload": {
diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json
index fe6f4861f2..2ce3d0a536 100644
--- a/web/client/translations/data.es-ES.json
+++ b/web/client/translations/data.es-ES.json
@@ -1990,7 +1990,19 @@
"loadingStrategy": "Estrategia de carga"
},
"disableViewportFilter": "No filtrar datos por la ventana gráfica actual",
- "enableViewportFilter": "Filtrar datos por la ventana gráfica actual"
+ "enableViewportFilter": "Filtrar datos por la ventana gráfica actual",
+ "validationError": "Hay errores de validación en los cambios aplicados. Por favor, compruebe los valores modificados antes de guardar."
+ },
+ "primaryKey": {
+ "tooltip": "Este campo es una clave primaria y no puede ser editado"
+ },
+ "restrictions": {
+ "required": "Este atributo es requerido.",
+ "range": "El valor debe ser mayor o igual a {minimum} y menor o igual a {maximum}.",
+ "greaterEqualThan": "El valor debe ser mayor o igual a {minimum}.",
+ "lessEqualThan": "El valor debe ser menor o igual a {maximum}.",
+ "options": "El valor debe ser uno de los siguientes: {options}.",
+ "nillable": "El valor puede ser null."
}
},
"layerdownload": {
diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json
index d90b9dc1d2..eb2506a9f9 100644
--- a/web/client/translations/data.fr-FR.json
+++ b/web/client/translations/data.fr-FR.json
@@ -1991,7 +1991,19 @@
"loadingStrategy": "Stratégie de chargement"
},
"disableViewportFilter": "Ne pas filtrer les données par la fenêtre actuelle",
- "enableViewportFilter": "Filtrer les données par la fenêtre actuelle"
+ "enableViewportFilter": "Filtrer les données par la fenêtre actuelle",
+ "validationError": "Il y a des erreurs de validation sur les modifications appliquées. Veuillez vérifier les valeurs modifiées avant de sauvegarder."
+ },
+ "primaryKey": {
+ "tooltip": "Ce champ est une clé primaire et ne peut pas être modifié"
+ },
+ "restrictions": {
+ "required": "Cet attribut est requis.",
+ "range": "Le valeur doit être supérieure ou égale à {minimum} et inférieure ou égale à {maximum}.",
+ "greaterEqualThan": "Le valeur doit être supérieure ou égale à {minimum}.",
+ "lessEqualThan": "Le valeur doit être inférieure ou égale à {maximum}.",
+ "options": "Le valeur doit être un des suivants: {options}.",
+ "nillable": "Le valeur peut être null."
}
},
"layerdownload": {
diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json
index 31f8ecd039..7eead8569c 100644
--- a/web/client/translations/data.it-IT.json
+++ b/web/client/translations/data.it-IT.json
@@ -1990,7 +1990,19 @@
"loadingStrategy": "Strategia di caricamento"
},
"disableViewportFilter": "Non filtrare i dati in base alla vista corrente",
- "enableViewportFilter": "Filtra i dati in base alla vista corrente"
+ "enableViewportFilter": "Filtra i dati in base alla vista corrente",
+ "validationError": "Ci sono errori di validazione nei cambiamenti applicati. Si prega di controllare attentamente i valori modificati prima di salvare."
+ },
+ "primaryKey": {
+ "tooltip": "Questo campo è una chiave primaria e non può essere modificato"
+ },
+ "restrictions": {
+ "required": "Questo attributo è richiesto.",
+ "range": "Il valore deve essere maggiore o uguale a {minimum} e minore o uguale a {maximum}.",
+ "greaterEqualThan": "Il valore deve essere maggiore o uguale a {minimum}.",
+ "lessEqualThan": "Il valore deve essere minore o uguale a {maximum}.",
+ "options": "Il valore deve essere uno dei seguenti: {options}.",
+ "nillable": "Il valore può essere null."
}
},
"layerdownload": {
diff --git a/web/client/utils/FeatureGridUtils.js b/web/client/utils/FeatureGridUtils.js
index 92f2d87ff0..f73985a747 100644
--- a/web/client/utils/FeatureGridUtils.js
+++ b/web/client/utils/FeatureGridUtils.js
@@ -6,7 +6,7 @@
* LICENSE file in the root directory of this source tree.
*/
-import { identity, trim, fill, findIndex, get, isArray, isNil, isString, isPlainObject, includes } from 'lodash';
+import { identity, trim, fill, findIndex, get, isArray, isNil, isString, isPlainObject, includes, isEmpty } from 'lodash';
import {
findGeometryProperty,
@@ -23,6 +23,18 @@ import { fidFilter } from './ogc/Filter/filter';
const getGeometryName = (describe) => get(findGeometryProperty(describe), "name");
const getPropertyName = (name, describe) => name === "geometry" ? getGeometryName(describe) : name;
+/**
+ * Check if a field name is considered a primary key
+ * @param {string} fieldName - The field name to check
+ * @param {string[]} customPrimaryKeyNames - Optional custom list of primary key names from config
+ * @returns {boolean} True if the field is a primary key
+ */
+export const isPrimaryKeyField = (fieldName, customPrimaryKeyNames = []) => {
+ if (!fieldName) return false;
+ const allPrimaryKeyNames = !isEmpty(customPrimaryKeyNames) ? customPrimaryKeyNames : [];
+ return includes(allPrimaryKeyNames.map(name => name.toLowerCase()), fieldName.toLowerCase());
+};
+
export const getBlockIdx = (indexes = [], size = 0, rowIdx) => findIndex(indexes, (startIdx) => startIdx <= rowIdx && rowIdx < startIdx + size);
/** Features are stored in an array grupped by block of pages. The page could be loaded unorderd
@@ -127,13 +139,17 @@ export const getCurrentPaginationOptions = ({ startPage, endPage }, oldPages, si
*/
export const featureTypeToGridColumns = (
describe,
+ featurePropertiesJSONSchema,
columnSettings = {},
fields = [],
- {editable = false, sortable = true, resizable = true, filterable = true, defaultSize = 200, options = []} = {},
+ {editable = false, sortable = true, resizable = true, filterable = true, defaultSize = 200, options = [], primaryKeyAttributes = []} = {},
{getEditor = () => {}, getFilterRenderer = () => {}, getFormatter = () => {}, getHeaderRenderer = () => {}, isWithinAttrTbl = false} = {}) =>
getAttributeFields(describe).filter(e => !(columnSettings[e.name] && columnSettings[e.name].hide)).map((desc) => {
const option = options.find(o => o.name === desc.name);
const field = fields.find(f => f.name === desc.name);
+ const schema = featurePropertiesJSONSchema?.properties?.[desc.name];
+ const schemaRequired = (featurePropertiesJSONSchema?.required || []).includes(desc.name);
+ const isPrimaryKey = isPrimaryKeyField(desc.name, primaryKeyAttributes);
let columnProp = {
sortable,
key: desc.name,
@@ -146,9 +162,12 @@ export const featureTypeToGridColumns = (
resizable,
editable,
filterable,
- editor: getEditor(desc, field),
+ editor: getEditor(desc, field, schema),
formatter: getFormatter(desc, field),
- filterRenderer: getFilterRenderer(desc, field)
+ filterRenderer: getFilterRenderer(desc, field),
+ schema,
+ schemaRequired,
+ isPrimaryKey
};
if (isWithinAttrTbl) columnProp.width = 300;
return columnProp;
@@ -406,3 +425,41 @@ export const createChangesTransaction = (changes, newFeatures, {insert, update,
return update(Object.keys(changes[id]).map(prop => propertyChange(getPropertyNameFunc(prop), changes[id][prop])), fidFilter("ogc", id));
})
);
+
+
+export const getRestrictionsMessageInfo = (schema, required) => {
+ if (!schema) {
+ return null;
+ }
+ const enumerator = schema?.enum;
+ const minimum = schema?.minimum;
+ const maximum = schema?.maximum;
+ const requiredMessage = required ? ['featuregrid.restrictions.required'] : [];
+ const rangeMessage = minimum !== undefined && maximum !== undefined ? ['featuregrid.restrictions.range'] : [];
+ const minimumMessage = !rangeMessage.length && minimum !== undefined ? ['featuregrid.restrictions.greaterEqualThan'] : [];
+ const maximumMessage = !rangeMessage.length && maximum !== undefined ? ['featuregrid.restrictions.lessEqualThan'] : [];
+ const optionsMessage = enumerator ? ['featuregrid.restrictions.options'] : [];
+
+ const msgIds = [
+ ...requiredMessage,
+ ...rangeMessage,
+ ...minimumMessage,
+ ...maximumMessage,
+ ...optionsMessage
+ ];
+
+ if (msgIds?.length) {
+ return {
+ msgIds: [
+ ...msgIds,
+ ...(requiredMessage.length ? [] : ['featuregrid.restrictions.nillable'])
+ ],
+ msgParams: {
+ options: (enumerator || []).filter(value => value !== null).join(', '),
+ minimum,
+ maximum
+ }
+ };
+ }
+ return null;
+};
diff --git a/web/client/utils/FeatureTypeUtils.js b/web/client/utils/FeatureTypeUtils.js
index e077961aec..9ede10cd8e 100644
--- a/web/client/utils/FeatureTypeUtils.js
+++ b/web/client/utils/FeatureTypeUtils.js
@@ -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 { get, find } from 'lodash';
+import { get, find, castArray, isString } from 'lodash';
import { applyDefaultToLocalizedString } from '../components/I18N/LocalizedString';
@@ -105,3 +105,112 @@ export const describeFeatureTypeToAttributes = (data, fields = []) => get(data,
values: []
};
});
+
+
+const FeatureTypesToJSONSChemaTypes = {
+ // string
+ // 'xsd:ENTITIES': 'string',
+ // 'xsd:ENTITY': 'string',
+ // 'xsd:ID': 'string',
+ // 'xsd:IDREF': 'string',
+ // 'xsd:IDREFS': 'string',
+ // 'xsd:language': 'string',
+ // 'xsd:Name': 'string',
+ // 'xsd:NCName': 'string',
+ // 'xsd:NMTOKEN': 'string',
+ // 'xsd:NMTOKENS': 'string',
+ 'xsd:normalizedString': 'string',
+ // 'xsd:QName': 'string',
+ 'xsd:string': 'string',
+ // 'xsd:token': 'string',
+
+ // date
+ 'xsd:date': 'string',
+ 'xsd:dateTime': 'string',
+ // 'xsd:duration': 'date',
+ // 'xsd:gDay': 'date',
+ // 'xsd:gMonth': 'date',
+ // 'xsd:gMonthDay': 'date',
+ // 'xsd:gYear': 'date',
+ // 'xsd:gYearMonth': 'date',
+ // date-time
+ 'xsd:date-time': 'string',
+ // time
+ 'xsd:time': 'string',
+
+ // number
+ // 'xsd:byte': 'number',
+ 'xsd:decimal': 'number',
+ 'xsd:int': 'integer',
+ 'xsd:integer': 'integer',
+ 'xsd:long': 'integer',
+ 'xsd:negativeInteger': 'integer',
+ 'xsd:nonNegativeInteger': 'integer',
+ 'xsd:nonPositiveInteger': 'integer',
+ 'xsd:positiveInteger': 'integer',
+ 'xsd:short': 'integer',
+ 'xsd:unsignedLong': 'integer',
+ 'xsd:unsignedInt': 'integer',
+ 'xsd:unsignedShort': 'integer',
+ // 'xsd:unsignedByte': 'number',
+
+ // from old object
+ 'xsd:number': 'number',
+
+ // misc
+ // 'xsd:anyURI': 'string',
+ // 'xsd:base64Binary': 'number',
+ 'xsd:boolean': 'boolean',
+ 'xsd:double': 'number',
+ // 'xsd:hexBinary': 'string',
+ // 'xsd:NOTATION': 'string',
+ 'xsd:float': 'number',
+ 'xsd:array': 'array'
+};
+
+const parseByJSONSchemaType = schemaType => (value) => {
+ if (schemaType === 'string') {
+ return `${value}`;
+ }
+ if (schemaType === 'integer') {
+ return parseInt(value, 10);
+ }
+ if (schemaType === 'number') {
+ return parseFloat(value);
+ }
+ return value;
+};
+
+export const describeFeatureTypeToJSONSchema = (describeFeatureType) => {
+ const properties = describeFeatureType?.featureTypes?.[0]?.properties || [];
+ const attributes = properties.filter(property => !property.type.includes('gml:'));
+ const required = attributes.filter((property) => property.nillable === false).map(property => property.name);
+ return {
+ type: 'object',
+ properties: attributes.reduce((acc, property) => {
+ const { name, type, restriction, nillable } = property;
+ const schemaType = FeatureTypesToJSONSChemaTypes[type] || 'string';
+ const enumeration = restriction?.enumeration;
+ const minInclusive = restriction?.minInclusive;
+ const maxInclusive = restriction?.maxInclusive;
+ const parser = parseByJSONSchemaType(schemaType);
+ acc[name] = {
+ 'type': nillable ? [schemaType, 'null'] : schemaType,
+ ...(enumeration && {
+ 'enum': [
+ ...(nillable ? [null] : []),
+ ...castArray(enumeration).map(parser)
+ ]
+ }),
+ ...(minInclusive !== undefined && !isString(minInclusive) && {
+ minimum: parser(minInclusive)
+ }),
+ ...(maxInclusive !== undefined && !isString(maxInclusive) && {
+ maximum: parser(maxInclusive)
+ })
+ };
+ return acc;
+ }, {}),
+ ...(required.length && { required })
+ };
+};
diff --git a/web/client/utils/__tests__/FeatureGridUtils-test.js b/web/client/utils/__tests__/FeatureGridUtils-test.js
index 5609f2b76d..692651c5a7 100644
--- a/web/client/utils/__tests__/FeatureGridUtils-test.js
+++ b/web/client/utils/__tests__/FeatureGridUtils-test.js
@@ -17,7 +17,8 @@ import {
featureTypeToGridColumns,
supportsFeatureEditing,
areLayerFeaturesEditable,
- createChangesTransaction
+ createChangesTransaction,
+ isPrimaryKeyField
} from '../FeatureGridUtils';
import requestBuilder from "../ogc/WFST/RequestBuilder";
@@ -327,7 +328,7 @@ describe('FeatureGridUtils', () => {
const describe = {featureTypes: [{properties: [{name: 'Test1', type: "xsd:number"}, {name: 'Test2', type: "xsd:number"}]}]};
const columnSettings = {name: 'Test1', hide: false};
const options = [{name: 'Test1', title: 'Some title', description: 'Some description'}];
- const featureGridColumns = featureTypeToGridColumns(describe, columnSettings, [], {options});
+ const featureGridColumns = featureTypeToGridColumns(describe, {}, columnSettings, [], {options});
expect(featureGridColumns.length).toBe(2);
featureGridColumns.forEach((fgColumns, index) => {
if (index === 0) {
@@ -348,7 +349,7 @@ describe('FeatureGridUtils', () => {
const describe = {featureTypes: [{properties: [{name: 'Test1', type: "xsd:number"}, {name: 'Test2', type: "xsd:number"}]}]};
const columnSettings = {name: 'Test1', hide: false};
const options = [{name: 'Test1', title: 'Some title', description: 'Some description'}];
- const featureGridColumns = featureTypeToGridColumns(describe, columnSettings, [], {options}, {getHeaderRenderer: () => DUMMY, getFilterRenderer: () => DUMMY, getFormatter: () => DUMMY, getEditor: () => DUMMY});
+ const featureGridColumns = featureTypeToGridColumns(describe, {}, columnSettings, [], {options}, {getHeaderRenderer: () => DUMMY, getFilterRenderer: () => DUMMY, getFormatter: () => DUMMY, getEditor: () => DUMMY});
expect(featureGridColumns.length).toBe(2);
featureGridColumns.forEach((fgColumns, index) => {
if (index === 0) {
@@ -373,7 +374,7 @@ describe('FeatureGridUtils', () => {
const describe = {featureTypes: [{properties: [{name: 'Test1', type: "xsd:number"}, {name: 'Test2', type: "xsd:number"}]}]};
const columnSettings = {name: 'Test1', hide: false};
const options = [{name: 'Test1', title: 'Some title', description: 'Some description'}];
- const featureGridColumns = featureTypeToGridColumns(describe, columnSettings, [], {options}, {getHeaderRenderer: () => DUMMY, getFilterRenderer: () => DUMMY, getFormatter: () => DUMMY, getEditor: () => DUMMY, isWithinAttrTbl: true});
+ const featureGridColumns = featureTypeToGridColumns(describe, {}, columnSettings, [], {options}, {getHeaderRenderer: () => DUMMY, getFilterRenderer: () => DUMMY, getFormatter: () => DUMMY, getEditor: () => DUMMY, isWithinAttrTbl: true});
expect(featureGridColumns.length).toBe(2);
featureGridColumns.forEach((fgColumns, index) => {
if (index === 0) {
@@ -397,15 +398,15 @@ describe('FeatureGridUtils', () => {
const describe = {featureTypes: [{properties: [{name: 'Test1', type: "xsd:number"}, {name: 'Test2', type: "xsd:number"}]}]};
const columnSettings = {name: 'Test1', hide: false};
const fields = [{name: 'Test1', type: "xsd:number", alias: 'Test1 alias'}];
- const featureGridColumns = featureTypeToGridColumns(describe, columnSettings, fields);
+ const featureGridColumns = featureTypeToGridColumns(describe, {}, columnSettings, fields);
expect(featureGridColumns.length).toBe(2);
expect(featureGridColumns[0].title).toBe('Test1 alias');
// test alias empty string
- expect(featureTypeToGridColumns(describe, columnSettings, [{name: "Test1", alias: ""}])[0].title).toEqual('Test1');
+ expect(featureTypeToGridColumns(describe, {}, columnSettings, [{name: "Test1", alias: ""}])[0].title).toEqual('Test1');
// test localized alias
- expect(featureTypeToGridColumns(describe, columnSettings, [{name: "Test1", alias: {"default": "XX"}}])[0].title.default).toEqual('XX');
+ expect(featureTypeToGridColumns(describe, {}, columnSettings, [{name: "Test1", alias: {"default": "XX"}}])[0].title.default).toEqual('XX');
// test localized alias with empty default
- expect(featureTypeToGridColumns(describe, columnSettings, [{name: "Test1", alias: {"default": ""}}])[0].title.default).toEqual('Test1');
+ expect(featureTypeToGridColumns(describe, {}, columnSettings, [{name: "Test1", alias: {"default": ""}}])[0].title.default).toEqual('Test1');
});
it('featureTypeToGridColumns formatters', () => {
@@ -414,7 +415,7 @@ describe('FeatureGridUtils', () => {
const describe = {featureTypes: [{properties: [{name: 'Test1', type: "xsd:number"}, {name: 'Test2', type: "xsd:number"}]}]};
const columnSettings = {name: 'Test1', hide: false};
const options = [{name: 'Test1', title: 'Some title', description: 'Some description'}];
- const featureGridColumns = featureTypeToGridColumns(describe, columnSettings, [], {options}, {getHeaderRenderer: () => DUMMY, getFilterRenderer: () => DUMMY, getFormatter: () => formatterWrapper, getEditor: () => DUMMY});
+ const featureGridColumns = featureTypeToGridColumns(describe, {}, columnSettings, [], {options}, {getHeaderRenderer: () => DUMMY, getFilterRenderer: () => DUMMY, getFormatter: () => formatterWrapper, getEditor: () => DUMMY});
expect(featureGridColumns.length).toBe(2);
featureGridColumns.forEach((fgColumns)=>{
const Formatter = fgColumns.formatter;
@@ -426,6 +427,138 @@ describe('FeatureGridUtils', () => {
});
});
+ it('featureTypeToGridColumns with featurePropertiesJSONSchema', () => {
+ const describe = {featureTypes: [{properties: [{name: 'Test1', type: "xsd:number"}, {name: 'Test2', type: "xsd:string"}]}]};
+ const featurePropertiesJSONSchema = {
+ properties: {
+ Test1: { type: 'number', minimum: 0, maximum: 100 },
+ Test2: { type: 'string', minLength: 1, maxLength: 50 }
+ }
+ };
+ const featureGridColumns = featureTypeToGridColumns(describe, featurePropertiesJSONSchema, {}, []);
+ expect(featureGridColumns.length).toBe(2);
+ expect(featureGridColumns[0].schema).toEqual({ type: 'number', minimum: 0, maximum: 100 });
+ expect(featureGridColumns[1].schema).toEqual({ type: 'string', minLength: 1, maxLength: 50 });
+ });
+ it('featureTypeToGridColumns with schemaRequired', () => {
+ const describe = {featureTypes: [{properties: [{name: 'Test1', type: "xsd:number"}, {name: 'Test2', type: "xsd:string"}, {name: 'Test3', type: "xsd:boolean"}]}]};
+ const featurePropertiesJSONSchema = {
+ properties: {
+ Test1: { type: 'number' },
+ Test2: { type: 'string' },
+ Test3: { type: 'boolean' }
+ },
+ required: ['Test1', 'Test2']
+ };
+ const featureGridColumns = featureTypeToGridColumns(describe, featurePropertiesJSONSchema, {}, []);
+ expect(featureGridColumns.length).toBe(3);
+ expect(featureGridColumns[0].schemaRequired).toBeTruthy(); // Test1 is required
+ expect(featureGridColumns[1].schemaRequired).toBeTruthy(); // Test2 is required
+ expect(featureGridColumns[2].schemaRequired).toBeFalsy(); // Test3 is not required
+ });
+ it('featureTypeToGridColumns with primaryKeyAttributes', () => {
+ const describe = {featureTypes: [{properties: [{name: 'fid', type: "xsd:string"}, {name: 'name', type: "xsd:string"}, {name: 'ogc_fid', type: "xsd:number"}]}]};
+ const featurePropertiesJSONSchema = {
+ properties: {
+ fid: { type: 'string' },
+ name: { type: 'string' },
+ ogc_fid: { type: 'number' }
+ }
+ };
+ const featureGridColumns = featureTypeToGridColumns(describe, featurePropertiesJSONSchema, {}, [], {primaryKeyAttributes: ['fid', 'ogc_fid']});
+ expect(featureGridColumns.length).toBe(3);
+ expect(featureGridColumns[0].isPrimaryKey).toBeTruthy(); // fid is primary key
+ expect(featureGridColumns[1].isPrimaryKey).toBeFalsy(); // name is not primary key
+ expect(featureGridColumns[2].isPrimaryKey).toBeTruthy(); // ogc_fid is primary key
+ });
+ it('featureTypeToGridColumns with primaryKeyAttributes case insensitive', () => {
+ const describe = {featureTypes: [{properties: [{name: 'FID', type: "xsd:string"}, {name: 'name', type: "xsd:string"}]}]};
+ const featurePropertiesJSONSchema = {
+ properties: {
+ FID: { type: 'string' },
+ name: { type: 'string' }
+ }
+ };
+ const featureGridColumns = featureTypeToGridColumns(describe, featurePropertiesJSONSchema, {}, [], {primaryKeyAttributes: ['fid']});
+ expect(featureGridColumns.length).toBe(2);
+ expect(featureGridColumns[0].isPrimaryKey).toBeTruthy(); // FID matches 'fid' (case insensitive)
+ expect(featureGridColumns[1].isPrimaryKey).toBeFalsy(); // name is not primary key
+ });
+ it('featureTypeToGridColumns with empty primaryKeyAttributes', () => {
+ const describe = {featureTypes: [{properties: [{name: 'fid', type: "xsd:string"}, {name: 'name', type: "xsd:string"}]}]};
+ const featurePropertiesJSONSchema = {
+ properties: {
+ fid: { type: 'string' },
+ name: { type: 'string' }
+ }
+ };
+ const featureGridColumns = featureTypeToGridColumns(describe, featurePropertiesJSONSchema, {}, [], {primaryKeyAttributes: []});
+ expect(featureGridColumns.length).toBe(2);
+ expect(featureGridColumns[0].isPrimaryKey).toBeFalsy(); // No primary keys defined
+ expect(featureGridColumns[1].isPrimaryKey).toBeFalsy();
+ });
+ it('featureTypeToGridColumns with featurePropertiesJSONSchema, schemaRequired, and primaryKeyAttributes combined', () => {
+ const describe = {featureTypes: [{properties: [{name: 'id', type: "xsd:number"}, {name: 'name', type: "xsd:string"}, {name: 'description', type: "xsd:string"}]}]};
+ const featurePropertiesJSONSchema = {
+ properties: {
+ id: { type: 'number', minimum: 1 },
+ name: { type: 'string', minLength: 1 },
+ description: { type: 'string' }
+ },
+ required: ['id', 'name']
+ };
+ const featureGridColumns = featureTypeToGridColumns(describe, featurePropertiesJSONSchema, {}, [], {primaryKeyAttributes: ['id']});
+ expect(featureGridColumns.length).toBe(3);
+
+ // Test id column
+ expect(featureGridColumns[0].name).toBe('id');
+ expect(featureGridColumns[0].schema).toEqual({ type: 'number', minimum: 1 });
+ expect(featureGridColumns[0].schemaRequired).toBeTruthy();
+ expect(featureGridColumns[0].isPrimaryKey).toBeTruthy();
+
+ // Test name column
+ expect(featureGridColumns[1].name).toBe('name');
+ expect(featureGridColumns[1].schema).toEqual({ type: 'string', minLength: 1 });
+ expect(featureGridColumns[1].schemaRequired).toBeTruthy();
+ expect(featureGridColumns[1].isPrimaryKey).toBeFalsy();
+
+ // Test description column
+ expect(featureGridColumns[2].name).toBe('description');
+ expect(featureGridColumns[2].schema).toEqual({ type: 'string' });
+ expect(featureGridColumns[2].schemaRequired).toBeFalsy();
+ expect(featureGridColumns[2].isPrimaryKey).toBeFalsy();
+ });
+ it('featureTypeToGridColumns with featurePropertiesJSONSchema undefined', () => {
+ const describe = {featureTypes: [{properties: [{name: 'Test1', type: "xsd:number"}]}]};
+ const featureGridColumns = featureTypeToGridColumns(describe, undefined, {}, []);
+ expect(featureGridColumns.length).toBe(1);
+ expect(featureGridColumns[0].schema).toBeFalsy();
+ expect(featureGridColumns[0].schemaRequired).toBeFalsy();
+ });
+ it('featureTypeToGridColumns with featurePropertiesJSONSchema null', () => {
+ const describe = {featureTypes: [{properties: [{name: 'Test1', type: "xsd:number"}]}]};
+ const featureGridColumns = featureTypeToGridColumns(describe, null, {}, []);
+ expect(featureGridColumns.length).toBe(1);
+ expect(featureGridColumns[0].schema).toBeFalsy();
+ expect(featureGridColumns[0].schemaRequired).toBeFalsy();
+ });
+ it('featureTypeToGridColumns with getEditor receiving schema parameter', () => {
+ const describe = {featureTypes: [{properties: [{name: 'Test1', type: "xsd:number"}]}]};
+ const featurePropertiesJSONSchema = {
+ properties: {
+ Test1: { type: 'number', minimum: 0, maximum: 100 }
+ }
+ };
+ const receivedSchemas = [];
+ const getEditor = (desc, field, schema) => {
+ receivedSchemas.push({ name: desc.name, schema });
+ return () => {};
+ };
+ featureTypeToGridColumns(describe, featurePropertiesJSONSchema, {}, [], {}, {getEditor});
+ expect(receivedSchemas.length).toBe(1);
+ expect(receivedSchemas[0].name).toBe('Test1');
+ expect(receivedSchemas[0].schema).toEqual({ type: 'number', minimum: 0, maximum: 100 });
+ });
describe("supportsFeatureEditing", () => {
it('test supportsFeatureEditing with valid layer type', () => {
let layer = {type: "wms", id: "test"};
@@ -502,4 +635,38 @@ describe('FeatureGridUtils', () => {
done();
});
});
+ describe('isPrimaryKeyField', () => {
+ it('should return false for empty fieldName', () => {
+ expect(isPrimaryKeyField('')).toBeFalsy();
+ expect(isPrimaryKeyField(null)).toBeFalsy();
+ expect(isPrimaryKeyField(undefined)).toBeFalsy();
+ });
+ it('should return false when customPrimaryKeyNames is empty or undefined', () => {
+ expect(isPrimaryKeyField('fid', [])).toBeFalsy();
+ expect(isPrimaryKeyField('fid', undefined)).toBeFalsy();
+ expect(isPrimaryKeyField('fid')).toBeFalsy();
+ });
+ it('should return false when fieldName does not match any custom primary key', () => {
+ expect(isPrimaryKeyField('fid', ['ogc_fid'])).toBeFalsy();
+ expect(isPrimaryKeyField('name', ['fid', 'ogc_fid'])).toBeFalsy();
+ expect(isPrimaryKeyField('description', ['id', 'gid'])).toBeFalsy();
+ });
+ it('should be case-insensitive when matching field names', () => {
+ expect(isPrimaryKeyField('FID', ['fid'])).toBeTruthy();
+ expect(isPrimaryKeyField('fid', ['FID'])).toBeTruthy();
+ expect(isPrimaryKeyField('OGC_FID', ['ogc_fid'])).toBeTruthy();
+ expect(isPrimaryKeyField('ogc_fid', ['OGC_FID'])).toBeTruthy();
+ expect(isPrimaryKeyField('Id', ['id'])).toBeTruthy();
+ expect(isPrimaryKeyField('ID', ['Id'])).toBeTruthy();
+ });
+ it('should handle multiple custom primary keys', () => {
+ const primaryKeys = ['fid', 'ogc_fid', 'id', 'gid', 'objectid'];
+ expect(isPrimaryKeyField('fid', primaryKeys)).toBeTruthy();
+ expect(isPrimaryKeyField('ogc_fid', primaryKeys)).toBeTruthy();
+ expect(isPrimaryKeyField('id', primaryKeys)).toBeTruthy();
+ expect(isPrimaryKeyField('gid', primaryKeys)).toBeTruthy();
+ expect(isPrimaryKeyField('objectid', primaryKeys)).toBeTruthy();
+ expect(isPrimaryKeyField('name', primaryKeys)).toBeFalsy();
+ });
+ });
});
| |