luci-base: add validator array support
authorPaul Donald <redacted>
Fri, 23 Jan 2026 19:53:21 +0000 (20:53 +0100)
committerPaul Donald <redacted>
Fri, 23 Jan 2026 21:42:00 +0000 (22:42 +0100)
While it's possible to stack validation using .datatype semantics,
e.g. o.datatype = 'and(foo,bar)' and those execute serially, they
depend on the internal validation factory and the built-ins there.

Now, one can define multiple functions in the calling code (passed
in an array) which execute serially. All validation functions
shall return true to succeed.

e.g.

```js
function foo(sid, val) {
 return val.includes('foo');
}

function bar(sid, val) {
 return val.includes('bar');
}

o = s.option(form.Value, 'foobar', _('foobar'));
o.default = 'foobar';
o.validate = [foo, bar];
```

This helps make validation less complex when special data-types
with high cardinality are in play. Previously, reuse of the this
context when calling sub functions was also lost.

The validate property passed to the new ui widget in form.js

```js
new ui.XX(..., { ... validate: this.getValidator(section_id), ...});
```

takes this.getValidator, which either binds a single validation
function or calls all of the validators, if the form element's
this.validate was an [array]. The result and effect are the same.

If a ui element is manually instantiated (when it's not being
called via form.js and this.getValidator might be unavailable),
passing an array to options.validate works identically:

```js
function foo(val) {
 return val.includes('foo');
}

function bar(val) {
 return val.includes('bar');
}

new ui.XX(..., { ... validate: [foo, bar], ...});
```

And the validation factory calls them serially.

Signed-off-by: Paul Donald <redacted>
modules/luci-base/htdocs/luci-static/resources/form.js
modules/luci-base/htdocs/luci-static/resources/ui.js
modules/luci-base/htdocs/luci-static/resources/validation.js

index de6c12290d39c1ed06eb893817d7bcf5e32936e8..8d627f8f07c7630cfd70dee453dbc6939f11b80b 100644 (file)
@@ -1920,6 +1920,37 @@ const CBIAbstractValue = CBIAbstractElement.extend(/** @lends LuCI.form.Abstract
                return true;
        },
 
+       /**
+        * Get the validator function for the widget, handling both single functions
+        * and arrays of functions.
+        *
+        * @private
+        * @param {string} section_id
+        * The configuration section ID
+        *
+        * @returns {function}
+        * Returns a bound validator function suitable for passing to UI widgets.
+        * If this.validate is an array, returns a wrapper that calls each validator
+        * serially. Otherwise returns the bound validate method.
+        */
+       getValidator(section_id) {
+               if (Array.isArray(this.validate)) {
+                       const validators = this.validate;
+                       const element = this;
+                       return (value) => {
+                               for (let val of validators) {
+                                       if (typeof(val) === 'function') {
+                                               const result = val.call(element, section_id, value);
+                                               if (result !== true)
+                                                       return result;
+                                       }
+                               }
+                               return true;
+                       };
+               }
+               return L.bind(this.validate, this, section_id);
+       },
+
        /**
         * Test whether the input value is currently valid.
         *
@@ -3870,7 +3901,7 @@ const CBIValue = CBIAbstractValue.extend(/** @lends LuCI.form.Value.prototype */
                                optional: this.optional || this.rmempty,
                                datatype: this.datatype,
                                select_placeholder: this.placeholder ?? placeholder,
-                               validate: L.bind(this.validate, this, section_id),
+                               validate: this.getValidator(section_id),
                                disabled: (this.readonly != null) ? this.readonly : this.map.readonly
                        });
                }
@@ -3881,7 +3912,7 @@ const CBIValue = CBIAbstractValue.extend(/** @lends LuCI.form.Value.prototype */
                                optional: this.optional || this.rmempty,
                                datatype: this.datatype,
                                placeholder: this.placeholder,
-                               validate: L.bind(this.validate, this, section_id),
+                               validate: this.getValidator(section_id),
                                disabled: (this.readonly != null) ? this.readonly : this.map.readonly
                        });
                }
@@ -3949,7 +3980,7 @@ const CBIDynamicList = CBIValue.extend(/** @lends LuCI.form.DynamicList.prototyp
                        optional: this.optional || this.rmempty,
                        datatype: this.datatype,
                        placeholder: this.placeholder,
-                       validate: L.bind(this.validate, this, section_id),
+                       validate: this.getValidator(section_id),
                        disabled: (this.readonly != null) ? this.readonly : this.map.readonly
                });
 
@@ -4041,7 +4072,7 @@ const CBIListValue = CBIValue.extend(/** @lends LuCI.form.ListValue.prototype */
                        optional: this.optional,
                        orientation: this.orientation,
                        placeholder: this.placeholder,
-                       validate: L.bind(this.validate, this, section_id),
+                       validate: this.getValidator(section_id),
                        disabled: (this.readonly != null) ? this.readonly : this.map.readonly
                });
 
