UI5 input validation concept from top to bottom 2023
Input Validation is performed to ensure only properly formed data is entering the workflow in an information system, preventing malformed data from persisting in the database and triggering malfunction of various downstream components. Input validation should happen as early as possible in the data flow, preferably as soon as the data is received from the external party. This concept supports single and multiple input values to be validated.
The following concept will define certain rules and checks: a) before data is sent to the server (client side) b) before data is stored in the server (server side)
Disclaimer: We use this concept in most of our UI5 projects and are able to handle most requirements. But this is not a silver bullet and has some flaws. Handle with care.
Client side validation
Automatic validation by ui5
- validation rules are added as constraints in the data binding definition, see https://ui5.sap.com/#/topic/07e4b920f5734fd78fdaa236f26236d8
- Mandatory checks are defined by creating custom DataTypes, because OpenUI5 has no default required constraints, see https://www.bing.com/ck/a?!&&p=53ad2ce146f58ebfJmltdHM9MTY3OTM1NjgwMCZpZ3VpZD0wMjkxZWFkYS1kNjU2LTZhMWQtMDg3ZS1mYWMxZDc5NjZiZWQmaW5zaWQ9NTIwMQ&ptn=3&hsh=3&fclid=0291eada-d656-6a1d-087e-fac1d7966bed&psq=custom+datatype+in+ui5&u=a1aHR0cHM6Ly9ibG9ncy5zYXAuY29tLzIwMTYvMDkvMTYvY3VzdG9tLWRhdGEtdHlwZXMtaW4tc2FwdWk1Lw&ntb=1
- the validation for all data types will be done if changes in the model happen (default UI5)
Ad-hoc validation
- Before sending data to the server, the validation must be executed
- A validator (Validator.js) class walks down the control tree for a given view and executed the data type validation
- special constraints(e.g. duplicates in a list or combined fields) shall be implemented in modules that are called by the validator class
- the validator class will return the boolean value true if no error happened
Display of validation errors
- validation errors are displayed:
- by the control itself (e.g. displayed with a red border)
- by an error counter and MessagePopover, see https://experience.sap.com/fiori-design-web/messaging/#message-popover
- clicking an error in the MessagePopover must scroll to the field mentioned in the error
- all ValidationMessages are controlled by the UI5 MessageManager
- error messages shall be created by the data type itself as much as possible (the automatic validation will raise exceptions to accomplish this, while the ad-hoc validation will trigger the method
sap.ui.base.ManagedObject.fireValidationError()
on the failed control) - avoid at the manual creation of error messages and error states (valueState and valueStateText)
- for all complex client validations that can not be handled by the Validator, it must be done in the controller
Server side validation
- on the server side all received values from the client must be checked again
- in case of errors the response must contain a list of error objects containing:
- the binding path of the failed property, e.g. "/minects"
- an error code to be used by the client as i18n message key, e.g. "WA.AI.ME"
- an error message to help the developer, e.g. "Invalid value for minects, must be ..."
- the error message is processed by a generic BaseController method that uses the Validator class to mark errors on the controls or remove them (
Validator.markControls()
);
Validator.js
/** * Based on the ui5 validator. * @see https://github.com/qualiture/ui5-validator * @see http://scn.sap.com/community/developer-center/front-end/blog/2015/11/01/generic-sapui5-form-validator * MIT Licence */ sap.ui.define([ "sap/ui/core/ValueState", "sap/ui/model/ParseException", "sap/ui/model/ValidateException", "./ModelReferenceUtil", "sap/base/Log", "acme/formular/type/TypeUtil" ], function ( ValueState, ParseException, ValidateException, ModelReferenceUtil, Log, TypeUtil ) { "use strict"; /** * Recursively validates the given oMainControl and any aggregations (i.e. child controls) it may have. * * * @param {Element} oMainControl * - The control or element to be validated. It needs a getVisible() function that returns true. * @return {boolean} - true, if the oMainControl and its aggregation are valid * * Custom constraints that are taken into account by validate: * - required: boolean - the value has to be set * - allowDuplicates: boolean - duplicates in a list are alowed * - atLeastOneRequired: boolean - in a set of properties at least one of the values has to be set * - checkDependencies: boolean - an element is only valid either if the value of all properties in a set are defined or none * * Custom format options that are used by validate: * - customError: string - I18n key of a custom error message that will be displayed on invalid input * - duplicatesProperties: string | [string] - single property or array of properties that are checked to determine duplicates; if it is empty or undefined while duplicates are not allowed, the list is checked directly for duplicates (list of references) * - atLeastOneRequiredProperties: [string] - array of properties of which the value of at least one of them has to be defined * - dependentProperties: [string] - array of properties of which the value either of all have to be defined or none * - customDuplicatesError: string - I18n key of a custom error message that will be displayed on when duplicates are found in the list * - customAtLeastOneRequiredError: string - I18n key of a custom error message that will be displayed when none of the values are set * - customDependencyError: string - I18n key of a custom error message that will be displayed when dependencies are not respected * - customFormatError: string - I18n key of a custom error message that will be displayed when the format does not match the (string default) constraint 'search' * - customErrorPrefix: string - I18n key of an error prefix that will be added to 'customDuplicatesError' and 'customAtLeastOneRequiredError' * - customErrorPrefixOnly: boolean - If set to true, the {customError} provided will only be used to prefix any standard error message * - customErrorComponentId: string - Component(s) in which the control is defined */ function validate(oMainControl) { attachValueStateValidationHandler(oMainControl); return _check(oMainControl, oCurrControl => _validateControl(oCurrControl)); } /** * Recursively traverses the given oMainControl and any aggregations (i.e. child controls) it may have * and marks all controls that have an entry in the oErrorMap for their binding. * * @param {Element} oMainControl * - The control or element to be checked. It needs a getVisible() function that returns true. * @param {map} oErrorMap (optional) * - error map with PathReference as key and message-string as value. * @return {boolean} - true, if the oMainControl and its aggregation don't have errors */ function markControls(oMainControl, oErrorMap) { attachValueStateValidationHandler(oMainControl); return _check(oMainControl, oCurrControl => _markControls(oCurrControl, oErrorMap)); } /** * Recursively traverses the given oMainControl and any aggregations (i.e. child controls) it may have * and removes any validation errors they may have. * @param {Element} oMainControl */ function removeErrorsFromControl(oMainControl) { _check(oMainControl, oCurrControl => _removeErrorsFromControl(oCurrControl)); } /** * Recursively traverses the given oControl and any aggregations (i.e. child controls) it may have. * * @param {Element} oControl - The control or element to be validated. * @param {function} fCheckControl - Function for checking individual controls (oControl=>boolean>). */ function _check(oControl, fCheckControl) { var valid = true; // only validate controls and elements which have a 'visible' property // and are visible controls (invisible controls make no sense checking) if (oControl instanceof sap.ui.core.Element && oControl.getVisible && oControl.getVisible()) { // in this UI5 version (1.60.*) Element doesn't have getVisible()! // Validate the control itself. if (!fCheckControl(oControl)) { valid = false; } else { // follow all aggregations, only if this control is still valid for (var sAggregationName in oControl.mAggregations) { var oAggregation = oControl.getAggregation(sAggregationName); valid &= _checkAggregation(oAggregation, fCheckControl); } } } return !!valid; } /** * Checks for duplicates in a Feldliste * * @param oControl * @param oType * @param oValue * @private */ function _checkDuplicates(oControl, oType, oValue) { if (oType?.oConstraints?.allowDuplicates === false) { const oCtx = oControl.getBindingContext(); const sPath = oCtx.getPath(); const sListPath = sPath.substring(0, sPath.lastIndexOf("/")); const aList = oControl.getModel().getProperty(sListPath); if (!Array.isArray(aList)) { throw new Error ("Binding path of the controls value is not part of a list."); } else if (typeof oValue !== "string") { throw new Error ("The controls value is not a string."); } else if (!oType.oFormatOptions?.duplicatesProperties) { //duplicatesProperties defines the property that is used as identifier of the elements of the list to be checked // If duplicatesProperties is not set, search directly in the array -> array of simple elements if (aList.filter(oItem => String(oItem) === oValue).length > 1) { TypeUtil.throwCustomValidateException(new ValidateException(), oType, TypeUtil.ERROR_TYPE.DUPLICATES); } // If duplicatesProperties is set, search in the array under the property 'duplicatesProperties' -> array of object elements } else if (Array.isArray(oType.oFormatOptions.duplicatesProperties)) { // Checks for array of properties if (oType.getName() === "String") { if (aList.filter((oItem, iIdx) => oType.oFormatOptions.duplicatesProperties.every(sProp => oType.formatValue(oControl.getModel().getProperty(`${sListPath}/${iIdx}/${sProp}`)) === oType.formatValue(oCtx.getProperty(sProp)))).length > 1) { // All properties defined in the array have to match TypeUtil.throwCustomValidateException(new ValidateException(), oType, TypeUtil.ERROR_TYPE.DUPLICATES); } } else if (aList.filter((oItem, iIdx) => oType.oFormatOptions.duplicatesProperties.every(sProp => oControl.getModel().getProperty(`${sListPath}/${iIdx}/${sProp}`) === oCtx.getProperty(sProp))).length > 1) { // All properties defined in the array have to match TypeUtil.throwCustomValidateException(new ValidateException(), oType, TypeUtil.ERROR_TYPE.DUPLICATES); } } else if (oType.getName() === "String") { if (aList.filter((oItem, iIdx) => oType.formatValue(oControl.getModel().getProperty(`${sListPath}/${iIdx}/${oType.oFormatOptions.duplicatesProperties}`)) === oType.formatValue(oCtx.getProperty(oType.oFormatOptions.duplicatesProperties))).length > 1) { // Checks for single property TypeUtil.throwCustomValidateException(new ValidateException(), oType, TypeUtil.ERROR_TYPE.DUPLICATES); } } else if (aList.filter((oItem, iIdx) => { const oValue1 = oControl.getModel().getProperty(`${sListPath}/${iIdx}/${oType.oFormatOptions.duplicatesProperties}`); const oValue2 = oCtx.getProperty(oType.oFormatOptions.duplicatesProperties); return oValue1 && oValue2 && oValue1 === oValue2; // Checks whether the values are defined and compares them with one another (background: EF10 does not allow duplicates, but allows multiple empty Kontaktstellen) }).length > 1) { // Checks for single property TypeUtil.throwCustomValidateException(new ValidateException(), oType, TypeUtil.ERROR_TYPE.DUPLICATES); } } } /** * Checks whether in an object at least one of the properties in the Array 'atLeastOneRequiredProperties' is defined * * @param oControl * @param oType * @private */ function _checkAtLeastOneRequired (oControl, oType) { if (oType?.oConstraints?.atLeastOneRequired === true) { const oCtx = oControl.getBindingContext() || oControl.getModel(); if (!oType.oFormatOptions.atLeastOneRequiredProperties.some(sProp => oCtx.getProperty(sProp) ? // Is property defined (Array.isArray(oCtx.getProperty(sProp)) ? // Is property an array (oCtx.getProperty(sProp).length > 0 ? true : false) // Has the array at least 1 element : true) : false) ) { TypeUtil.throwCustomValidateException(new ValidateException(), oType, TypeUtil.ERROR_TYPE.AT_LEAST_ONE_REQUIRED); } } } /** * Checks whether in an object either all or none of the properties in the Array 'dependentProperties' are defined * * @param oControl * @param oType * @private */ function _checkDependencies (oControl, oType) { if (oType?.oConstraints?.checkDependencies === true && Array.isArray(oType.oFormatOptions?.dependentProperties) && oType.oFormatOptions.dependentProperties.length > 1) { const oCtx = oControl.getBindingContext() || oControl.getModel(); // When there is no binding context, just use the default model const oFirstValue = oCtx.getProperty(oType.oFormatOptions.dependentProperties[0]); // Check if either all fields or none are set for (let i = 1; i < oType.oFormatOptions.dependentProperties.length; i++) { const oNextValue = oCtx.getProperty(oType.oFormatOptions.dependentProperties[i]); if(!((oFirstValue && oNextValue) || (!oFirstValue && !oNextValue))) { TypeUtil.throwCustomValidateException(new ValidateException(), oType, TypeUtil.ERROR_TYPE.DEPENDENCY); } } } } /** * Validation function for a single control. * Calls the validate() function on the control if available, * then the parseValue() and validateValue() function on the type. * * @param oControl * @return {boolean} * @private */ function _validateControl(oControl) { if (oControl.validate) { try { oControl.validate(); } catch (oException) { // catch any parse/validation errors if (oException instanceof ValidateException) { oControl.fireValidationError({ element: oControl, exception: oException, message: oException.message }, false, true); // bAllowPreventDefault, bEnableEventBubbling } else { Log.error(oException); } return false; } } var editable = _isControlEditable(oControl); // check control for any properties worth validating for (var sPropertyName in oControl.mProperties) { var oControlBinding = oControl.getBinding(sPropertyName) || oControl.getBindingInfo(sPropertyName); // MultiComboBoxes seem to not always have a binding, but instead have a binding info if (!oControlBinding) { continue; } var oType; var oValue; try { // retrieves data type (which may have validation constraints) depending on the kind of controlBinding - binding / bindingInfo if (oControlBinding.getType) { oType = oControlBinding.getType(); } else { oType = oControlBinding.type; } if (editable && oType) { // Read current value directly from control, not from binding. oValue = oControl.getProperty(sPropertyName); // Try validating this value (by parsing and validating on the type). // This may trigger a ParseException or ValidateException. var oInternalValue = oType.parseValue(oValue, oControlBinding.sInternalType); oType.validateValue(oInternalValue); } // check that at least one of the properties is defined _checkAtLeastOneRequired(oControl, oType); // check that either all or none of the properties are defined _checkDependencies(oControl, oType); // check for duplicates _checkDuplicates(oControl, oType, oValue); // If no validation error occurred, fire validation success oControl.fireValidationSuccess({ element: oControl, property: sPropertyName }, false, true); // bAllowPreventDefault, bEnableEventBubbling } catch (oException) { // catch any parse/validation errors // Instead of setting value state manually to error which causes inconsistent behavior from standard // on-change validation (e.g. error state set by on-demand validation is not reset during on-change validation), // we now fire a parse or validation error just like ManagedObject#updateModelProperty does // (unfortunately this method is private so we won't call it directly even though it works - like: // oControl.updateModelProperty(oValidateProperty, oValue, oValue); // )! if (oException instanceof ParseException) { oControl.fireParseError({ element: oControl, property: sPropertyName, type: oType, newValue: oValue, oldValue: oValue, exception: oException, message: oException.message }, false, true); // bAllowPreventDefault, bEnableEventBubbling } else if (oException instanceof ValidateException) { oControl.fireValidationError({ element: oControl, property: sPropertyName, type: oType, newValue: oValue, oldValue: oValue, exception: oException, message: oException.message }, false, true); // bAllowPreventDefault, bEnableEventBubbling } else { Log.error(oException); } return false; } } oControl.fireValidationSuccess({ element: oControl }, false, true); // bAllowPreventDefault, bEnableEventBubbling return true; } /** * Marks errors in the oErrorMap for a single control. * * @param oControl * @param oErrorMap * @return {boolean} * @private */ function _markControls(oControl, oErrorMap) { // check control for any properties worth validating const aRelevantProperties = []; for (let sPropertyName in oControl.mProperties) { aRelevantProperties.push(sPropertyName); } for (let sAggregationName in oControl.mForwardedAggregations) { aRelevantProperties.push(sAggregationName); } for (let nIndex = 0; nIndex < aRelevantProperties.length; nIndex++) { const sPropertyName = aRelevantProperties[nIndex]; var oControlBinding = oControl.getBinding(sPropertyName); if (!oControlBinding) { continue; } // Check if there are any errors for this binding in oErrorMap. var aModelReferences = ModelReferenceUtil.buildPathReferencesForBinding(oControlBinding); for (var sModelReference of aModelReferences) { var sErrorTextKey = oErrorMap[sModelReference]; if (sErrorTextKey) { oControl.fireValidationError({ element: oControl, message: sErrorTextKey }, false, true); // bAllowPreventDefault, bEnableEventBubbling return false; } } } oControl.fireValidationSuccess({ element: oControl }, false, true); // bAllowPreventDefault, bEnableEventBubbling return true; } /** * Remove errors for a single control. * @param oControl * @return {boolean} - returns always true to check aggregations as well * @private */ function _removeErrorsFromControl (oControl) { for (var sPropertyName in oControl.mProperties) { oControl.fireValidationSuccess({ element: oControl, property: sPropertyName }); } if (oControl.setValueState) { oControl.setValueState("None"); // In some case (e.g. Teilkosten) the value state of the control is set manually, thus it has to be removed manually as well } oControl.fireValidationSuccess({ element: oControl }); return true; } function _isControlEditable(oControl) { try { return oControl.getProperty("editable"); } catch (ex) { /* ignore */ return true; } } function _checkAggregation(oAggregation, fCheckControl) { var valid = true; if (oAggregation instanceof Array) { // generally, aggregations are of type Array oAggregation.forEach(function (oControl) { valid &= _check(oControl, fCheckControl); }); } else if (oAggregation) { // ...however, with sap.ui.layout.form.Form, it is a single object *sigh* // Operator &= is important because we want to continue validation even if invalid (so we don't use &&)! valid &= _check(oAggregation, fCheckControl); } return valid; } function attachValueStateValidationHandler(oControl) { const sErrorStyleClass = "acme-control-fehler"; if (oControl._validationHandlerAttached) { return; } function setErrorStyleClass(oSource, sValueState) { if (!(oSource instanceof sap.ui.core.Control)) { return; } const bHasErrorStyleClass = oSource.hasStyleClass(sErrorStyleClass); if (ValueState.Error === sValueState && !bHasErrorStyleClass) { oSource.addStyleClass(sErrorStyleClass); } else if (ValueState.Success === sValueState && bHasErrorStyleClass) { oSource.removeStyleClass(sErrorStyleClass); } } function setValueState(oSource, sValueState, sValueStateText) { if (oSource.setValueState) { oSource.setValueState(sValueState); } else { setErrorStyleClass(oSource, sValueState); } if (oSource.setValueStateText) { oSource.setValueStateText(sValueStateText); } } oControl.attachParseError(function (oEvent) { setValueState(oEvent.getSource(), ValueState.Error, oEvent.getParameter("message")); }); oControl.attachValidationError(function (oEvent) { setValueState(oEvent.getSource(), ValueState.Error, oEvent.getParameter("message")); }); oControl.attachValidationSuccess(function (oEvent) { setValueState(oEvent.getSource(), ValueState.None, null); }); oControl._validationHandlerAttached = true; } return { /** * Recursively validates the given oMainControl and any aggregations (i.e. child controls) it may have. * * @param {Element} oMainControl * - The control or element to be validated. It needs a getVisible() function that returns true. * @return {boolean} - true, if the oMainControl and its aggregation are valid * * Custom constraints that are taken into account by validate: * - required: boolean - the value has to be set * - allowDuplicates: boolean - duplicates in a list are alowed * - atLeastOneRequired: boolean - in a set of properties at least one of the values has to be set * - checkDependencies: boolean - an element is only valid either if the value of all properties in a set are defined or none * * Custom format options that are used by validate: * - emptyString: TODO: explain what this is for * - customError: string - I18n key of a custom error message that will be displayed on invalid input * - duplicatesProperties: string | [string] - single property or array of properties that are checked to determine duplicates; if it is empty or undefined while duplicates are not allowed, the list is checked directly for duplicates (list of references) * - atLeastOneRequiredProperties: [string] - array of properties of which the value of at least one of them has to be defined * - dependentProperties: [string] - array of properties of which the value either of all have to be defined or none * - customDuplicatesError: string - I18n key of a custom error message that will be displayed on when duplicates are found in the list * - customAtLeastOneRequiredError: string - I18n key of a custom error message that will be displayed when none of the values are set * - customDependencyError: string - I18n key of a custom error message that will be displayed when dependencies are not respected * - customErrorPrefix: string - I18n key of an error prefix that will be added to 'customDuplicatesError' and 'customAtLeastOneRequiredError' * - customErrorPrefixOnly: boolean - TODO: explain what this is for * - customErrorComponentId: string - Component(s) in which the control is defined */ validate: validate, /** * Recursively traverses the given oMainControl and any aggregations (i.e. child controls) it may have * and marks all controls that have an entry in the oErrorMap for their binding. * * @param {Element} oMainControl * - The control or element to be checked. It needs a getVisible() function that returns true. * @param {map} oErrorMap (optional) * - error map with PathReference as key and message-string as value. * @return {boolean} - true, if the oMainControl and its aggregation don't have errors */ markControls: markControls, /** * Attaches default validation handlers to the control (i.e. attachParseError, attachValidationError, attachValidationSuccess). * If the *source* control of the event has a setValueState() and setValueStateText() function, these will be called. * This function makes sure to only attach validation handlers once by setting a flag _validationHandlerAttached on the control. * As validation events are propagated up the "UI5 DOM", normally it's enough to call this function only on one parent control. * validate() and markControls() implicitly call attachValueStateValidationHandler(). * * @param oControl */ attachValueStateValidationHandler: attachValueStateValidationHandler, /** * Recursively traverses the given oMainControl and any aggregations (i.e. child controls) it may have * and removes any validation errors they may have. * * @param {Element} oMainControl * - The control or element to be checked. It needs a getVisible() function that returns true. */ removeErrorsFromControl: removeErrorsFromControl }; });
Image sources
The cover image used in this post was created by Pixabay under the following license. All other images on this page were created by eXXcellent solutions under the terms of the Creative Commons Attribution 4.0 International License