luci-base: ui.js: support clearChoices()/addChoices() for DynLists
[project/luci.git] / modules / luci-base / htdocs / luci-static / resources / ui.js
index caae812812979158029032bd2c086e8ce92207da..08edaa1475a48915e29fa85f1d2842756323471b 100644 (file)
@@ -312,7 +312,7 @@ var UISelect = UIElement.extend({
                                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] == '')
@@ -321,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 {
@@ -442,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', {
@@ -457,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));
                }
@@ -917,6 +923,33 @@ 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(),
@@ -936,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 = E(markup.replace(/{{value}}/g, '%h'.format(item)));
+                               new_item = sbox.createChoiceElement(sb, item);
 
-                               if (sbox.options.multiple) {
-                                       sbox.transformItem(sb, new_item);
-                               }
-                               else {
+                               if (!sbox.options.multiple) {
                                        var old = ul.querySelector('li[created]');
                                        if (old)
                                                ul.removeChild(old);
@@ -965,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', {}));
@@ -1188,6 +1258,51 @@ var UICombobox = UIDropdown.extend({
        }
 });
 
+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))
@@ -1211,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 {
@@ -1225,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);
        },
@@ -1255,7 +1379,7 @@ 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,
@@ -1349,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) {
@@ -1422,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');
        }
 });
 
@@ -1593,7 +1736,7 @@ var UIFileUpload = UIElement.extend({
                data.append('filename', path + '/' + filename);
                data.append('filedata', fileinput.files[0]);
 
-               return L.Request.post('/cgi-bin/cgi-upload', data, {
+               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)
@@ -2021,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);
 
@@ -2053,6 +2199,9 @@ 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'),
@@ -2072,6 +2221,7 @@ return L.Class.extend({
                        }
 
                        group.parentNode.insertBefore(menu, group);
+                       group.setAttribute('data-initialized', true);
 
                        if (selected === null) {
                                selected = this.getActiveTabId(panes[0]);
@@ -2214,6 +2364,109 @@ 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());
@@ -2407,7 +2660,7 @@ 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('button', {
@@ -2468,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() {
@@ -2493,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)
@@ -2606,8 +2859,10 @@ return L.Class.extend({
                if (typeof(fn) != 'function')
                        return null;
 
+               var arg_offset = arguments.length - 2;
+
                return Function.prototype.bind.apply(function() {
-                       var t = arguments[arguments.length - 1].target;
+                       var t = arguments[arg_offset].target;
 
                        t.classList.add('spinning');
                        t.disabled = true;
@@ -2622,6 +2877,8 @@ return L.Class.extend({
                }, this.varargs(arguments, 2, ctx));
        },
 
+       AbstractElement: UIElement,
+
        /* Widgets */
        Textfield: UITextfield,
        Textarea: UITextarea,
@@ -2630,6 +2887,7 @@ return L.Class.extend({
        Dropdown: UIDropdown,
        DynamicList: UIDynamicList,
        Combobox: UICombobox,
+       ComboButton: UIComboButton,
        Hiddenfield: UIHiddenfield,
        FileUpload: UIFileUpload
 });