luci-base: ui.js: support clearChoices()/addChoices() for DynLists
[project/luci.git] / modules / luci-base / htdocs / luci-static / resources / ui.js
index 9e43c2d1250149dceb73bea8c6767c3e3b0dbacc..08edaa1475a48915e29fa85f1d2842756323471b 100644 (file)
@@ -1,6 +1,8 @@
 'use strict';
+'require rpc';
 'require uci';
 'require validation';
+'require fs';
 
 var modalDiv = null,
     tooltipDiv = null,
@@ -69,6 +71,11 @@ var UIElement = L.Class.extend({
        },
 
        setChangeEvents: function(targetNode /*, ... */) {
+               var tag_changed = L.bind(function(ev) { this.setAttribute('data-changed', true) }, this.node);
+
+               for (var i = 1; i < arguments.length; i++)
+                       targetNode.addEventListener(arguments[i], tag_changed);
+
                this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
        }
 });
@@ -146,6 +153,61 @@ var UITextfield = UIElement.extend({
        }
 });
 
+var UITextarea = UIElement.extend({
+       __init__: function(value, options) {
+               this.value = value;
+               this.options = Object.assign({
+                       optional: true,
+                       wrap: false,
+                       cols: null,
+                       rows: null
+               }, options);
+       },
+
+       render: function() {
+               var frameEl = E('div', { 'id': this.options.id }),
+                   value = (this.value != null) ? String(this.value) : '';
+
+               frameEl.appendChild(E('textarea', {
+                       'id': this.options.id ? 'widget.' + this.options.id : null,
+                       'name': this.options.name,
+                       'class': 'cbi-input-textarea',
+                       'readonly': this.options.readonly ? '' : null,
+                       'placeholder': this.options.placeholder,
+                       'style': !this.options.cols ? 'width:100%' : null,
+                       'cols': this.options.cols,
+                       'rows': this.options.rows,
+                       'wrap': this.options.wrap ? '' : null
+               }, [ value ]));
+
+               if (this.options.monospace)
+                       frameEl.firstElementChild.style.fontFamily = 'monospace';
+
+               return this.bind(frameEl);
+       },
+
+       bind: function(frameEl) {
+               var inputEl = frameEl.firstElementChild;
+
+               this.node = frameEl;
+
+               this.setUpdateEvents(inputEl, 'keyup', 'blur');
+               this.setChangeEvents(inputEl, 'change');
+
+               L.dom.bindClassInstance(frameEl, this);
+
+               return frameEl;
+       },
+
+       getValue: function() {
+               return this.node.firstElementChild.value;
+       },
+
+       setValue: function(value) {
+               this.node.firstElementChild.value = value;
+       }
+});
+
 var UICheckbox = UIElement.extend({
        __init__: function(value, options) {
                this.value = value;
@@ -207,22 +269,25 @@ var UICheckbox = UIElement.extend({
 
 var UISelect = UIElement.extend({
        __init__: function(value, choices, options) {
-               if (typeof(choices) != 'object')
+               if (!L.isObject(choices))
                        choices = {};
 
                if (!Array.isArray(value))
                        value = (value != null && value != '') ? [ value ] : [];
 
-               if (!options.multi && value.length > 1)
+               if (!options.multiple && value.length > 1)
                        value.length = 1;
 
                this.values = value;
                this.choices = choices;
                this.options = Object.assign({
-                       multi: false,
+                       multiple: false,
                        widget: 'select',
                        orientation: 'horizontal'
                }, options);
+
+               if (this.choices.hasOwnProperty(''))
+                       this.options.optional = true;
        },
 
        render: function() {
@@ -240,14 +305,14 @@ var UISelect = UIElement.extend({
                                'name': this.options.name,
                                'size': this.options.size,
                                'class': 'cbi-input-select',
-                               'multiple': this.options.multi ? '' : null
+                               'multiple': this.options.multiple ? '' : null
                        }));
 
                        if (this.options.optional)
                                frameEl.lastChild.appendChild(E('option', {
                                        'value': '',
                                        'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null
-                               }, this.choices[''] || this.options.placeholder || _('-- Please choose --')));
+                               }, [ this.choices[''] || this.options.placeholder || _('-- Please choose --') ]));
 
                        for (var i = 0; i < keys.length; i++) {
                                if (keys[i] == null || keys[i] == '')
@@ -256,7 +321,7 @@ var UISelect = UIElement.extend({
                                frameEl.lastChild.appendChild(E('option', {
                                        'value': keys[i],
                                        'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
-                               }, this.choices[keys[i]] || keys[i]));
+                               }, [ this.choices[keys[i]] || keys[i] ]));
                        }
                }
                else {
@@ -267,8 +332,8 @@ var UISelect = UIElement.extend({
                                        E('input', {
                                                'id': this.options.id ? 'widget.' + this.options.id : null,
                                                'name': this.options.id || this.options.name,
-                                               'type': this.options.multi ? 'checkbox' : 'radio',
-                                               'class': this.options.multi ? 'cbi-input-checkbox' : 'cbi-input-radio',
+                                               'type': this.options.multiple ? 'checkbox' : 'radio',
+                                               'class': this.options.multiple ? 'cbi-input-checkbox' : 'cbi-input-radio',
                                                'value': keys[i],
                                                'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null
                                        }),
@@ -287,8 +352,8 @@ var UISelect = UIElement.extend({
                this.node = frameEl;
 
                if (this.options.widget == 'select') {
-                       this.setUpdateEvents(frameEl, 'change', 'click', 'blur');
-                       this.setChangeEvents(frameEl, 'change');
+                       this.setUpdateEvents(frameEl.firstChild, 'change', 'click', 'blur');
+                       this.setChangeEvents(frameEl.firstChild, 'change');
                }
                else {
                        var radioEls = frameEl.querySelectorAll('input[type="radio"]');
@@ -345,7 +410,7 @@ var UIDropdown = UIElement.extend({
                this.choices = choices;
                this.options = Object.assign({
                        sort:               true,
-                       multi:              Array.isArray(value),
+                       multiple:           Array.isArray(value),
                        optional:           true,
                        select_placeholder: _('-- Please choose --'),
                        custom_placeholder: _('-- custom --'),
@@ -361,7 +426,7 @@ var UIDropdown = UIElement.extend({
                var sb = E('div', {
                        'id': this.options.id,
                        'class': 'cbi-dropdown',
-                       'multiple': this.options.multi ? '' : null,
+                       'multiple': this.options.multiple ? '' : null,
                        'optional': this.options.optional ? '' : null,
                }, E('ul'));
 
@@ -377,11 +442,17 @@ var UIDropdown = UIElement.extend({
                                if (!this.choices.hasOwnProperty(this.values[i]))
                                        keys.push(this.values[i]);
 
-               for (var i = 0; i < keys.length; i++)
+               for (var i = 0; i < keys.length; i++) {
+                       var label = this.choices[keys[i]];
+
+                       if (L.dom.elem(label))
+                               label = label.cloneNode(true);
+
                        sb.lastElementChild.appendChild(E('li', {
                                'data-value': keys[i],
                                'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
-                       }, this.choices[keys[i]] || keys[i]));
+                       }, [ label || keys[i] ]));
+               }
 
                if (this.options.create) {
                        var createEl = E('input', {
@@ -392,9 +463,9 @@ var UIDropdown = UIElement.extend({
                                'placeholder': this.options.custom_placeholder || this.options.placeholder
                        });
 
-                       if (this.options.datatype)
-                               L.ui.addValidator(createEl, this.options.datatype,
-                                                 true, null, 'blur', 'keyup');
+                       if (this.options.datatype || this.options.validate)
+                               L.ui.addValidator(createEl, this.options.datatype || 'string',
+                                                 true, this.options.validate, 'blur', 'keyup');
 
                        sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
                }
@@ -409,7 +480,7 @@ var UIDropdown = UIElement.extend({
        bind: function(sb) {
                var o = this.options;
 
-               o.multi = sb.hasAttribute('multiple');
+               o.multiple = sb.hasAttribute('multiple');
                o.optional = sb.hasAttribute('optional');
                o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
                o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
@@ -425,7 +496,7 @@ var UIDropdown = UIElement.extend({
                    ndisplay = this.options.display_items,
                    n = 0;
 
-               if (this.options.multi) {
+               if (this.options.multiple) {
                        var items = ul.querySelectorAll('li');
 
                        for (var i = 0; i < items.length; i++) {
@@ -657,7 +728,7 @@ var UIDropdown = UIElement.extend({
                if (li.hasAttribute('unselectable'))
                        return;
 
-               if (this.options.multi) {
+               if (this.options.multiple) {
                        var cbox = li.querySelector('input[type="checkbox"]'),
                            items = li.parentNode.querySelectorAll('li'),
                            label = sb.querySelector('ul.preview'),
@@ -780,7 +851,7 @@ var UIDropdown = UIElement.extend({
                        element: sb
                };
 
-               if (this.options.multi)
+               if (this.options.multiple)
                        detail.values = values;
                else
                        detail.value = values.length ? values[0] : null;
@@ -800,12 +871,12 @@ var UIDropdown = UIElement.extend({
                        for (var value in values) {
                                this.createItems(sb, value);
 
-                               if (!this.options.multi)
+                               if (!this.options.multiple)
                                        break;
                        }
                }
 
-               if (this.options.multi) {
+               if (this.options.multiple) {
                        var lis = ul.querySelectorAll('li[data-value]');
                        for (var i = 0; i < lis.length; i++) {
                                var value = lis[i].getAttribute('data-value');
@@ -852,12 +923,39 @@ var UIDropdown = UIElement.extend({
                }
        },
 
+       createChoiceElement: function(sb, value, label) {
+               var tpl = sb.querySelector(this.options.create_template),
+                   markup = null;
+
+               if (tpl)
+                       markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
+               else
+                       markup = '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
+
+               var new_item = E(markup.replace(/{{value}}/g, '%h'.format(value))),
+                   placeholder = new_item.querySelector('[data-label-placeholder]');
+
+               if (placeholder) {
+                       var content = E('span', {}, label || this.choices[value] || [ value ]);
+
+                       while (content.firstChild)
+                               placeholder.parentNode.insertBefore(content.firstChild, placeholder);
+
+                       placeholder.parentNode.removeChild(placeholder);
+               }
+
+               if (this.options.multiple)
+                       this.transformItem(sb, new_item);
+
+               return new_item;
+       },
+
        createItems: function(sb, value) {
                var sbox = this,
                    val = (value || '').trim(),
                    ul = sb.querySelector('ul');
 
-               if (!sbox.options.multi)
+               if (!sbox.options.multiple)
                        val = val.length ? [ val ] : [];
                else
                        val = val.length ? val.split(/\s+/) : [];
@@ -871,20 +969,9 @@ var UIDropdown = UIElement.extend({
                        });
 
                        if (!new_item) {
-                               var markup,
-                                   tpl = sb.querySelector(sbox.options.create_template);
-
-                               if (tpl)
-                                       markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
-                               else
-                                       markup = '<li data-value="{{value}}">{{value}}</li>';
+                               new_item = sbox.createChoiceElement(sb, item);
 
-                               new_item = E(markup.replace(/{{value}}/g, item));
-
-                               if (sbox.options.multi) {
-                                       sbox.transformItem(sb, new_item);
-                               }
-                               else {
+                               if (!sbox.options.multiple) {
                                        var old = ul.querySelector('li[created]');
                                        if (old)
                                                ul.removeChild(old);
@@ -900,6 +987,54 @@ var UIDropdown = UIElement.extend({
                });
        },
 
+       clearChoices: function(reset_value) {
+               var ul = this.node.querySelector('ul'),
+                   lis = ul ? ul.querySelectorAll('li[data-value]') : [],
+                   len = lis.length - (this.options.create ? 1 : 0),
+                   val = reset_value ? null : this.getValue();
+
+               for (var i = 0; i < len; i++) {
+                       var lival = lis[i].getAttribute('data-value');
+                       if (val == null ||
+                               (!this.options.multiple && val != lival) ||
+                               (this.options.multiple && val.indexOf(lival) == -1))
+                               ul.removeChild(lis[i]);
+               }
+
+               if (reset_value)
+                       this.setValues(this.node, {});
+       },
+
+       addChoices: function(values, labels) {
+               var sb = this.node,
+                   ul = sb.querySelector('ul'),
+                   lis = ul ? ul.querySelectorAll('li[data-value]') : [];
+
+               if (!Array.isArray(values))
+                       values = L.toArray(values);
+
+               if (!L.isObject(labels))
+                       labels = {};
+
+               for (var i = 0; i < values.length; i++) {
+                       var found = false;
+
+                       for (var j = 0; j < lis.length; j++) {
+                               if (lis[j].getAttribute('data-value') === values[i]) {
+                                       found = true;
+                                       break;
+                               }
+                       }
+
+                       if (found)
+                               continue;
+
+                       ul.insertBefore(
+                               this.createChoiceElement(sb, values[i], labels[values[i]]),
+                               ul.lastElementChild);
+               }
+       },
+
        closeAllDropdowns: function() {
                document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
                        s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
@@ -1071,7 +1206,7 @@ var UIDropdown = UIElement.extend({
        },
 
        setValue: function(values) {
-               if (this.options.multi) {
+               if (this.options.multiple) {
                        if (!Array.isArray(values))
                                values = (values != null && values != '') ? [ values ] : [];
 
@@ -1104,7 +1239,7 @@ var UIDropdown = UIElement.extend({
                for (var i = 0; i < h.length; i++)
                        v.push(h[i].value);
 
-               return this.options.multi ? v : v[0];
+               return this.options.multiple ? v : v[0];
        }
 });
 
@@ -1116,13 +1251,58 @@ var UICombobox = UIDropdown.extend({
                        dropdown_items: -1,
                        sort: true
                }, options, {
-                       multi: false,
+                       multiple: false,
                        create: true,
                        optional: true
                }) ]);
        }
 });
 
+var UIComboButton = UIDropdown.extend({
+       __init__: function(value, choices, options) {
+               this.super('__init__', [ value, choices, Object.assign({
+                       sort: true
+               }, options, {
+                       multiple: false,
+                       create: false,
+                       optional: false
+               }) ]);
+       },
+
+       render: function(/* ... */) {
+               var node = UIDropdown.prototype.render.apply(this, arguments),
+                   val = this.getValue();
+
+               if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
+                       node.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
+
+               return node;
+       },
+
+       handleClick: function(ev) {
+               var sb = ev.currentTarget,
+                   t = ev.target;
+
+               if (sb.hasAttribute('open') || L.dom.matches(t, '.cbi-dropdown > span.open'))
+                       return UIDropdown.prototype.handleClick.apply(this, arguments);
+
+               if (this.options.click)
+                       return this.options.click.call(sb, ev, this.getValue());
+       },
+
+       toggleItem: function(sb /*, ... */) {
+               var rv = UIDropdown.prototype.toggleItem.apply(this, arguments),
+                   val = this.getValue();
+
+               if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
+                       sb.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
+               else
+                       sb.setAttribute('class', 'cbi-dropdown');
+
+               return rv;
+       }
+});
+
 var UIDynamicList = UIElement.extend({
        __init__: function(values, choices, options) {
                if (!Array.isArray(values))
@@ -1134,7 +1314,7 @@ var UIDynamicList = UIElement.extend({
                this.values = values;
                this.choices = choices;
                this.options = Object.assign({}, options, {
-                       multi: false,
+                       multiple: false,
                        optional: true
                });
        },
@@ -1146,7 +1326,11 @@ var UIDynamicList = UIElement.extend({
                }, E('div', { 'class': 'add-item' }));
 
                if (this.choices) {
+                       if (this.options.placeholder != null)
+                               this.options.select_placeholder = this.options.placeholder;
+
                        var cbox = new UICombobox(null, this.choices, this.options);
+
                        dl.lastElementChild.appendChild(cbox.render());
                }
                else {
@@ -1160,14 +1344,19 @@ var UIDynamicList = UIElement.extend({
                        dl.lastElementChild.appendChild(inputEl);
                        dl.lastElementChild.appendChild(E('div', { 'class': 'cbi-button cbi-button-add' }, '+'));
 
-                       if (this.options.datatype)
-                               L.ui.addValidator(inputEl, this.options.datatype,
-                                                 true, null, 'blur', 'keyup');
+                       if (this.options.datatype || this.options.validate)
+                               L.ui.addValidator(inputEl, this.options.datatype || 'string',
+                                                 true, this.options.validate, 'blur', 'keyup');
                }
 
-               for (var i = 0; i < this.values.length; i++)
-                       this.addItem(dl, this.values[i],
-                               this.choices ? this.choices[this.values[i]] : null);
+               for (var i = 0; i < this.values.length; i++) {
+                       var label = this.choices ? this.choices[this.values[i]] : null;
+
+                       if (L.dom.elem(label))
+                               label = label.cloneNode(true);
+
+                       this.addItem(dl, this.values[i], label);
+               }
 
                return this.bind(dl);
        },
@@ -1190,13 +1379,13 @@ var UIDynamicList = UIElement.extend({
        addItem: function(dl, value, text, flash) {
                var exists = false,
                    new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
-                               E('span', {}, text || value),
+                               E('span', {}, [ text || value ]),
                                E('input', {
                                        'type': 'hidden',
                                        'name': this.options.name,
                                        'value': value })]);
 
-               dl.querySelectorAll('.item, .add-item').forEach(function(item) {
+               dl.querySelectorAll('.item').forEach(function(item) {
                        if (exists)
                                return;
 
@@ -1207,10 +1396,13 @@ var UIDynamicList = UIElement.extend({
 
                        if (hidden && hidden.value === value)
                                exists = true;
-                       else if (!hidden || hidden.value >= value)
-                               exists = !!item.parentNode.insertBefore(new_item, item);
                });
 
+               if (!exists) {
+                       var ai = dl.querySelector('.add-item');
+                       ai.parentNode.insertBefore(new_item, ai);
+               }
+
                dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
                        bubbles: true,
                        detail: {
@@ -1281,7 +1473,16 @@ var UIDynamicList = UIElement.extend({
                        sbVal.element.setAttribute('dynlistcustom', '');
                }
 
-               this.addItem(dl, sbVal.value, sbVal.text, true);
+               var label = sbVal.text;
+
+               if (sbVal.element) {
+                       label = E([]);
+
+                       for (var i = 0; i < sbVal.element.childNodes.length; i++)
+                               label.appendChild(sbVal.element.childNodes[i].cloneNode(true));
+               }
+
+               this.addItem(dl, sbVal.value, label, true);
        },
 
        handleKeydown: function(ev) {
@@ -1327,11 +1528,17 @@ var UIDynamicList = UIElement.extend({
 
        getValue: function() {
                var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
+                   input = this.node.querySelector('.add-item > input[type="text"]'),
                    v = [];
 
                for (var i = 0; i < items.length; i++)
                        v.push(items[i].value);
 
+               if (input && input.value != null && input.value.match(/\S/) &&
+                   input.classList.contains('cbi-input-invalid') == false &&
+                   v.filter(function(s) { return s == input.value }).length == 0)
+                       v.push(input.value);
+
                return v;
        },
 
@@ -1348,6 +1555,16 @@ var UIDynamicList = UIElement.extend({
                for (var i = 0; i < values.length; i++)
                        this.addItem(this.node, values[i],
                                this.choices ? this.choices[values[i]] : null);
+       },
+
+       addChoices: function(values, labels) {
+               var dl = this.node.lastElementChild.firstElementChild;
+               L.dom.callClassMethod(dl, 'addChoices', values, labels);
+       },
+
+       clearChoices: function() {
+               var dl = this.node.lastElementChild.firstElementChild;
+               L.dom.callClassMethod(dl, 'clearChoices');
        }
 });
 
@@ -1386,6 +1603,393 @@ var UIHiddenfield = UIElement.extend({
        }
 });
 
+var UIFileUpload = UIElement.extend({
+       __init__: function(value, options) {
+               this.value = value;
+               this.options = Object.assign({
+                       show_hidden: false,
+                       enable_upload: true,
+                       enable_remove: true,
+                       root_directory: '/etc/luci-uploads'
+               }, options);
+       },
+
+       bind: function(browserEl) {
+               this.node = browserEl;
+
+               this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
+               this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
+
+               L.dom.bindClassInstance(browserEl, this);
+
+               return browserEl;
+       },
+
+       render: function() {
+               return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) {
+                       var label;
+
+                       if (L.isObject(stat) && stat.type != 'directory')
+                               this.stat = stat;
+
+                       if (this.stat != null)
+                               label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
+                       else if (this.value != null)
+                               label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
+                       else
+                               label = [ _('Select file…') ];
+
+                       return this.bind(E('div', { 'id': this.options.id }, [
+                               E('button', {
+                                       'class': 'btn',
+                                       'click': L.ui.createHandlerFn(this, 'handleFileBrowser')
+                               }, label),
+                               E('div', {
+                                       'class': 'cbi-filebrowser'
+                               }),
+                               E('input', {
+                                       'type': 'hidden',
+                                       'name': this.options.name,
+                                       'value': this.value
+                               })
+                       ]));
+               }, this));
+       },
+
+       truncatePath: function(path) {
+               if (path.length > 50)
+                       path = path.substring(0, 25) + '…' + path.substring(path.length - 25);
+
+               return path;
+       },
+
+       iconForType: function(type) {
+               switch (type) {
+               case 'symlink':
+                       return E('img', {
+                               'src': L.resource('cbi/link.gif'),
+                               'title': _('Symbolic link'),
+                               'class': 'middle'
+                       });
+
+               case 'directory':
+                       return E('img', {
+                               'src': L.resource('cbi/folder.gif'),
+                               'title': _('Directory'),
+                               'class': 'middle'
+                       });
+
+               default:
+                       return E('img', {
+                               'src': L.resource('cbi/file.gif'),
+                               'title': _('File'),
+                               'class': 'middle'
+                       });
+               }
+       },
+
+       canonicalizePath: function(path) {
+               return path.replace(/\/{2,}/, '/')
+                       .replace(/\/\.(\/|$)/g, '/')
+                       .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
+                       .replace(/\/$/, '');
+       },
+
+       splitPath: function(path) {
+               var croot = this.canonicalizePath(this.options.root_directory || '/'),
+                   cpath = this.canonicalizePath(path || '/');
+
+               if (cpath.length <= croot.length)
+                       return [ croot ];
+
+               if (cpath.charAt(croot.length) != '/')
+                       return [ croot ];
+
+               var parts = cpath.substring(croot.length + 1).split(/\//);
+
+               parts.unshift(croot);
+
+               return parts;
+       },
+
+       handleUpload: function(path, list, ev) {
+               var form = ev.target.parentNode,
+                   fileinput = form.querySelector('input[type="file"]'),
+                   nameinput = form.querySelector('input[type="text"]'),
+                   filename = (nameinput.value != null ? nameinput.value : '').trim();
+
+               ev.preventDefault();
+
+               if (filename == '' || filename.match(/\//) || fileinput.files[0] == null)
+                       return;
+
+               var existing = list.filter(function(e) { return e.name == filename })[0];
+
+               if (existing != null && existing.type == 'directory')
+                       return alert(_('A directory with the same name already exists.'));
+               else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename)))
+                       return;
+
+               var data = new FormData();
+
+               data.append('sessionid', L.env.sessionid);
+               data.append('filename', path + '/' + filename);
+               data.append('filedata', fileinput.files[0]);
+
+               return L.Request.post(L.env.cgi_base + '/cgi-upload', data, {
+                       progress: L.bind(function(btn, ev) {
+                               btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100);
+                       }, this, ev.target)
+               }).then(L.bind(function(path, ev, res) {
+                       var reply = res.json();
+
+                       if (L.isObject(reply) && reply.failure)
+                               alert(_('Upload request failed: %s').format(reply.message));
+
+                       return this.handleSelect(path, null, ev);
+               }, this, path, ev));
+       },
+
+       handleDelete: function(path, fileStat, ev) {
+               var parent = path.replace(/\/[^\/]+$/, '') || '/',
+                   name = path.replace(/^.+\//, ''),
+                   msg;
+
+               ev.preventDefault();
+
+               if (fileStat.type == 'directory')
+                       msg = _('Do you really want to recursively delete the directory "%s" ?').format(name);
+               else
+                       msg = _('Do you really want to delete "%s" ?').format(name);
+
+               if (confirm(msg)) {
+                       var button = this.node.firstElementChild,
+                           hidden = this.node.lastElementChild;
+
+                       if (path == hidden.value) {
+                               L.dom.content(button, _('Select file…'));
+                               hidden.value = '';
+                       }
+
+                       return fs.remove(path).then(L.bind(function(parent, ev) {
+                               return this.handleSelect(parent, null, ev);
+                       }, this, parent, ev)).catch(function(err) {
+                               alert(_('Delete request failed: %s').format(err.message));
+                       });
+               }
+       },
+
+       renderUpload: function(path, list) {
+               if (!this.options.enable_upload)
+                       return E([]);
+
+               return E([
+                       E('a', {
+                               'href': '#',
+                               'class': 'btn cbi-button-positive',
+                               'click': function(ev) {
+                                       var uploadForm = ev.target.nextElementSibling,
+                                           fileInput = uploadForm.querySelector('input[type="file"]');
+
+                                       ev.target.style.display = 'none';
+                                       uploadForm.style.display = '';
+                                       fileInput.click();
+                               }
+                       }, _('Upload file…')),
+                       E('div', { 'class': 'upload', 'style': 'display:none' }, [
+                               E('input', {
+                                       'type': 'file',
+                                       'style': 'display:none',
+                                       'change': function(ev) {
+                                               var nameinput = ev.target.parentNode.querySelector('input[type="text"]'),
+                                                   uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save');
+
+                                               nameinput.value = ev.target.value.replace(/^.+[\/\\]/, '');
+                                               uploadbtn.disabled = false;
+                                       }
+                               }),
+                               E('button', {
+                                       'class': 'btn',
+                                       'click': function(ev) {
+                                               ev.preventDefault();
+                                               ev.target.previousElementSibling.click();
+                                       }
+                               }, [ _('Browse…') ]),
+                               E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
+                               E('button', {
+                                       'class': 'btn cbi-button-save',
+                                       'click': L.ui.createHandlerFn(this, 'handleUpload', path, list),
+                                       'disabled': true
+                               }, [ _('Upload file') ])
+                       ])
+               ]);
+       },
+
+       renderListing: function(container, path, list) {
+               var breadcrumb = E('p'),
+                   rows = E('ul');
+
+               list.sort(function(a, b) {
+                       var isDirA = (a.type == 'directory'),
+                           isDirB = (b.type == 'directory');
+
+                       if (isDirA != isDirB)
+                               return isDirA < isDirB;
+
+                       return a.name > b.name;
+               });
+
+               for (var i = 0; i < list.length; i++) {
+                       if (!this.options.show_hidden && list[i].name.charAt(0) == '.')
+                               continue;
+
+                       var entrypath = this.canonicalizePath(path + '/' + list[i].name),
+                           selected = (entrypath == this.node.lastElementChild.value),
+                           mtime = new Date(list[i].mtime * 1000);
+
+                       rows.appendChild(E('li', [
+                               E('div', { 'class': 'name' }, [
+                                       this.iconForType(list[i].type),
+                                       ' ',
+                                       E('a', {
+                                               'href': '#',
+                                               'style': selected ? 'font-weight:bold' : null,
+                                               'click': L.ui.createHandlerFn(this, 'handleSelect',
+                                                       entrypath, list[i].type != 'directory' ? list[i] : null)
+                                       }, '%h'.format(list[i].name))
+                               ]),
+                               E('div', { 'class': 'mtime hide-xs' }, [
+                                       ' %04d-%02d-%02d %02d:%02d:%02d '.format(
+                                               mtime.getFullYear(),
+                                               mtime.getMonth() + 1,
+                                               mtime.getDate(),
+                                               mtime.getHours(),
+                                               mtime.getMinutes(),
+                                               mtime.getSeconds())
+                               ]),
+                               E('div', [
+                                       selected ? E('button', {
+                                               'class': 'btn',
+                                               'click': L.ui.createHandlerFn(this, 'handleReset')
+                                       }, [ _('Deselect') ]) : '',
+                                       this.options.enable_remove ? E('button', {
+                                               'class': 'btn cbi-button-negative',
+                                               'click': L.ui.createHandlerFn(this, 'handleDelete', entrypath, list[i])
+                                       }, [ _('Delete') ]) : ''
+                               ])
+                       ]));
+               }
+
+               if (!rows.firstElementChild)
+                       rows.appendChild(E('em', _('No entries in this directory')));
+
+               var dirs = this.splitPath(path),
+                   cur = '';
+
+               for (var i = 0; i < dirs.length; i++) {
+                       cur = cur ? cur + '/' + dirs[i] : dirs[i];
+                       L.dom.append(breadcrumb, [
+                               i ? ' » ' : '',
+                               E('a', {
+                                       'href': '#',
+                                       'click': L.ui.createHandlerFn(this, 'handleSelect', cur || '/', null)
+                               }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')),
+                       ]);
+               }
+
+               L.dom.content(container, [
+                       breadcrumb,
+                       rows,
+                       E('div', { 'class': 'right' }, [
+                               this.renderUpload(path, list),
+                               E('a', {
+                                       'href': '#',
+                                       'class': 'btn',
+                                       'click': L.ui.createHandlerFn(this, 'handleCancel')
+                               }, _('Cancel'))
+                       ]),
+               ]);
+       },
+
+       handleCancel: function(ev) {
+               var button = this.node.firstElementChild,
+                   browser = button.nextElementSibling;
+
+               browser.classList.remove('open');
+               button.style.display = '';
+
+               this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
+       },
+
+       handleReset: function(ev) {
+               var button = this.node.firstElementChild,
+                   hidden = this.node.lastElementChild;
+
+               hidden.value = '';
+               L.dom.content(button, _('Select file…'));
+
+               this.handleCancel(ev);
+       },
+
+       handleSelect: function(path, fileStat, ev) {
+               var browser = L.dom.parent(ev.target, '.cbi-filebrowser'),
+                   ul = browser.querySelector('ul');
+
+               if (fileStat == null) {
+                       L.dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
+                       L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
+               }
+               else {
+                       var button = this.node.firstElementChild,
+                           hidden = this.node.lastElementChild;
+
+                       path = this.canonicalizePath(path);
+
+                       L.dom.content(button, [
+                               this.iconForType(fileStat.type),
+                               ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
+                       ]);
+
+                       browser.classList.remove('open');
+                       button.style.display = '';
+                       hidden.value = path;
+
+                       this.stat = Object.assign({ path: path }, fileStat);
+                       this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
+               }
+       },
+
+       handleFileBrowser: function(ev) {
+               var button = ev.target,
+                   browser = button.nextElementSibling,
+                   path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : this.options.root_directory;
+
+               if (this.options.root_directory.indexOf(path) != 0)
+                       path = this.options.root_directory;
+
+               ev.preventDefault();
+
+               return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) {
+                       document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
+                               L.dom.findClassInstance(browserEl).handleCancel(ev);
+                       });
+
+                       button.style.display = 'none';
+                       browser.classList.add('open');
+
+                       return this.renderListing(browser, path, list);
+               }, this, button, browser, path));
+       },
+
+       getValue: function() {
+               return this.node.lastElementChild.value;
+       },
+
+       setValue: function(value) {
+               this.node.lastElementChild.value = value;
+       }
+});
+
 
 return L.Class.extend({
        __init__: function() {
@@ -1488,6 +2092,43 @@ return L.Class.extend({
                tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
        },
 
+       addNotification: function(title, children /*, ... */) {
+               var mc = document.querySelector('#maincontent') || document.body;
+               var msg = E('div', {
+                       'class': 'alert-message fade-in',
+                       'style': 'display:flex',
+                       'transitionend': function(ev) {
+                               var node = ev.currentTarget;
+                               if (node.parentNode && node.classList.contains('fade-out'))
+                                       node.parentNode.removeChild(node);
+                       }
+               }, [
+                       E('div', { 'style': 'flex:10' }),
+                       E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
+                               E('button', {
+                                       'class': 'btn',
+                                       'style': 'margin-left:auto; margin-top:auto',
+                                       'click': function(ev) {
+                                               L.dom.parent(ev.target, '.alert-message').classList.add('fade-out');
+                                       },
+
+                               }, [ _('Dismiss') ])
+                       ])
+               ]);
+
+               if (title != null)
+                       L.dom.append(msg.firstElementChild, E('h4', {}, title));
+
+               L.dom.append(msg.firstElementChild, children);
+
+               for (var i = 2; i < arguments.length; i++)
+                       msg.classList.add(arguments[i]);
+
+               mc.insertBefore(msg, mc.firstElementChild);
+
+               return msg;
+       },
+
        /* Widget helper */
        itemlist: function(node, items, separators) {
                var children = [];
@@ -1523,6 +2164,9 @@ return L.Class.extend({
                        document.querySelectorAll('[data-tab]').forEach(function(tab) {
                                var parent = tab.parentNode;
 
+                               if (L.dom.matches(tab, 'li') && L.dom.matches(parent, 'ul.cbi-tabmenu'))
+                                       return;
+
                                if (!parent.hasAttribute('data-tab-group'))
                                        parent.setAttribute('data-tab-group', groups.length);
 
@@ -1544,9 +2188,6 @@ return L.Class.extend({
                        document.addEventListener('dependency-update', this.updateTabs.bind(this));
 
                        this.updateTabs();
-
-                       if (!groups.length)
-                               this.setActiveTabId(-1, -1);
                },
 
                initTabGroup: function(panes) {
@@ -1558,12 +2199,16 @@ return L.Class.extend({
                            groupId = +group.getAttribute('data-tab-group'),
                            selected = null;
 
+                       if (group.getAttribute('data-initialized') === 'true')
+                               return;
+
                        for (var i = 0, pane; pane = panes[i]; i++) {
                                var name = pane.getAttribute('data-tab'),
                                    title = pane.getAttribute('data-tab-title'),
                                    active = pane.getAttribute('data-tab-active') === 'true';
 
                                menu.appendChild(E('li', {
+                                       'style': this.isEmptyPane(pane) ? 'display:none' : null,
                                        'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
                                        'data-tab': name
                                }, E('a', {
@@ -1576,19 +2221,48 @@ return L.Class.extend({
                        }
 
                        group.parentNode.insertBefore(menu, group);
+                       group.setAttribute('data-initialized', true);
 
                        if (selected === null) {
-                               selected = this.getActiveTabId(groupId);
+                               selected = this.getActiveTabId(panes[0]);
 
-                               if (selected < 0 || selected >= panes.length)
-                                       selected = 0;
+                               if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) {
+                                       for (var i = 0; i < panes.length; i++) {
+                                               if (!this.isEmptyPane(panes[i])) {
+                                                       selected = i;
+                                                       break;
+                                               }
+                                       }
+                               }
 
                                menu.childNodes[selected].classList.add('cbi-tab');
                                menu.childNodes[selected].classList.remove('cbi-tab-disabled');
                                panes[selected].setAttribute('data-tab-active', 'true');
 
-                               this.setActiveTabId(groupId, selected);
+                               this.setActiveTabId(panes[selected], selected);
                        }
+
+                       this.updateTabs(group);
+               },
+
+               isEmptyPane: function(pane) {
+                       return L.dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
+               },
+
+               getPathForPane: function(pane) {
+                       var path = [], node = null;
+
+                       for (node = pane ? pane.parentNode : null;
+                            node != null && node.hasAttribute != null;
+                            node = node.parentNode)
+                       {
+                               if (node.hasAttribute('data-tab'))
+                                       path.unshift(node.getAttribute('data-tab'));
+                               else if (node.hasAttribute('data-section-id'))
+                                       path.unshift(node.getAttribute('data-section-id'));
+                       }
+
+                       return path.join('/');
                },
 
                getActiveTabState: function() {
@@ -1596,23 +2270,26 @@ return L.Class.extend({
 
                        try {
                                var val = JSON.parse(window.sessionStorage.getItem('tab'));
-                               if (val.page === page && Array.isArray(val.groups))
+                               if (val.page === page && L.isObject(val.paths))
                                        return val;
                        }
                        catch(e) {}
 
                        window.sessionStorage.removeItem('tab');
-                       return { page: page, groups: [] };
+                       return { page: page, paths: {} };
                },
 
-               getActiveTabId: function(groupId) {
-                       return +this.getActiveTabState().groups[groupId] || 0;
+               getActiveTabId: function(pane) {
+                       var path = this.getPathForPane(pane);
+                       return +this.getActiveTabState().paths[path] || 0;
                },
 
-               setActiveTabId: function(groupId, tabIndex) {
+               setActiveTabId: function(pane, tabIndex) {
+                       var path = this.getPathForPane(pane);
+
                        try {
                                var state = this.getActiveTabState();
-                                   state.groups[groupId] = tabIndex;
+                                   state.paths[path] = tabIndex;
 
                            window.sessionStorage.setItem('tab', JSON.stringify(state));
                        }
@@ -1621,13 +2298,16 @@ return L.Class.extend({
                        return true;
                },
 
-               updateTabs: function(ev) {
-                       document.querySelectorAll('[data-tab-title]').forEach(function(pane) {
+               updateTabs: function(ev, root) {
+                       (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
                                var menu = pane.parentNode.previousElementSibling,
-                                   tab = menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))),
+                                   tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
                                    n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
 
-                               if (!pane.firstElementChild) {
+                               if (!menu || !tab)
+                                       return;
+
+                               if (this.isEmptyPane(pane)) {
                                        tab.style.display = 'none';
                                        tab.classList.remove('flash');
                                }
@@ -1645,7 +2325,7 @@ return L.Class.extend({
                                        tab.removeAttribute('data-errors');
                                        tab.removeAttribute('data-tooltip');
                                }
-                       });
+                       }, this));
                },
 
                switchTab: function(ev) {
@@ -1672,7 +2352,7 @@ return L.Class.extend({
                                if (L.dom.matches(pane, '[data-tab]')) {
                                        if (pane.getAttribute('data-tab') === name) {
                                                pane.setAttribute('data-tab-active', 'true');
-                                               L.ui.tabs.setActiveTabId(groupId, index);
+                                               L.ui.tabs.setActiveTabId(pane, index);
                                        }
                                        else {
                                                pane.setAttribute('data-tab-active', 'false');
@@ -1684,6 +2364,147 @@ return L.Class.extend({
                }
        }),
 
+       /* File uploading */
+       uploadFile: function(path, progressStatusNode) {
+               return new Promise(function(resolveFn, rejectFn) {
+                       L.ui.showModal(_('Uploading file…'), [
+                               E('p', _('Please select the file to upload.')),
+                               E('div', { 'style': 'display:flex' }, [
+                                       E('div', { 'class': 'left', 'style': 'flex:1' }, [
+                                               E('input', {
+                                                       type: 'file',
+                                                       style: 'display:none',
+                                                       change: function(ev) {
+                                                               var modal = L.dom.parent(ev.target, '.modal'),
+                                                                   body = modal.querySelector('p'),
+                                                                   upload = modal.querySelector('.cbi-button-action.important'),
+                                                                   file = ev.currentTarget.files[0];
+
+                                                               if (file == null)
+                                                                       return;
+
+                                                               L.dom.content(body, [
+                                                                       E('ul', {}, [
+                                                                               E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
+                                                                               E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
+                                                                       ])
+                                                               ]);
+
+                                                               upload.disabled = false;
+                                                               upload.focus();
+                                                       }
+                                               }),
+                                               E('button', {
+                                                       'class': 'btn',
+                                                       'click': function(ev) {
+                                                               ev.target.previousElementSibling.click();
+                                                       }
+                                               }, [ _('Browse…') ])
+                                       ]),
+                                       E('div', { 'class': 'right', 'style': 'flex:1' }, [
+                                               E('button', {
+                                                       'class': 'btn',
+                                                       'click': function() {
+                                                               L.ui.hideModal();
+                                                               rejectFn(new Error('Upload has been cancelled'));
+                                                       }
+                                               }, [ _('Cancel') ]),
+                                               ' ',
+                                               E('button', {
+                                                       'class': 'btn cbi-button-action important',
+                                                       'disabled': true,
+                                                       'click': function(ev) {
+                                                               var input = L.dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
+
+                                                               if (!input.files[0])
+                                                                       return;
+
+                                                               var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
+
+                                                               L.ui.showModal(_('Uploading file…'), [ progress ]);
+
+                                                               var data = new FormData();
+
+                                                               data.append('sessionid', rpc.getSessionID());
+                                                               data.append('filename', path);
+                                                               data.append('filedata', input.files[0]);
+
+                                                               var filename = input.files[0].name;
+
+                                                               L.Request.post(L.env.cgi_base + '/cgi-upload', data, {
+                                                                       timeout: 0,
+                                                                       progress: function(pev) {
+                                                                               var percent = (pev.loaded / pev.total) * 100;
+
+                                                                               if (progressStatusNode)
+                                                                                       progressStatusNode.data = '%.2f%%'.format(percent);
+
+                                                                               progress.setAttribute('title', '%.2f%%'.format(percent));
+                                                                               progress.firstElementChild.style.width = '%.2f%%'.format(percent);
+                                                                       }
+                                                               }).then(function(res) {
+                                                                       var reply = res.json();
+
+                                                                       L.ui.hideModal();
+
+                                                                       if (L.isObject(reply) && reply.failure) {
+                                                                               L.ui.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
+                                                                               rejectFn(new Error(reply.failure));
+                                                                       }
+                                                                       else {
+                                                                               reply.name = filename;
+                                                                               resolveFn(reply);
+                                                                       }
+                                                               }, function(err) {
+                                                                       L.ui.hideModal();
+                                                                       rejectFn(err);
+                                                               });
+                                                       }
+                                               }, [ _('Upload') ])
+                                       ])
+                               ])
+                       ]);
+               });
+       },
+
+       /* Reconnect handling */
+       pingDevice: function(proto, ipaddr) {
+               var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
+
+               return new Promise(function(resolveFn, rejectFn) {
+                       var img = new Image();
+
+                       img.onload = resolveFn;
+                       img.onerror = rejectFn;
+
+                       window.setTimeout(rejectFn, 1000);
+
+                       img.src = target;
+               });
+       },
+
+       awaitReconnect: function(/* ... */) {
+               var ipaddrs = arguments.length ? arguments : [ window.location.host ];
+
+               window.setTimeout(L.bind(function() {
+                       L.Poll.add(L.bind(function() {
+                               var tasks = [], reachable = false;
+
+                               for (var i = 0; i < 2; i++)
+                                       for (var j = 0; j < ipaddrs.length; j++)
+                                               tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
+                                                       .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
+
+                               return Promise.all(tasks).then(function() {
+                                       if (reachable) {
+                                               L.Poll.stop();
+                                               window.location = reachable;
+                                       }
+                               });
+                       }, this));
+               }, this), 5000);
+       },
+
        /* UCI Changes */
        changes: L.Class.singleton({
                init: function() {
@@ -1755,24 +2576,18 @@ return L.Class.extend({
                                                        E('var', {}, E('del', '&#160;')), ' ', _('Option removed') ])]),
                                        E('br'), list,
                                        E('div', { 'class': 'right' }, [
-                                               E('input', {
-                                                       'type': 'button',
+                                               E('button', {
                                                        'class': 'btn',
-                                                       'click': L.ui.hideModal,
-                                                       'value': _('Dismiss')
-                                               }), ' ',
-                                               E('input', {
-                                                       'type': 'button',
+                                                       'click': L.ui.hideModal
+                                               }, [ _('Dismiss') ]), ' ',
+                                               E('button', {
                                                        'class': 'cbi-button cbi-button-positive important',
-                                                       'click': L.bind(this.apply, this, true),
-                                                       'value': _('Save & Apply')
-                                               }), ' ',
-                                               E('input', {
-                                                       'type': 'button',
+                                                       'click': L.bind(this.apply, this, true)
+                                               }, [ _('Save & Apply') ]), ' ',
+                                               E('button', {
                                                        'class': 'cbi-button cbi-button-reset',
-                                                       'click': L.bind(this.revert, this),
-                                                       'value': _('Revert')
-                                               })])])
+                                                       'click': L.bind(this.revert, this)
+                                               }, [ _('Revert') ])])])
                        ]);
 
                        for (var config in this.changes) {
@@ -1797,7 +2612,7 @@ return L.Class.extend({
                                                                return chg[1];
 
                                                case 4:
-                                                       return "'" + chg[3].replace(/'/g, "'\"'\"'") + "'";
+                                                       return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
 
                                                default:
                                                        return chg[m1-1];
@@ -1845,27 +2660,21 @@ return L.Class.extend({
                                var call = function(r, data, duration) {
                                        if (r.status === 204) {
                                                L.ui.changes.displayStatus('warning', [
-                                                       E('h4', _('Configuration has been rolled back!')),
+                                                       E('h4', _('Configuration changes have been rolled back!')),
                                                        E('p', _('The device could not be reached within %d seconds after applying the pending changes, which caused the configuration to be rolled back for safety reasons. If you believe that the configuration changes are correct nonetheless, perform an unchecked configuration apply. Alternatively, you can dismiss this warning and edit changes before attempting to apply again, or revert all pending changes to keep the currently working configuration state.').format(L.env.apply_rollback)),
                                                        E('div', { 'class': 'right' }, [
-                                                               E('input', {
-                                                                       'type': 'button',
+                                                               E('button', {
                                                                        'class': 'btn',
-                                                                       'click': L.bind(L.ui.changes.displayStatus, L.ui.changes, false),
-                                                                       'value': _('Dismiss')
-                                                               }), ' ',
-                                                               E('input', {
-                                                                       'type': 'button',
+                                                                       'click': L.bind(L.ui.changes.displayStatus, L.ui.changes, false)
+                                                               }, [ _('Dismiss') ]), ' ',
+                                                               E('button', {
                                                                        'class': 'btn cbi-button-action important',
-                                                                       'click': L.bind(L.ui.changes.revert, L.ui.changes),
-                                                                       'value': _('Revert changes')
-                                                               }), ' ',
-                                                               E('input', {
-                                                                       'type': 'button',
+                                                                       'click': L.bind(L.ui.changes.revert, L.ui.changes)
+                                                               }, [ _('Revert changes') ]), ' ',
+                                                               E('button', {
                                                                        'class': 'btn cbi-button-negative important',
-                                                                       'click': L.bind(L.ui.changes.apply, L.ui.changes, false),
-                                                                       'value': _('Apply unchecked')
-                                                               })
+                                                                       'click': L.bind(L.ui.changes.apply, L.ui.changes, false)
+                                                               }, [ _('Apply unchecked') ])
                                                        ])
                                                ]);
 
@@ -1912,7 +2721,7 @@ return L.Class.extend({
 
                                        L.ui.changes.setIndicator(0);
                                        L.ui.changes.displayStatus('notice',
-                                               E('p', _('Configuration has been applied.')));
+                                               E('p', _('Configuration changes applied.')));
 
                                        window.clearTimeout(tt);
                                        window.setTimeout(function() {
@@ -1929,7 +2738,7 @@ return L.Class.extend({
                                                method: 'post',
                                                timeout: L.env.apply_timeout * 1000,
                                                query: L.ui.changes.confirm_auth
-                                       }).then(call);
+                                       }).then(call, call);
                                }, delay);
                        };
 
@@ -1937,7 +2746,7 @@ return L.Class.extend({
                                var now = Date.now();
 
                                L.ui.changes.displayStatus('notice spinning',
-                                       E('p', _('Waiting for configuration to get applied… %ds')
+                                       E('p', _('Applying configuration changes… %ds')
                                                .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
 
                                if (now >= deadline)
@@ -2043,12 +2852,42 @@ return L.Class.extend({
                catch (e) { }
        },
 
+       createHandlerFn: function(ctx, fn /*, ... */) {
+               if (typeof(fn) == 'string')
+                       fn = ctx[fn];
+
+               if (typeof(fn) != 'function')
+                       return null;
+
+               var arg_offset = arguments.length - 2;
+
+               return Function.prototype.bind.apply(function() {
+                       var t = arguments[arg_offset].target;
+
+                       t.classList.add('spinning');
+                       t.disabled = true;
+
+                       if (t.blur)
+                               t.blur();
+
+                       Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
+                               t.classList.remove('spinning');
+                               t.disabled = false;
+                       });
+               }, this.varargs(arguments, 2, ctx));
+       },
+
+       AbstractElement: UIElement,
+
        /* Widgets */
        Textfield: UITextfield,
+       Textarea: UITextarea,
        Checkbox: UICheckbox,
        Select: UISelect,
        Dropdown: UIDropdown,
        DynamicList: UIDynamicList,
        Combobox: UICombobox,
-       Hiddenfield: UIHiddenfield
+       ComboButton: UIComboButton,
+       Hiddenfield: UIHiddenfield,
+       FileUpload: UIFileUpload
 });