luci-base: FlagValue fix type of enabled and disabled properties
[project/luci.git] / modules / luci-base / htdocs / luci-static / resources / form.js
index 8fc8d3aca739926b567423a79dd8af5876b9d447..b1e7d2c39358a466cd78f2f60b35e4fee6a50329 100644 (file)
@@ -74,7 +74,7 @@ var CBIJSONConfig = baseclass.extend({
                        if (indexA != indexB)
                                return (indexA - indexB);
 
-                       return (a > b);
+                       return L.naturalCompare(a, b);
                }, this));
 
                for (var i = 0; i < section_ids.length; i++)
@@ -204,7 +204,7 @@ var CBIAbstractElement = baseclass.extend(/** @lends LuCI.form.AbstractElement.p
        /**
         * Add another form element as children to this element.
         *
-        * @param {AbstractElement} element
+        * @param {AbstractElement} obj
         * The form element to add.
         */
        append: function(obj) {
@@ -217,7 +217,7 @@ var CBIAbstractElement = baseclass.extend(/** @lends LuCI.form.AbstractElement.p
         * The `parse()` function recursively walks the form element tree and
         * triggers input value reading and validation for each encountered element.
         *
-        * Elements which are hidden due to unsatisified dependencies are skipped.
+        * Elements which are hidden due to unsatisfied dependencies are skipped.
         *
         * @returns {Promise<void>}
         * Returns a promise resolving once this element's value and the values of
@@ -277,18 +277,23 @@ var CBIAbstractElement = baseclass.extend(/** @lends LuCI.form.AbstractElement.p
        /**
         * Strip any HTML tags from the given input string.
         *
-        * @param {string} input
+        * @param {string} s
         * The input string to clean.
         *
         * @returns {string}
-        * The cleaned input string with HTML removes removed.
+        * The cleaned input string with HTML tags removed.
         */
        stripTags: function(s) {
                if (typeof(s) == 'string' && !s.match(/[<>]/))
                        return s;
 
-               var x = E('div', {}, s);
-               return x.textContent || x.innerText || '';
+               var x = dom.elem(s) ? s : dom.parse('<div>' + s + '</div>');
+
+               x.querySelectorAll('br').forEach(function(br) {
+                       x.replaceChild(document.createTextNode('\n'), br);
+               });
+
+               return (x.textContent || x.innerText || '').replace(/([ \t]*\n)+/g, '\n');
        },
 
        /**
@@ -343,7 +348,7 @@ var CBIAbstractElement = baseclass.extend(/** @lends LuCI.form.AbstractElement.p
  * @classdesc
  *
  * The `Map` class represents one complete form. A form usually maps one UCI
- * configuraton file and is divided into multiple sections containing multiple
+ * configuration file and is divided into multiple sections containing multiple
  * fields each.
  *
  * It serves as main entry point into the `LuCI.form` for typical view code.
@@ -359,7 +364,7 @@ var CBIAbstractElement = baseclass.extend(/** @lends LuCI.form.AbstractElement.p
  *
  * @param {string} [description]
  * The description text of the form which is usually rendered as text
- * paragraph below the form title and before the actual form conents.
+ * paragraph below the form title and before the actual form contents.
  * If omitted, the corresponding paragraph element will not be rendered.
  */
 var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ {
@@ -497,11 +502,11 @@ var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ {
         * @param {LuCI.form.AbstractSection} sectionclass
         * The section class to use for rendering the configuration section.
         * Note that this value must be the class itself, not a class instance
-        * obtained from calling `new`. It must also be a class dervied from
+        * obtained from calling `new`. It must also be a class derived from
         * `LuCI.form.AbstractSection`.
         *
         * @param {...string} classargs
-        * Additional arguments which are passed as-is to the contructor of the
+        * Additional arguments which are passed as-is to the constructor of the
         * given section class. Refer to the class specific constructor
         * documentation for details.
         *
@@ -553,7 +558,7 @@ var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ {
         * The `parse()` function recursively walks the form element tree and
         * triggers input value reading and validation for each child element.
         *
-        * Elements which are hidden due to unsatisified dependencies are skipped.
+        * Elements which are hidden due to unsatisfied dependencies are skipped.
         *
         * @returns {Promise<void>}
         * Returns a promise resolving once the entire form completed parsing all
@@ -583,7 +588,7 @@ var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ {
         *
         * @param {boolean} [silent=false]
         * If set to `true`, trigger an alert message to the user in case saving
-        * the form data failes. Otherwise fail silently.
+        * the form data failures. Otherwise fail silently.
         *
         * @returns {Promise<void>}
         * Returns a promise resolving once the entire save operation is complete.
@@ -601,9 +606,9 @@ var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ {
                                if (!silent) {
                                        ui.showModal(_('Save error'), [
                                                E('p', {}, [ _('An error occurred while saving the form:') ]),
-                                               E('p', {}, [ E('em', { 'style': 'white-space:pre' }, [ e.message ]) ]),
+                                               E('p', {}, [ E('em', { 'style': 'white-space:pre-wrap' }, [ e.message ]) ]),
                                                E('div', { 'class': 'right' }, [
-                                                       E('button', { 'class': 'btn', 'click': ui.hideModal }, [ _('Dismiss') ])
+                                                       E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Dismiss') ])
                                                ])
                                        ]);
                                }
@@ -683,15 +688,15 @@ var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ {
        /**
         * Find a form option element instance.
         *
-        * @param {string} name_or_id
+        * @param {string} name
         * The name or the full ID of the option element to look up.
         *
         * @param {string} [section_id]
         * The ID of the UCI section containing the option to look up. May be
         * omitted if a full ID is passed as first argument.
         *
-        * @param {string} [config]
-        * The name of the UCI configuration the option instance is belonging to.
+        * @param {string} [config_name]
+        * The name of the UCI configuration the option instance belongs to.
         * Defaults to the main UCI configuration of the map if omitted.
         *
         * @returns {Array<LuCI.form.AbstractValue,string>|null}
@@ -792,7 +797,7 @@ var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ {
  *
  * @param {string} [description]
  * The description text of the form which is usually rendered as text
- * paragraph below the form title and before the actual form conents.
+ * paragraph below the form title and before the actual form contents.
  * If omitted, the corresponding paragraph element will not be rendered.
  */
 var CBIJSONMap = CBIMap.extend(/** @lends LuCI.form.JSONMap.prototype */ {
@@ -916,7 +921,7 @@ var CBIAbstractSection = CBIAbstractElement.extend(/** @lends LuCI.form.Abstract
         * triggers input value reading and validation for each encountered child
         * option element.
         *
-        * Options which are hidden due to unsatisified dependencies are skipped.
+        * Options which are hidden due to unsatisfied dependencies are skipped.
         *
         * @returns {Promise<void>}
         * Returns a promise resolving once the values of all child elements have
@@ -962,7 +967,7 @@ var CBIAbstractSection = CBIAbstractElement.extend(/** @lends LuCI.form.Abstract
         * contents. If omitted, no description will be rendered.
         *
         * @throws {Error}
-        * Throws an exeption if a tab with the same `name` already exists.
+        * Throws an exception if a tab with the same `name` already exists.
         */
        tab: function(name, title, description) {
                if (this.tabs && this.tabs[name])
@@ -992,11 +997,11 @@ var CBIAbstractSection = CBIAbstractElement.extend(/** @lends LuCI.form.Abstract
         * @param {LuCI.form.AbstractValue} optionclass
         * The option class to use for rendering the configuration option. Note
         * that this value must be the class itself, not a class instance obtained
-        * from calling `new`. It must also be a class dervied from
+        * from calling `new`. It must also be a class derived from
         * [LuCI.form.AbstractSection]{@link LuCI.form.AbstractSection}.
         *
         * @param {...*} classargs
-        * Additional arguments which are passed as-is to the contructor of the
+        * Additional arguments which are passed as-is to the constructor of the
         * given option class. Refer to the class specific constructor
         * documentation for details.
         *
@@ -1019,17 +1024,17 @@ var CBIAbstractSection = CBIAbstractElement.extend(/** @lends LuCI.form.Abstract
        /**
         * Add a configuration option widget to a tab of the section.
         *
-        * @param {string} tabname
+        * @param {string} tabName
         * The name of the section tab to add the option element to.
         *
         * @param {LuCI.form.AbstractValue} optionclass
         * The option class to use for rendering the configuration option. Note
         * that this value must be the class itself, not a class instance obtained
-        * from calling `new`. It must also be a class dervied from
+        * from calling `new`. It must also be a class derived from
         * [LuCI.form.AbstractSection]{@link LuCI.form.AbstractSection}.
         *
         * @param {...*} classargs
-        * Additional arguments which are passed as-is to the contructor of the
+        * Additional arguments which are passed as-is to the constructor of the
         * given option class. Refer to the class specific constructor
         * documentation for details.
         *
@@ -1347,6 +1352,7 @@ var CBIAbstractValue = CBIAbstractElement.extend(/** @lends LuCI.form.AbstractVa
                this.default = null;
                this.size = null;
                this.optional = false;
+               this.retain = false;
        },
 
        /**
@@ -1369,6 +1375,17 @@ var CBIAbstractValue = CBIAbstractElement.extend(/** @lends LuCI.form.AbstractVa
         * @default false
         */
 
+       /**
+        * If set to `true`, the underlying ui input widget value is not cleared
+        * from the configuration on unsatisfied dependencies. The default behavior
+        * is to remove the values of all options whose dependencies are not
+        * fulfilled.
+        *
+        * @name LuCI.form.AbstractValue.prototype#retain
+        * @type boolean
+        * @default false
+        */
+
        /**
         * Sets a default value to use when the underlying UCI option is not set.
         *
@@ -1522,10 +1539,10 @@ var CBIAbstractValue = CBIAbstractElement.extend(/** @lends LuCI.form.AbstractVa
         */
 
        /**
-        * Add a dependency contraint to the option.
+        * Add a dependency constraint to the option.
         *
         * Dependency constraints allow making the presence of option elements
-        * dependant on the current values of certain other options within the
+        * dependent on the current values of certain other options within the
         * same form. An option element with unsatisfied dependencies will be
         * hidden from the view and its current value is omitted when saving.
         *
@@ -1537,7 +1554,7 @@ var CBIAbstractValue = CBIAbstractElement.extend(/** @lends LuCI.form.AbstractVa
         * a logical "and" expression.
         *
         * Option names may be given in "dot notation" which allows to reference
-        * option elements outside of the current form section. If a name without
+        * option elements outside the current form section. If a name without
         * dot is specified, it refers to an option within the same configuration
         * section. If specified as <code>configname.sectionid.optionname</code>,
         * options anywhere within the same form may be specified.
@@ -1603,11 +1620,11 @@ var CBIAbstractValue = CBIAbstractElement.extend(/** @lends LuCI.form.AbstractVa
         *  </li>
         * </ul>
         *
-        * @param {string|Object<string, string|RegExp>} optionname_or_depends
+        * @param {string|Object<string, string|RegExp>} field
         * The name of the option to depend on or an object describing multiple
-        * dependencies which must be satified (a logical "and" expression).
+        * dependencies which must be satisfied (a logical "and" expression).
         *
-        * @param {string} optionvalue|RegExp
+        * @param {string|RegExp} value
         * When invoked with a plain option name as first argument, this parameter
         * specifies the expected value. In case an object is passed as first
         * argument, this parameter is ignored.
@@ -1910,6 +1927,20 @@ var CBIAbstractValue = CBIAbstractElement.extend(/** @lends LuCI.form.AbstractVa
                return elem ? elem.isValid() : true;
        },
 
+       /**
+        * Returns the current validation error for this input.
+        *
+        * @param {string} section_id
+        * The configuration section ID
+        *
+        * @returns {string}
+        * The validation error at this time
+        */
+       getValidationError: function (section_id) {
+               var elem = this.getUIElement(section_id);
+               return elem ? elem.getValidationError() : '';
+       },
+
        /**
         * Test whether the option element is currently active.
         *
@@ -1965,28 +1996,38 @@ var CBIAbstractValue = CBIAbstractElement.extend(/** @lends LuCI.form.AbstractVa
         * validation constraints.
         */
        parse: function(section_id) {
-               var active = this.isActive(section_id),
-                   cval = this.cfgvalue(section_id),
-                   fval = active ? this.formvalue(section_id) : null;
+               var active = this.isActive(section_id);
 
                if (active && !this.isValid(section_id)) {
-                       var title = this.stripTags(this.title).trim();
-                       return Promise.reject(new TypeError(_('Option "%s" contains an invalid input value.').format(title || this.option)));
-               }
+                       var title = this.stripTags(this.title).trim(),
+                           error = this.getValidationError(section_id);
 
-               if (fval != '' && fval != null) {
-                       if (this.forcewrite || !isEqual(cval, fval))
-                               return Promise.resolve(this.write(section_id, fval));
+                       return Promise.reject(new TypeError(
+                               _('Option "%s" contains an invalid input value.').format(title || this.option) + ' ' + error));
                }
-               else {
-                       if (!active || this.rmempty || this.optional) {
-                               return Promise.resolve(this.remove(section_id));
+
+               if (active) {
+                       var cval = this.cfgvalue(section_id),
+                           fval = this.formvalue(section_id);
+
+                       if (fval == null || fval == '') {
+                               if (this.rmempty || this.optional) {
+                                       return Promise.resolve(this.remove(section_id));
+                               }
+                               else {
+                                       var title = this.stripTags(this.title).trim();
+
+                                       return Promise.reject(new TypeError(
+                                               _('Option "%s" must not be empty.').format(title || this.option)));
+                               }
                        }
-                       else if (!isEqual(cval, fval)) {
-                               var title = this.stripTags(this.title).trim();
-                               return Promise.reject(new TypeError(_('Option "%s" must not be empty.').format(title || this.option)));
+                       else if (this.forcewrite || !isEqual(cval, fval)) {
+                               return Promise.resolve(this.write(section_id, fval));
                        }
                }
+               else if (!this.retain) {
+                       return Promise.resolve(this.remove(section_id));
+               }
 
                return Promise.resolve();
        },
@@ -2203,10 +2244,8 @@ var CBITypedSection = CBIAbstractSection.extend(/** @lends LuCI.form.TypedSectio
 
                        dom.append(createEl, [
                                E('div', {}, nameEl),
-                               E('input', {
+                               E('button', {
                                        'class': 'cbi-button cbi-button-add',
-                                       'type': 'submit',
-                                       'value': btn_title || _('Add'),
                                        'title': btn_title || _('Add'),
                                        'click': ui.createHandlerFn(this, function(ev) {
                                                if (nameEl.classList.contains('cbi-input-invalid'))
@@ -2214,11 +2253,23 @@ var CBITypedSection = CBIAbstractSection.extend(/** @lends LuCI.form.TypedSectio
 
                                                return this.handleAdd(ev, nameEl.value);
                                        }),
-                                       'disabled': this.map.readonly || null
-                               })
+                                       'disabled': this.map.readonly || true
+                               }, [ btn_title || _('Add') ])
                        ]);
 
-                       ui.addValidator(nameEl, 'uciname', true, 'blur', 'keyup');
+                       if (this.map.readonly !== true) {
+                               ui.addValidator(nameEl, 'uciname', true, function(v) {
+                                       var button = createEl.querySelector('.cbi-section-create > .cbi-button-add');
+                                       if (v !== '') {
+                                               button.disabled = null;
+                                               return true;
+                                       }
+                                       else {
+                                               button.disabled = true;
+                                               return _('Expecting: %s').format(_('non-empty value'));
+                                       }
+                               }, 'blur', 'keyup');
+                       }
                }
 
                return createEl;
@@ -2226,10 +2277,7 @@ var CBITypedSection = CBIAbstractSection.extend(/** @lends LuCI.form.TypedSectio
 
        /** @private */
        renderSectionPlaceholder: function() {
-               return E([
-                       E('em', _('This section contains no values yet')),
-                       E('br'), E('br')
-               ]);
+               return E('em', _('This section contains no values yet'));
        },
 
        /** @private */
@@ -2479,6 +2527,8 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
                    config_name = this.uciconfig || this.map.config,
                    max_cols = isNaN(this.max_cols) ? this.children.length : this.max_cols,
                    has_more = max_cols < this.children.length,
+                   drag_sort = this.sortable && !('ontouchstart' in window),
+                   touch_sort = this.sortable && ('ontouchstart' in window),
                    sectionEl = E('div', {
                                'id': 'cbi-%s-%s'.format(config_name, this.sectiontype),
                                'class': 'cbi-section cbi-tblsection',
@@ -2507,14 +2557,16 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
                                'id': 'cbi-%s-%s'.format(config_name, cfgsections[i]),
                                'class': 'tr cbi-section-table-row',
                                'data-sid': cfgsections[i],
-                               'draggable': this.sortable ? true : null,
-                               'mousedown': this.sortable ? L.bind(this.handleDragInit, this) : null,
-                               'dragstart': this.sortable ? L.bind(this.handleDragStart, this) : null,
-                               'dragover': this.sortable ? L.bind(this.handleDragOver, this) : null,
-                               'dragenter': this.sortable ? L.bind(this.handleDragEnter, this) : null,
-                               'dragleave': this.sortable ? L.bind(this.handleDragLeave, this) : null,
-                               'dragend': this.sortable ? L.bind(this.handleDragEnd, this) : null,
-                               'drop': this.sortable ? L.bind(this.handleDrop, this) : null,
+                               'draggable': (drag_sort || touch_sort) ? true : null,
+                               'mousedown': drag_sort ? L.bind(this.handleDragInit, this) : null,
+                               'dragstart': drag_sort ? L.bind(this.handleDragStart, this) : null,
+                               'dragover': drag_sort ? L.bind(this.handleDragOver, this) : null,
+                               'dragenter': drag_sort ? L.bind(this.handleDragEnter, this) : null,
+                               'dragleave': drag_sort ? L.bind(this.handleDragLeave, this) : null,
+                               'dragend': drag_sort ? L.bind(this.handleDragEnd, this) : null,
+                               'drop': drag_sort ? L.bind(this.handleDrop, this) : null,
+                               'touchmove': touch_sort ? L.bind(this.handleTouchMove, this) : null,
+                               'touchend': touch_sort ? L.bind(this.handleTouchEnd, this) : null,
                                'data-title': (sectionname && (!this.anonymous || this.sectiontitle)) ? sectionname : null,
                                'data-section-id': cfgsections[i]
                        });
@@ -2532,8 +2584,7 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
 
                if (nodes.length == 0)
                        tableEl.appendChild(E('tr', { 'class': 'tr cbi-section-table-row placeholder' },
-                               E('td', { 'class': 'td' },
-                                       E('em', {}, _('This section contains no values yet')))));
+                               E('td', { 'class': 'td' }, this.renderSectionPlaceholder())));
 
                sectionEl.appendChild(tableEl);
 
@@ -2564,7 +2615,8 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
                if (has_titles) {
                        var trEl = E('tr', {
                                'class': 'tr cbi-section-table-titles ' + anon_class,
-                               'data-title': (!this.anonymous || this.sectiontitle) ? _('Name') : null
+                               'data-title': (!this.anonymous || this.sectiontitle) ? _('Name') : null,
+                               'click': this.sortable ? ui.createHandlerFn(this, 'handleSort') : null
                        });
 
                        for (var i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) {
@@ -2573,7 +2625,8 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
 
                                trEl.appendChild(E('th', {
                                        'class': 'th cbi-section-table-cell',
-                                       'data-widget': opt.__name__
+                                       'data-widget': opt.__name__,
+                                       'data-sortable-row': this.sortable ? '' : null
                                }));
 
                                if (opt.width != null)
@@ -2641,9 +2694,9 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
 
                if (this.sortable) {
                        dom.append(tdEl.lastElementChild, [
-                               E('div', {
+                               E('button', {
                                        'title': _('Drag to reorder'),
-                                       'class': 'btn cbi-button drag-handle center',
+                                       'class': 'cbi-button drag-handle center',
                                        'style': 'cursor:move',
                                        'disabled': this.map.readonly || null
                                }, 'โ˜ฐ')
@@ -2786,20 +2839,271 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
                return false;
        },
 
+       /** @private */
+       determineBackgroundColor: function(node) {
+               var r = 255, g = 255, b = 255;
+
+               while (node) {
+                       var s = window.getComputedStyle(node),
+                           c = (s.getPropertyValue('background-color') || '').replace(/ /g, '');
+
+                       if (c != '' && c != 'transparent' && c != 'rgba(0,0,0,0)') {
+                               if (/^#([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})$/i.test(c)) {
+                                       r = parseInt(RegExp.$1, 16);
+                                       g = parseInt(RegExp.$2, 16);
+                                       b = parseInt(RegExp.$3, 16);
+                               }
+                               else if (/^rgba?\(([0-9]+),([0-9]+),([0-9]+)[,)]$/.test(c)) {
+                                       r = +RegExp.$1;
+                                       g = +RegExp.$2;
+                                       b = +RegExp.$3;
+                               }
+
+                               break;
+                       }
+
+                       node = node.parentNode;
+               }
+
+               return [ r, g, b ];
+       },
+
+       /** @private */
+       handleTouchMove: function(ev) {
+               if (!ev.target.classList.contains('drag-handle'))
+                       return;
+
+               var touchLoc = ev.targetTouches[0],
+                   rowBtn = ev.target,
+                   rowElem = dom.parent(rowBtn, '.tr'),
+                   htmlElem = document.querySelector('html'),
+                   dragHandle = document.querySelector('.touchsort-element'),
+                   viewportHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
+
+               if (!dragHandle) {
+                       var rowRect = rowElem.getBoundingClientRect(),
+                           btnRect = rowBtn.getBoundingClientRect(),
+                           paddingLeft = btnRect.left - rowRect.left,
+                           paddingRight = rowRect.right - btnRect.right,
+                           colorBg = this.determineBackgroundColor(rowElem),
+                           colorFg = (colorBg[0] * 0.299 + colorBg[1] * 0.587 + colorBg[2] * 0.114) > 186 ? [ 0, 0, 0 ] : [ 255, 255, 255 ];
+
+                       dragHandle = E('div', { 'class': 'touchsort-element' }, [
+                               E('strong', [ rowElem.getAttribute('data-title') ]),
+                               rowBtn.cloneNode(true)
+                       ]);
+
+                       Object.assign(dragHandle.style, {
+                               position: 'absolute',
+                               boxShadow: '0 0 3px rgba(%d, %d, %d, 1)'.format(colorFg[0], colorFg[1], colorFg[2]),
+                               background: 'rgba(%d, %d, %d, 0.8)'.format(colorBg[0], colorBg[1], colorBg[2]),
+                               top: rowRect.top + 'px',
+                               left: rowRect.left + 'px',
+                               width: rowRect.width + 'px',
+                               height: (rowBtn.offsetHeight + 4) + 'px'
+                       });
+
+                       Object.assign(dragHandle.firstElementChild.style, {
+                               position: 'absolute',
+                               lineHeight: dragHandle.style.height,
+                               whiteSpace: 'nowrap',
+                               overflow: 'hidden',
+                               textOverflow: 'ellipsis',
+                               left: (paddingRight > paddingLeft) ? '' : '5px',
+                               right: (paddingRight > paddingLeft) ? '5px' : '',
+                               width: (Math.max(paddingLeft, paddingRight) - 10) + 'px'
+                       });
+
+                       Object.assign(dragHandle.lastElementChild.style, {
+                               position: 'absolute',
+                               top: '2px',
+                               left: paddingLeft + 'px',
+                               width: rowBtn.offsetWidth + 'px'
+                       });
+
+                       document.body.appendChild(dragHandle);
+
+                       rowElem.classList.remove('flash');
+                       rowBtn.blur();
+               }
+
+               dragHandle.style.top = (touchLoc.pageY - (parseInt(dragHandle.style.height) / 2)) + 'px';
+
+               rowElem.parentNode.querySelectorAll('[draggable]').forEach(function(tr, i, trs) {
+                       var trRect = tr.getBoundingClientRect(),
+                           yTop = trRect.top + window.scrollY,
+                           yBottom = trRect.bottom + window.scrollY,
+                           yMiddle = yTop + ((yBottom - yTop) / 2);
+
+                       tr.classList.remove('drag-over-above', 'drag-over-below');
+
+                       if ((i == 0 || touchLoc.pageY >= yTop) && touchLoc.pageY <= yMiddle)
+                               tr.classList.add('drag-over-above');
+                       else if ((i == (trs.length - 1) || touchLoc.pageY <= yBottom) && touchLoc.pageY > yMiddle)
+                               tr.classList.add('drag-over-below');
+               });
+
+               /* prevent standard scrolling and scroll page when drag handle is
+                * moved very close (~30px) to the viewport edge */
+
+               ev.preventDefault();
+
+               if (touchLoc.clientY < 30)
+                       window.requestAnimationFrame(function() { htmlElem.scrollTop -= 30 });
+               else if (touchLoc.clientY > viewportHeight - 30)
+                       window.requestAnimationFrame(function() { htmlElem.scrollTop += 30 });
+       },
+
+       /** @private */
+       handleTouchEnd: function(ev) {
+               var rowElem = dom.parent(ev.target, '.tr'),
+                   htmlElem = document.querySelector('html'),
+                   dragHandle = document.querySelector('.touchsort-element'),
+                   targetElem = rowElem.parentNode.querySelector('.drag-over-above, .drag-over-below'),
+                   viewportHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
+
+               if (!dragHandle)
+                       return;
+
+               if (targetElem) {
+                   var isBelow = targetElem.classList.contains('drag-over-below');
+
+                       rowElem.parentNode.insertBefore(rowElem, isBelow ? targetElem.nextElementSibling : targetElem);
+
+                       this.map.data.move(
+                               this.uciconfig || this.map.config,
+                               rowElem.getAttribute('data-sid'),
+                               targetElem.getAttribute('data-sid'),
+                               isBelow);
+
+                       window.requestAnimationFrame(function() {
+                               var rowRect = rowElem.getBoundingClientRect();
+
+                               if (rowRect.top < 50)
+                                       htmlElem.scrollTop = (htmlElem.scrollTop + rowRect.top - 50);
+                               else if (rowRect.bottom > viewportHeight - 50)
+                                       htmlElem.scrollTop = (htmlElem.scrollTop + viewportHeight - 50 - rowRect.height);
+
+                               rowElem.classList.add('flash');
+                       });
+
+                       targetElem.classList.remove('drag-over-above', 'drag-over-below');
+               }
+
+               document.body.removeChild(dragHandle);
+       },
+
        /** @private */
        handleModalCancel: function(modalMap, ev) {
-               return Promise.resolve(ui.hideModal());
+               var prevNode = this.getPreviousModalMap(),
+                   resetTasks = Promise.resolve();
+
+               if (prevNode) {
+                       var heading = prevNode.parentNode.querySelector('h4'),
+                           prevMap = dom.findClassInstance(prevNode);
+
+                       while (prevMap) {
+                               resetTasks = resetTasks
+                                       .then(L.bind(prevMap.load, prevMap))
+                                       .then(L.bind(prevMap.reset, prevMap));
+
+                               prevMap = prevMap.parent;
+                       }
+
+                       prevNode.classList.add('flash');
+                       prevNode.classList.remove('hidden');
+                       prevNode.parentNode.removeChild(prevNode.nextElementSibling);
+
+                       heading.removeChild(heading.lastElementChild);
+
+                       if (!this.getPreviousModalMap())
+                               prevNode.parentNode
+                                       .querySelector('div.right > button')
+                                       .firstChild.data = _('Dismiss');
+               }
+               else {
+                       ui.hideModal();
+               }
+
+               return resetTasks;
        },
 
        /** @private */
        handleModalSave: function(modalMap, ev) {
-               return modalMap.save(null, true)
-                       .then(L.bind(this.map.load, this.map))
-                       .then(L.bind(this.map.reset, this.map))
-                       .then(ui.hideModal)
+               var mapNode = this.getActiveModalMap(),
+                   activeMap = dom.findClassInstance(mapNode),
+                   saveTasks = activeMap.save(null, true);
+
+               while (activeMap.parent) {
+                       activeMap = activeMap.parent;
+                       saveTasks = saveTasks
+                               .then(L.bind(activeMap.load, activeMap))
+                               .then(L.bind(activeMap.reset, activeMap));
+               }
+
+               return saveTasks
+                       .then(L.bind(this.handleModalCancel, this, modalMap, ev, true))
                        .catch(function() {});
        },
 
+       /** @private */
+       handleSort: function(ev) {
+               if (!ev.target.matches('th[data-sortable-row]'))
+                       return;
+
+               var th = ev.target,
+                   descending = (th.getAttribute('data-sort-direction') == 'desc'),
+                   config_name = this.uciconfig || this.map.config,
+                   index = 0,
+                   list = [];
+
+               ev.currentTarget.querySelectorAll('th').forEach(function(other_th, i) {
+                       if (other_th !== th)
+                               other_th.removeAttribute('data-sort-direction');
+                       else
+                               index = i;
+               });
+
+               ev.currentTarget.parentNode.querySelectorAll('tr.cbi-section-table-row').forEach(L.bind(function(tr, i) {
+                       var sid = tr.getAttribute('data-sid'),
+                           opt = tr.childNodes[index].getAttribute('data-name'),
+                           val = this.cfgvalue(sid, opt);
+
+                       tr.querySelectorAll('.flash').forEach(function(n) {
+                               n.classList.remove('flash')
+                       });
+
+                       list.push([
+                               ui.Table.prototype.deriveSortKey((val != null) ? val.trim() : ''),
+                               tr
+                       ]);
+               }, this));
+
+               list.sort(function(a, b) {
+                       return descending
+                               ? -L.naturalCompare(a[0], b[0])
+                               : L.naturalCompare(a[0], b[0]);
+               });
+
+               window.requestAnimationFrame(L.bind(function() {
+                       var ref_sid, cur_sid;
+
+                       for (var i = 0; i < list.length; i++) {
+                               list[i][1].childNodes[index].classList.add('flash');
+                               th.parentNode.parentNode.appendChild(list[i][1]);
+
+                               cur_sid = list[i][1].getAttribute('data-sid');
+
+                               if (ref_sid)
+                                       this.map.data.move(config_name, cur_sid, ref_sid, true);
+
+                               ref_sid = cur_sid;
+                       }
+
+                       th.setAttribute('data-sort-direction', descending ? 'asc' : 'desc');
+               }, this));
+       },
+
        /**
         * Add further options to the per-section instanced modal popup.
         *
@@ -2822,7 +3126,7 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
         * @returns {*|Promise<*>}
         * Return values of this function are ignored but if a promise is returned,
         * it is run to completion before the rendering is continued, allowing
-        * custom logic to perform asynchroneous work before the modal dialog
+        * custom logic to perform asynchronous work before the modal dialog
         * is shown.
         */
        addModalOptions: function(modalSection, section_id, ev) {
@@ -2830,33 +3134,51 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
        },
 
        /** @private */
-       renderMoreOptionsModal: function(section_id, ev) {
-               var parent = this.map,
-                   title = parent.title,
-                   name = null,
-                   m = new CBIMap(this.map.config, null, null),
-                   s = m.section(CBINamedSection, section_id, this.sectiontype);
-
-               m.parent = parent;
-               m.readonly = parent.readonly;
+       getActiveModalMap: function() {
+               return document.querySelector('body.modal-overlay-active > #modal_overlay > .modal.cbi-modal > .cbi-map:not(.hidden)');
+       },
 
-               s.tabs = this.tabs;
-               s.tab_names = this.tab_names;
+       /** @private */
+       getPreviousModalMap: function() {
+               var mapNode = this.getActiveModalMap(),
+                   prevNode = mapNode ? mapNode.previousElementSibling : null;
 
-               if ((name = this.titleFn('modaltitle', section_id)) != null)
-                       title = name;
-               else if ((name = this.titleFn('sectiontitle', section_id)) != null)
-                       title = '%s - %s'.format(parent.title, name);
-               else if (!this.anonymous)
-                       title = '%s - %s'.format(parent.title, section_id);
+               return (prevNode && prevNode.matches('.cbi-map.hidden')) ? prevNode : null;
+       },
 
-               for (var i = 0; i < this.children.length; i++) {
-                       var o1 = this.children[i];
+       /** @private */
+       cloneOptions: function(src_section, dest_section) {
+               for (var i = 0; i < src_section.children.length; i++) {
+                       var o1 = src_section.children[i];
 
-                       if (o1.modalonly === false)
+                       if (o1.modalonly === false && src_section === this)
                                continue;
 
-                       var o2 = s.option(o1.constructor, o1.option, o1.title, o1.description);
+                       var o2;
+
+                       if (o1.subsection) {
+                               o2 = dest_section.option(o1.constructor, o1.option, o1.subsection.constructor, o1.subsection.sectiontype, o1.subsection.title, o1.subsection.description);
+
+                               for (var k in o1.subsection) {
+                                       if (!o1.subsection.hasOwnProperty(k))
+                                               continue;
+
+                                       switch (k) {
+                                       case 'map':
+                                       case 'children':
+                                       case 'parentoption':
+                                               continue;
+
+                                       default:
+                                               o2.subsection[k] = o1.subsection[k];
+                                       }
+                               }
+
+                               this.cloneOptions(o1.subsection, o2.subsection);
+                       }
+                       else {
+                               o2 = dest_section.option(o1.constructor, o1.option, o1.title, o1.description);
+                       }
 
                        for (var k in o1) {
                                if (!o1.hasOwnProperty(k))
@@ -2868,6 +3190,7 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
                                case 'option':
                                case 'title':
                                case 'description':
+                               case 'subsection':
                                        continue;
 
                                default:
@@ -2875,22 +3198,84 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
                                }
                        }
                }
+       },
+
+       /** @private */
+       renderMoreOptionsModal: function(section_id, ev) {
+               var parent = this.map,
+                   sref = parent.data.get(parent.config, section_id),
+                   mapNode = this.getActiveModalMap(),
+                   activeMap = mapNode ? dom.findClassInstance(mapNode) : null,
+                   stackedMap = activeMap && (activeMap.parent !== parent || activeMap.section !== section_id);
+
+               return (stackedMap ? activeMap.save(null, true) : Promise.resolve()).then(L.bind(function() {
+                       section_id = sref['.name'];
+
+                       var m;
+
+                       if (parent instanceof CBIJSONMap) {
+                               m = new CBIJSONMap(null, null, null);
+                               m.data = parent.data;
+                       }
+                       else {
+                               m = new CBIMap(parent.config, null, null);
+                       }
+
+                       var s = m.section(CBINamedSection, section_id, this.sectiontype);
+
+                       m.parent = parent;
+                       m.section = section_id;
+                       m.readonly = parent.readonly;
+
+                       s.tabs = this.tabs;
+                       s.tab_names = this.tab_names;
+
+                       this.cloneOptions(this, s);
+
+                       return Promise.resolve(this.addModalOptions(s, section_id, ev)).then(function() {
+                               return m.render();
+                       }).then(L.bind(function(nodes) {
+                               var title = parent.title,
+                                   name = null;
+
+                               if ((name = this.titleFn('modaltitle', section_id)) != null)
+                                       title = name;
+                               else if ((name = this.titleFn('sectiontitle', section_id)) != null)
+                                       title = '%s - %s'.format(parent.title, name);
+                               else if (!this.anonymous)
+                                       title = '%s - %s'.format(parent.title, section_id);
+
+                               if (stackedMap) {
+                                       mapNode.parentNode
+                                               .querySelector('h4')
+                                               .appendChild(E('span', title ? ' ยป ' + title : ''));
 
-               return Promise.resolve(this.addModalOptions(s, section_id, ev)).then(L.bind(m.render, m)).then(L.bind(function(nodes) {
-                       ui.showModal(title, [
-                               nodes,
-                               E('div', { 'class': 'right' }, [
-                                       E('button', {
-                                               'class': 'btn',
-                                               'click': ui.createHandlerFn(this, 'handleModalCancel', m)
-                                       }, [ _('Dismiss') ]), ' ',
-                                       E('button', {
-                                               'class': 'cbi-button cbi-button-positive important',
-                                               'click': ui.createHandlerFn(this, 'handleModalSave', m),
-                                               'disabled': m.readonly || null
-                                       }, [ _('Save') ])
-                               ])
-                       ], 'cbi-modal');
+                                       mapNode.parentNode
+                                               .querySelector('div.right > button')
+                                               .firstChild.data = _('Back');
+
+                                       mapNode.classList.add('hidden');
+                                       mapNode.parentNode.insertBefore(nodes, mapNode.nextElementSibling);
+
+                                       nodes.classList.add('flash');
+                               }
+                               else {
+                                       ui.showModal(title, [
+                                               nodes,
+                                               E('div', { 'class': 'right' }, [
+                                                       E('button', {
+                                                               'class': 'cbi-button',
+                                                               'click': ui.createHandlerFn(this, 'handleModalCancel', m)
+                                                       }, [ _('Dismiss') ]), ' ',
+                                                       E('button', {
+                                                               'class': 'cbi-button cbi-button-positive important',
+                                                               'click': ui.createHandlerFn(this, 'handleModalSave', m),
+                                                               'disabled': m.readonly || null
+                                                       }, [ _('Save') ])
+                                               ])
+                                       ], 'cbi-modal');
+                               }
+                       }, this));
                }, this)).catch(L.error);
        }
 });
@@ -2913,7 +3298,7 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
  *
  * Another important difference is that the table cells show a readonly text
  * preview of the corresponding option elements by default, unless the child
- * option element is explicitely made writable by setting the `editable`
+ * option element is explicitly made writable by setting the `editable`
  * property to `true`.
  *
  * Additionally, the grid section honours a `modalonly` property of child
@@ -2962,7 +3347,7 @@ var CBIGridSection = CBITableSection.extend(/** @lends LuCI.form.GridSection.pro
         * contents. If omitted, no description will be rendered.
         *
         * @throws {Error}
-        * Throws an exeption if a tab with the same `name` already exists.
+        * Throws an exception if a tab with the same `name` already exists.
         */
        tab: function(name, title, description) {
                CBIAbstractSection.prototype.tab.call(this, name, title, description);
@@ -2971,26 +3356,33 @@ var CBIGridSection = CBITableSection.extend(/** @lends LuCI.form.GridSection.pro
        /** @private */
        handleAdd: function(ev, name) {
                var config_name = this.uciconfig || this.map.config,
-                   section_id = this.map.data.add(config_name, this.sectiontype, name);
+                   section_id = this.map.data.add(config_name, this.sectiontype, name),
+                   mapNode = this.getPreviousModalMap(),
+                   prevMap = mapNode ? dom.findClassInstance(mapNode) : this.map;
+
+               prevMap.addedSection = section_id;
 
-               this.addedSection = section_id;
                return this.renderMoreOptionsModal(section_id);
        },
 
        /** @private */
        handleModalSave: function(/* ... */) {
-               return this.super('handleModalSave', arguments)
-                       .then(L.bind(function() { this.addedSection = null }, this));
+               var mapNode = this.getPreviousModalMap(),
+                   prevMap = mapNode ? dom.findClassInstance(mapNode) : this.map;
+
+               return this.super('handleModalSave', arguments);
        },
 
        /** @private */
-       handleModalCancel: function(/* ... */) {
-               var config_name = this.uciconfig || this.map.config;
+       handleModalCancel: function(modalMap, ev, isSaving) {
+               var config_name = this.uciconfig || this.map.config,
+                   mapNode = this.getPreviousModalMap(),
+                   prevMap = mapNode ? dom.findClassInstance(mapNode) : this.map;
 
-               if (this.addedSection != null) {
-                       this.map.data.remove(config_name, this.addedSection);
-                       this.addedSection = null;
-               }
+               if (prevMap.addedSection != null && !isSaving)
+                       this.map.data.remove(config_name, prevMap.addedSection);
+
+               delete prevMap.addedSection;
 
                return this.super('handleModalCancel', arguments);
        },
@@ -3028,7 +3420,7 @@ var CBIGridSection = CBITableSection.extend(/** @lends LuCI.form.GridSection.pro
                        'data-title': (title != '') ? title : null,
                        'data-description': (descr != '') ? descr : null,
                        'data-name': opt.option,
-                       'data-widget': opt.typename || opt.__name__
+                       'data-widget': 'CBI.DummyValue'
                }, (value != null) ? value : E('em', _('none')));
        },
 
@@ -3273,7 +3665,7 @@ var CBIValue = CBIAbstractValue.extend(/** @lends LuCI.form.Value.prototype */ {
         * @param {string} key
         * The choice value to add.
         *
-        * @param {Node|string} value
+        * @param {Node|string} val
         * The caption for the choice value. May be a DOM node, a document fragment
         * or a plain text string. If omitted, the `key` value is used as caption.
         */
@@ -3370,7 +3762,7 @@ var CBIValue = CBIAbstractValue.extend(/** @lends LuCI.form.Value.prototype */ {
 
                if (!in_table && typeof(this.description) === 'string' && this.description !== '')
                        dom.append(optionEl.lastChild || optionEl,
-                               E('div', { 'class': 'cbi-value-description' }, this.description));
+                               E('div', { 'class': 'cbi-value-description' }, this.description.trim()));
 
                if (depend_list && depend_list.length)
                        optionEl.classList.add('hidden');
@@ -3485,7 +3877,7 @@ var CBIDynamicList = CBIValue.extend(/** @lends LuCI.form.DynamicList.prototype
  * @classdesc
  *
  * The `ListValue` class implements a simple static HTML select element
- * allowing the user to chose a single value from a set of predefined choices.
+ * allowing the user chose a single value from a set of predefined choices.
  * It builds upon the {@link LuCI.ui.Select} widget.
  *
  * @param {LuCI.form.Map|LuCI.form.JSONMap} form
@@ -3615,7 +4007,7 @@ var CBIFlagValue = CBIValue.extend(/** @lends LuCI.form.FlagValue.prototype */ {
         * Sets the input value to use for the checkbox checked state.
         *
         * @name LuCI.form.FlagValue.prototype#enabled
-        * @type number
+        * @type string
         * @default 1
         */
 
@@ -3623,7 +4015,7 @@ var CBIFlagValue = CBIValue.extend(/** @lends LuCI.form.FlagValue.prototype */ {
         * Sets the input value to use for the checkbox unchecked state.
         *
         * @name LuCI.form.FlagValue.prototype#disabled
-        * @type number
+        * @type string
         * @default 0
         */
 
@@ -3709,7 +4101,9 @@ var CBIFlagValue = CBIValue.extend(/** @lends LuCI.form.FlagValue.prototype */ {
 
                        if (!this.isValid(section_id)) {
                                var title = this.stripTags(this.title).trim();
-                               return Promise.reject(new TypeError(_('Option "%s" contains an invalid input value.').format(title || this.option)));
+                               var error = this.getValidationError(section_id);
+                               return Promise.reject(new TypeError(
+                                       _('Option "%s" contains an invalid input value.').format(title || this.option) + ' ' + error));
                        }
 
                        if (fval == this.default && (this.optional || this.rmempty))
@@ -3717,7 +4111,7 @@ var CBIFlagValue = CBIValue.extend(/** @lends LuCI.form.FlagValue.prototype */ {
                        else
                                return Promise.resolve(this.write(section_id, fval));
                }
-               else {
+               else if (!this.retain) {
                        return Promise.resolve(this.remove(section_id));
                }
        },
@@ -3932,7 +4326,7 @@ var CBIDummyValue = CBIValue.extend(/** @lends LuCI.form.DummyValue.prototype */
        __name__: 'CBI.DummyValue',
 
        /**
-        * Set an URL which is opened when clicking on the dummy value text.
+        * Set a URL which is opened when clicking on the dummy value text.
         *
         * By setting this property, the dummy value text is wrapped in an `<a>`
         * element with the property value used as `href` attribute.
@@ -3954,11 +4348,21 @@ var CBIDummyValue = CBIValue.extend(/** @lends LuCI.form.DummyValue.prototype */
         * @default null
         */
 
+    /**
+        * Render the UCI option value as hidden using the HTML display: none style property.
+        *
+        * By default, the value is displayed
+        *
+        * @name LuCI.form.DummyValue.prototype#hidden
+        * @type boolean
+        * @default null
+        */
+
        /** @private */
        renderWidget: function(section_id, option_index, cfgvalue) {
                var value = (cfgvalue != null) ? cfgvalue : this.default,
                    hiddenEl = new ui.Hiddenfield(value, { id: this.cbid(section_id) }),
-                   outputEl = E('div');
+                   outputEl = E('div', { 'style': this.hidden ? 'display:none' : null });
 
                if (this.href && !((this.readonly != null) ? this.readonly : this.map.readonly))
                        outputEl.appendChild(E('a', { 'href': this.href }));
@@ -4395,7 +4799,7 @@ var CBISectionValue = CBIValue.extend(/** @lends LuCI.form.SectionValue.prototyp
  * @hideconstructor
  * @classdesc
  *
- * The LuCI form class provides high level abstractions for creating creating
+ * The LuCI form class provides high level abstractions for creating
  * UCI- or JSON backed configurations forms.
  *
  * To import the class in views, use `'require form'`, to import it in