@@ -4136,7 +4167,7 @@ const CBIRichListValue = CBIListValue.extend(/** @lends LuCI.form.ListValue.prot
                        orientation: this.orientation,
                        select_placeholder: this.select_placeholder || this.placeholder,
                        custom_placeholder: this.custom_placeholder || this.placeholder,
-                       validate: L.bind(this.validate, this, section_id),
+                       validate: this.getValidator(section_id),
                        disabled: (this.readonly != null) ? this.readonly : this.map.readonly
                });
 
@@ -4281,7 +4312,7 @@ const CBIRangeSliderValue = CBIValue.extend(/** @lends LuCI.form.RangeSliderValu
                        calcunits: this.calcunits,
                        disabled: this.readonly || this.disabled,
                        datatype: this.datatype,
-                       validate: L.bind(this.validate, this, section_id),
+                       validate: this.getValidator(section_id),
                });
 
                this.widget = slider;
@@ -4403,7 +4434,7 @@ const CBIFlagValue = CBIValue.extend(/** @lends LuCI.form.Flag.prototype */ {
                        id: this.cbid(section_id),
                        value_enabled: this.enabled,
                        value_disabled: this.disabled,
-                       validate: L.bind(this.validate, this, section_id),
+                       validate: this.getValidator(section_id),
                        tooltip,
                        tooltipicon: this.tooltipicon,
                        disabled: (this.readonly != null) ? this.readonly : this.map.readonly
@@ -4546,7 +4577,7 @@ const CBIMultiValue = CBIDynamicList.extend(/** @lends LuCI.form.MultiValue.prot
                        create: this.create,            
                        display_items: this.display_size ?? this.size ?? 3,
                        dropdown_items: this.dropdown_size ?? this.size ?? -1,
-                       validate: L.bind(this.validate, this, section_id),
+                       validate: this.getValidator(section_id),
                        disabled: (this.readonly != null) ? this.readonly : this.map.readonly
                });
 
@@ -4639,7 +4670,7 @@ const CBITextValue = CBIValue.extend(/** @lends LuCI.form.TextValue.prototype */
                        cols: this.cols,
                        rows: this.rows,
                        wrap: this.wrap,
-                       validate: L.bind(this.validate, this, section_id),
+                       validate: this.getValidator(section_id),
                        readonly: (this.readonly != null) ? this.readonly : this.map.readonly,
                        disabled: (this.disabled != null) ? this.disabled : null,
                });
index c8857b01c5d895817bdfa4e402ba5974a12ea554..b9ccb1e18708892e4eb2607154a274754a6e4613 100644 (file)
@@ -55,11 +55,14 @@ const UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype
         * It defaults to `string` which will allow any value.
         * See {@link LuCI.validation} for details on the expression format.
         *
-        * @property {function} [validator]
-        * Specifies a custom validator function which is invoked after the
-        * standard validation constraints are checked. The function should return
-        * `true` to accept the given input value. Any other return value type is
-        * converted to a string and treated as validation error message.
+        * @property {function|function[]} [validator]
+        * Specifies one or more custom validator functions which are invoked after
+        * the standard validation constraints are checked. Each function should
+        * return `true` to accept the given input value. When multiple functions
+        * are provided as an array, they are executed serially and validation stops
+        * at the first function that returns a non-true value. Any non-true return
+        * value type is converted to a string and treated as a validation error
+        * message.
         *
         * @property {boolean} [disabled=false]
         * Specifies whether the widget should be rendered in disabled state
@@ -5266,11 +5269,14 @@ const UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
         * If an input element is not marked optional it must not be empty,
         * otherwise it will be marked as invalid.
         *
-        * @param {function} [vfunc]
-        * Specifies a custom validation function which is invoked after the
-        * other validation constraints are applied. The validation must return
-        * `true` to accept the passed value. Any other return type is converted
-        * to a string and treated as validation error message.
+        * @param {function|function[]} [vfunc]
+        * Specifies a custom validation function or an array of validation functions
+        * which are invoked after the other validation constraints are applied. Each
+        * function must return `true` to accept the passed value. When multiple
+        * functions are provided as an array, they are executed serially and
+        * validation stops at the first function that returns a non-true value.
+        * Any non-true return type is converted to a string and treated as validation
+        * error message.
         *
         * @param {...string} [events=blur, keyup]
         * The list of events to bind. Each received event will trigger a field
index 5959338bf43d57ac699921bc732c2fb3a850016f..9e44e47185cb3e2ef8d6be46db882b4464e4db55 100644 (file)
@@ -86,8 +86,18 @@ const Validator = baseclass.extend({
                        return false;
                }
 
-               if (typeof(this.vfunc) == 'function')
+               if (typeof(this.vfunc) == 'function') {
                        valid = this.vfunc(this.value);
+               } else if (Array.isArray(this.vfunc)) {
+                       /* Execute validation functions serially */
+                       for (let val of this.vfunc) {
+                               if (typeof(val) == 'function') {
+                                       valid = val(this.value);
+                                       if (valid !== true)
+                                               break;
+                               }
+                       }
+               }
 
                if (valid !== true) {
                        this.assert(false, valid);
git clone https://git.99rst.org/PROJECT