diff options
Diffstat (limited to 'modules/luci-base/htdocs/luci-static')
32 files changed, 1048 insertions, 523 deletions
diff --git a/modules/luci-base/htdocs/luci-static/resources/cbi.js b/modules/luci-base/htdocs/luci-static/resources/cbi.js index 324a91403f..38687a1cef 100644 --- a/modules/luci-base/htdocs/luci-static/resources/cbi.js +++ b/modules/luci-base/htdocs/luci-static/resources/cbi.js @@ -370,12 +370,7 @@ function cbi_validate_form(form, errmsg) function cbi_validate_named_section_add(input) { var button = input.parentNode.parentNode.querySelector('.cbi-button-add'); - if (input.value !== '') { - button.disabled = false; - } - else { - button.disabled = true; - } + button.disabled = input.value === ''; } function cbi_validate_reset(form) @@ -764,72 +759,14 @@ function cbi_update_table(table, data, placeholder) { if (!isElem(target)) return; - target.querySelectorAll('tr.table-titles, .tr.table-titles, .cbi-section-table-titles').forEach(function(thead) { - var titles = []; - - thead.querySelectorAll('th, .th').forEach(function(th) { - titles.push(th); - }); - - if (Array.isArray(data)) { - var n = 0, rows = target.querySelectorAll('tr, .tr'), trows = []; - - data.forEach(function(row) { - var trow = E('tr', { 'class': 'tr' }); - - for (var i = 0; i < titles.length; i++) { - var text = (titles[i].innerText || '').trim(); - var td = trow.appendChild(E('td', { - 'class': titles[i].className, - 'data-title': (text !== '') ? text : null - }, (row[i] != null) ? row[i] : '')); - - td.classList.remove('th'); - td.classList.add('td'); - } - - trow.classList.add('cbi-rowstyle-%d'.format((n++ % 2) ? 2 : 1)); - - trows[n] = trow; - }); - - for (var i = 1; i <= n; i++) { - if (rows[i]) - target.replaceChild(trows[i], rows[i]); - else - target.appendChild(trows[i]); - } - - while (rows[++n]) - target.removeChild(rows[n]); - - if (placeholder && target.firstElementChild === target.lastElementChild) { - var trow = target.appendChild(E('tr', { 'class': 'tr placeholder' })); - var td = trow.appendChild(E('td', { 'class': titles[0].className }, placeholder)); + var t = L.dom.findClassInstance(target); - td.classList.remove('th'); - td.classList.add('td'); - } - } - else { - thead.parentNode.style.display = 'none'; - - thead.parentNode.querySelectorAll('tr, .tr, .cbi-section-table-row').forEach(function(trow) { - if (trow !== thead) { - var n = 0; - trow.querySelectorAll('th, td, .th, .td').forEach(function(td) { - if (n < titles.length) { - var text = (titles[n++].innerText || '').trim(); - if (text !== '') - td.setAttribute('data-title', text); - } - }); - } - }); + if (!(t instanceof L.ui.Table)) { + t = new L.ui.Table(target); + L.dom.bindClassInstance(target, t); + } - thead.parentNode.style.display = ''; - } - }); + t.update(data, placeholder); } function showModal(title, children) @@ -854,5 +791,7 @@ document.addEventListener('DOMContentLoaded', function() { L.hideTooltip(ev); }); - document.querySelectorAll('.table').forEach(cbi_update_table); + L.require('ui').then(function(ui) { + document.querySelectorAll('.table').forEach(cbi_update_table); + }); }); diff --git a/modules/luci-base/htdocs/luci-static/resources/form.js b/modules/luci-base/htdocs/luci-static/resources/form.js index 23cc0b1cb5..66ba030208 100644 --- a/modules/luci-base/htdocs/luci-static/resources/form.js +++ b/modules/luci-base/htdocs/luci-static/resources/form.js @@ -74,7 +74,7 @@ var CBIJSONConfig = baseclass.extend({ if (indexA != indexB) return (indexA - indexB); - return (a > b); + return L.naturalCompare(a, b); }, this)); for (var i = 0; i < section_ids.length; i++) @@ -204,7 +204,7 @@ var CBIAbstractElement = baseclass.extend(/** @lends LuCI.form.AbstractElement.p /** * Add another form element as children to this element. * - * @param {AbstractElement} element + * @param {AbstractElement} obj * The form element to add. */ append: function(obj) { @@ -217,7 +217,7 @@ var CBIAbstractElement = baseclass.extend(/** @lends LuCI.form.AbstractElement.p * The `parse()` function recursively walks the form element tree and * triggers input value reading and validation for each encountered element. * - * Elements which are hidden due to unsatisified dependencies are skipped. + * Elements which are hidden due to unsatisfied dependencies are skipped. * * @returns {Promise<void>} * Returns a promise resolving once this element's value and the values of @@ -277,19 +277,23 @@ var CBIAbstractElement = baseclass.extend(/** @lends LuCI.form.AbstractElement.p /** * Strip any HTML tags from the given input string. * - * @param {string} input + * @param {string} s * The input string to clean. * * @returns {string} - * The cleaned input string with HTML removes removed. + * The cleaned input string with HTML tags removed. */ stripTags: function(s) { if (typeof(s) == 'string' && !s.match(/[<>]/)) return s; - var x = dom.parse('<div>' + s + '</div>'); + var x = dom.elem(s) ? s : dom.parse('<div>' + s + '</div>'); - return x.textContent || x.innerText || ''; + x.querySelectorAll('br').forEach(function(br) { + x.replaceChild(document.createTextNode('\n'), br); + }); + + return (x.textContent || x.innerText || '').replace(/([ \t]*\n)+/g, '\n'); }, /** @@ -344,7 +348,7 @@ var CBIAbstractElement = baseclass.extend(/** @lends LuCI.form.AbstractElement.p * @classdesc * * The `Map` class represents one complete form. A form usually maps one UCI - * configuraton file and is divided into multiple sections containing multiple + * configuration file and is divided into multiple sections containing multiple * fields each. * * It serves as main entry point into the `LuCI.form` for typical view code. @@ -360,7 +364,7 @@ var CBIAbstractElement = baseclass.extend(/** @lends LuCI.form.AbstractElement.p * * @param {string} [description] * The description text of the form which is usually rendered as text - * paragraph below the form title and before the actual form conents. + * paragraph below the form title and before the actual form contents. * If omitted, the corresponding paragraph element will not be rendered. */ var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ { @@ -498,11 +502,11 @@ var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ { * @param {LuCI.form.AbstractSection} sectionclass * The section class to use for rendering the configuration section. * Note that this value must be the class itself, not a class instance - * obtained from calling `new`. It must also be a class dervied from + * obtained from calling `new`. It must also be a class derived from * `LuCI.form.AbstractSection`. * * @param {...string} classargs - * Additional arguments which are passed as-is to the contructor of the + * Additional arguments which are passed as-is to the constructor of the * given section class. Refer to the class specific constructor * documentation for details. * @@ -554,7 +558,7 @@ var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ { * The `parse()` function recursively walks the form element tree and * triggers input value reading and validation for each child element. * - * Elements which are hidden due to unsatisified dependencies are skipped. + * Elements which are hidden due to unsatisfied dependencies are skipped. * * @returns {Promise<void>} * Returns a promise resolving once the entire form completed parsing all @@ -584,7 +588,7 @@ var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ { * * @param {boolean} [silent=false] * If set to `true`, trigger an alert message to the user in case saving - * the form data failes. Otherwise fail silently. + * the form data failures. Otherwise fail silently. * * @returns {Promise<void>} * Returns a promise resolving once the entire save operation is complete. @@ -684,15 +688,15 @@ var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ { /** * Find a form option element instance. * - * @param {string} name_or_id + * @param {string} name * The name or the full ID of the option element to look up. * * @param {string} [section_id] * The ID of the UCI section containing the option to look up. May be * omitted if a full ID is passed as first argument. * - * @param {string} [config] - * The name of the UCI configuration the option instance is belonging to. + * @param {string} [config_name] + * The name of the UCI configuration the option instance belongs to. * Defaults to the main UCI configuration of the map if omitted. * * @returns {Array<LuCI.form.AbstractValue,string>|null} @@ -793,7 +797,7 @@ var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ { * * @param {string} [description] * The description text of the form which is usually rendered as text - * paragraph below the form title and before the actual form conents. + * paragraph below the form title and before the actual form contents. * If omitted, the corresponding paragraph element will not be rendered. */ var CBIJSONMap = CBIMap.extend(/** @lends LuCI.form.JSONMap.prototype */ { @@ -917,7 +921,7 @@ var CBIAbstractSection = CBIAbstractElement.extend(/** @lends LuCI.form.Abstract * triggers input value reading and validation for each encountered child * option element. * - * Options which are hidden due to unsatisified dependencies are skipped. + * Options which are hidden due to unsatisfied dependencies are skipped. * * @returns {Promise<void>} * Returns a promise resolving once the values of all child elements have @@ -963,7 +967,7 @@ var CBIAbstractSection = CBIAbstractElement.extend(/** @lends LuCI.form.Abstract * contents. If omitted, no description will be rendered. * * @throws {Error} - * Throws an exeption if a tab with the same `name` already exists. + * Throws an exception if a tab with the same `name` already exists. */ tab: function(name, title, description) { if (this.tabs && this.tabs[name]) @@ -993,11 +997,11 @@ var CBIAbstractSection = CBIAbstractElement.extend(/** @lends LuCI.form.Abstract * @param {LuCI.form.AbstractValue} optionclass * The option class to use for rendering the configuration option. Note * that this value must be the class itself, not a class instance obtained - * from calling `new`. It must also be a class dervied from + * from calling `new`. It must also be a class derived from * [LuCI.form.AbstractSection]{@link LuCI.form.AbstractSection}. * * @param {...*} classargs - * Additional arguments which are passed as-is to the contructor of the + * Additional arguments which are passed as-is to the constructor of the * given option class. Refer to the class specific constructor * documentation for details. * @@ -1020,17 +1024,17 @@ var CBIAbstractSection = CBIAbstractElement.extend(/** @lends LuCI.form.Abstract /** * Add a configuration option widget to a tab of the section. * - * @param {string} tabname + * @param {string} tabName * The name of the section tab to add the option element to. * * @param {LuCI.form.AbstractValue} optionclass * The option class to use for rendering the configuration option. Note * that this value must be the class itself, not a class instance obtained - * from calling `new`. It must also be a class dervied from + * from calling `new`. It must also be a class derived from * [LuCI.form.AbstractSection]{@link LuCI.form.AbstractSection}. * * @param {...*} classargs - * Additional arguments which are passed as-is to the contructor of the + * Additional arguments which are passed as-is to the constructor of the * given option class. Refer to the class specific constructor * documentation for details. * @@ -1373,7 +1377,7 @@ var CBIAbstractValue = CBIAbstractElement.extend(/** @lends LuCI.form.AbstractVa /** * If set to `true`, the underlying ui input widget value is not cleared - * from the configuration on unsatisfied depedencies. The default behavior + * from the configuration on unsatisfied dependencies. The default behavior * is to remove the values of all options whose dependencies are not * fulfilled. * @@ -1535,10 +1539,10 @@ var CBIAbstractValue = CBIAbstractElement.extend(/** @lends LuCI.form.AbstractVa */ /** - * Add a dependency contraint to the option. + * Add a dependency constraint to the option. * * Dependency constraints allow making the presence of option elements - * dependant on the current values of certain other options within the + * dependent on the current values of certain other options within the * same form. An option element with unsatisfied dependencies will be * hidden from the view and its current value is omitted when saving. * @@ -1550,7 +1554,7 @@ var CBIAbstractValue = CBIAbstractElement.extend(/** @lends LuCI.form.AbstractVa * a logical "and" expression. * * Option names may be given in "dot notation" which allows to reference - * option elements outside of the current form section. If a name without + * option elements outside the current form section. If a name without * dot is specified, it refers to an option within the same configuration * section. If specified as <code>configname.sectionid.optionname</code>, * options anywhere within the same form may be specified. @@ -1616,11 +1620,11 @@ var CBIAbstractValue = CBIAbstractElement.extend(/** @lends LuCI.form.AbstractVa * </li> * </ul> * - * @param {string|Object<string, string|RegExp>} optionname_or_depends + * @param {string|Object<string, string|RegExp>} field * The name of the option to depend on or an object describing multiple - * dependencies which must be satified (a logical "and" expression). + * dependencies which must be satisfied (a logical "and" expression). * - * @param {string} optionvalue|RegExp + * @param {string|RegExp} value * When invoked with a plain option name as first argument, this parameter * specifies the expected value. In case an object is passed as first * argument, this parameter is ignored. @@ -2255,7 +2259,7 @@ var CBITypedSection = CBIAbstractSection.extend(/** @lends LuCI.form.TypedSectio if (this.map.readonly !== true) { ui.addValidator(nameEl, 'uciname', true, function(v) { - var button = document.querySelector('.cbi-section-create > .cbi-button-add'); + var button = createEl.querySelector('.cbi-section-create > .cbi-button-add'); if (v !== '') { button.disabled = null; return true; @@ -2273,10 +2277,7 @@ var CBITypedSection = CBIAbstractSection.extend(/** @lends LuCI.form.TypedSectio /** @private */ renderSectionPlaceholder: function() { - return E([ - E('em', _('This section contains no values yet')), - E('br'), E('br') - ]); + return E('em', _('This section contains no values yet')); }, /** @private */ @@ -2583,8 +2584,7 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p if (nodes.length == 0) tableEl.appendChild(E('tr', { 'class': 'tr cbi-section-table-row placeholder' }, - E('td', { 'class': 'td' }, - E('em', {}, _('This section contains no values yet'))))); + E('td', { 'class': 'td' }, this.renderSectionPlaceholder()))); sectionEl.appendChild(tableEl); @@ -2615,7 +2615,8 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p if (has_titles) { var trEl = E('tr', { 'class': 'tr cbi-section-table-titles ' + anon_class, - 'data-title': (!this.anonymous || this.sectiontitle) ? _('Name') : null + 'data-title': (!this.anonymous || this.sectiontitle) ? _('Name') : null, + 'click': this.sortable ? ui.createHandlerFn(this, 'handleSort') : null }); for (var i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) { @@ -2624,7 +2625,8 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p trEl.appendChild(E('th', { 'class': 'th cbi-section-table-cell', - 'data-widget': opt.__name__ + 'data-widget': opt.__name__, + 'data-sortable-row': this.sortable ? '' : null })); if (opt.width != null) @@ -2993,10 +2995,20 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p /** @private */ handleModalCancel: function(modalMap, ev) { - var prevNode = this.getPreviousModalMap(); + var prevNode = this.getPreviousModalMap(), + resetTasks = Promise.resolve(); if (prevNode) { - var heading = prevNode.parentNode.querySelector('h4'); + var heading = prevNode.parentNode.querySelector('h4'), + prevMap = dom.findClassInstance(prevNode); + + while (prevMap) { + resetTasks = resetTasks + .then(L.bind(prevMap.load, prevMap)) + .then(L.bind(prevMap.reset, prevMap)); + + prevMap = prevMap.parent; + } prevNode.classList.add('flash'); prevNode.classList.remove('hidden'); @@ -3013,7 +3025,7 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p ui.hideModal(); } - return Promise.resolve(); + return resetTasks; }, /** @private */ @@ -3030,10 +3042,68 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p } return saveTasks - .then(L.bind(this.handleModalCancel, this, modalMap, ev)) + .then(L.bind(this.handleModalCancel, this, modalMap, ev, true)) .catch(function() {}); }, + /** @private */ + handleSort: function(ev) { + if (!ev.target.matches('th[data-sortable-row]')) + return; + + var th = ev.target, + descending = (th.getAttribute('data-sort-direction') == 'desc'), + config_name = this.uciconfig || this.map.config, + index = 0, + list = []; + + ev.currentTarget.querySelectorAll('th').forEach(function(other_th, i) { + if (other_th !== th) + other_th.removeAttribute('data-sort-direction'); + else + index = i; + }); + + ev.currentTarget.parentNode.querySelectorAll('tr.cbi-section-table-row').forEach(L.bind(function(tr, i) { + var sid = tr.getAttribute('data-sid'), + opt = tr.childNodes[index].getAttribute('data-name'), + val = this.cfgvalue(sid, opt); + + tr.querySelectorAll('.flash').forEach(function(n) { + n.classList.remove('flash') + }); + + list.push([ + ui.Table.prototype.deriveSortKey((val != null) ? val.trim() : ''), + tr + ]); + }, this)); + + list.sort(function(a, b) { + return descending + ? -L.naturalCompare(a[0], b[0]) + : L.naturalCompare(a[0], b[0]); + }); + + window.requestAnimationFrame(L.bind(function() { + var ref_sid, cur_sid; + + for (var i = 0; i < list.length; i++) { + list[i][1].childNodes[index].classList.add('flash'); + th.parentNode.parentNode.appendChild(list[i][1]); + + cur_sid = list[i][1].getAttribute('data-sid'); + + if (ref_sid) + this.map.data.move(config_name, cur_sid, ref_sid, true); + + ref_sid = cur_sid; + } + + th.setAttribute('data-sort-direction', descending ? 'asc' : 'desc'); + }, this)); + }, + /** * Add further options to the per-section instanced modal popup. * @@ -3056,7 +3126,7 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p * @returns {*|Promise<*>} * Return values of this function are ignored but if a promise is returned, * it is run to completion before the rendering is continued, allowing - * custom logic to perform asynchroneous work before the modal dialog + * custom logic to perform asynchronous work before the modal dialog * is shown. */ addModalOptions: function(modalSection, section_id, ev) { @@ -3077,33 +3147,38 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p }, /** @private */ - renderMoreOptionsModal: function(section_id, ev) { - var parent = this.map, - title = parent.title, - name = null, - m = new CBIMap(this.map.config, null, null), - s = m.section(CBINamedSection, section_id, this.sectiontype); + cloneOptions: function(src_section, dest_section) { + for (var i = 0; i < src_section.children.length; i++) { + var o1 = src_section.children[i]; - m.parent = parent; - m.readonly = parent.readonly; + if (o1.modalonly === false && src_section === this) + continue; - s.tabs = this.tabs; - s.tab_names = this.tab_names; + var o2; - if ((name = this.titleFn('modaltitle', section_id)) != null) - title = name; - else if ((name = this.titleFn('sectiontitle', section_id)) != null) - title = '%s - %s'.format(parent.title, name); - else if (!this.anonymous) - title = '%s - %s'.format(parent.title, section_id); + if (o1.subsection) { + o2 = dest_section.option(o1.constructor, o1.option, o1.subsection.constructor, o1.subsection.sectiontype, o1.subsection.title, o1.subsection.description); - for (var i = 0; i < this.children.length; i++) { - var o1 = this.children[i]; + for (var k in o1.subsection) { + if (!o1.subsection.hasOwnProperty(k)) + continue; - if (o1.modalonly === false) - continue; + switch (k) { + case 'map': + case 'children': + case 'parentoption': + continue; - var o2 = s.option(o1.constructor, o1.option, o1.title, o1.description); + default: + o2.subsection[k] = o1.subsection[k]; + } + } + + this.cloneOptions(o1.subsection, o2.subsection); + } + else { + o2 = dest_section.option(o1.constructor, o1.option, o1.title, o1.description); + } for (var k in o1) { if (!o1.hasOwnProperty(k)) @@ -3115,6 +3190,7 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p case 'option': case 'title': case 'description': + case 'subsection': continue; default: @@ -3122,39 +3198,84 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p } } } + }, - return Promise.resolve(this.addModalOptions(s, section_id, ev)).then(L.bind(m.render, m)).then(L.bind(function(nodes) { - var modalMap = this.getActiveModalMap(); + /** @private */ + renderMoreOptionsModal: function(section_id, ev) { + var parent = this.map, + sref = parent.data.get(parent.config, section_id), + mapNode = this.getActiveModalMap(), + activeMap = mapNode ? dom.findClassInstance(mapNode) : null, + stackedMap = activeMap && (activeMap.parent !== parent || activeMap.section !== section_id); - if (modalMap) { - modalMap.parentNode - .querySelector('h4') - .appendChild(E('span', title ? ' » ' + title : '')); + return (stackedMap ? activeMap.save(null, true) : Promise.resolve()).then(L.bind(function() { + section_id = sref['.name']; - modalMap.parentNode - .querySelector('div.right > button') - .firstChild.data = _('Back'); + var m; - modalMap.classList.add('hidden'); - modalMap.parentNode.insertBefore(nodes, modalMap.nextElementSibling); - nodes.classList.add('flash'); + if (parent instanceof CBIJSONMap) { + m = new CBIJSONMap(null, null, null); + m.data = parent.data; } else { - ui.showModal(title, [ - nodes, - E('div', { 'class': 'right' }, [ - E('button', { - 'class': 'cbi-button', - 'click': ui.createHandlerFn(this, 'handleModalCancel', m) - }, [ _('Dismiss') ]), ' ', - E('button', { - 'class': 'cbi-button cbi-button-positive important', - 'click': ui.createHandlerFn(this, 'handleModalSave', m), - 'disabled': m.readonly || null - }, [ _('Save') ]) - ]) - ], 'cbi-modal'); + m = new CBIMap(parent.config, null, null); } + + var s = m.section(CBINamedSection, section_id, this.sectiontype); + + m.parent = parent; + m.section = section_id; + m.readonly = parent.readonly; + + s.tabs = this.tabs; + s.tab_names = this.tab_names; + + this.cloneOptions(this, s); + + return Promise.resolve(this.addModalOptions(s, section_id, ev)).then(function() { + return m.render(); + }).then(L.bind(function(nodes) { + var title = parent.title, + name = null; + + if ((name = this.titleFn('modaltitle', section_id)) != null) + title = name; + else if ((name = this.titleFn('sectiontitle', section_id)) != null) + title = '%s - %s'.format(parent.title, name); + else if (!this.anonymous) + title = '%s - %s'.format(parent.title, section_id); + + if (stackedMap) { + mapNode.parentNode + .querySelector('h4') + .appendChild(E('span', title ? ' » ' + title : '')); + + mapNode.parentNode + .querySelector('div.right > button') + .firstChild.data = _('Back'); + + mapNode.classList.add('hidden'); + mapNode.parentNode.insertBefore(nodes, mapNode.nextElementSibling); + + nodes.classList.add('flash'); + } + else { + ui.showModal(title, [ + nodes, + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'cbi-button', + 'click': ui.createHandlerFn(this, 'handleModalCancel', m) + }, [ _('Dismiss') ]), ' ', + E('button', { + 'class': 'cbi-button cbi-button-positive important', + 'click': ui.createHandlerFn(this, 'handleModalSave', m), + 'disabled': m.readonly || null + }, [ _('Save') ]) + ]) + ], 'cbi-modal'); + } + }, this)); }, this)).catch(L.error); } }); @@ -3177,7 +3298,7 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p * * Another important difference is that the table cells show a readonly text * preview of the corresponding option elements by default, unless the child - * option element is explicitely made writable by setting the `editable` + * option element is explicitly made writable by setting the `editable` * property to `true`. * * Additionally, the grid section honours a `modalonly` property of child @@ -3226,7 +3347,7 @@ var CBIGridSection = CBITableSection.extend(/** @lends LuCI.form.GridSection.pro * contents. If omitted, no description will be rendered. * * @throws {Error} - * Throws an exeption if a tab with the same `name` already exists. + * Throws an exception if a tab with the same `name` already exists. */ tab: function(name, title, description) { CBIAbstractSection.prototype.tab.call(this, name, title, description); @@ -3249,20 +3370,19 @@ var CBIGridSection = CBITableSection.extend(/** @lends LuCI.form.GridSection.pro var mapNode = this.getPreviousModalMap(), prevMap = mapNode ? dom.findClassInstance(mapNode) : this.map; - return this.super('handleModalSave', arguments) - .then(function() { delete prevMap.addedSection }); + return this.super('handleModalSave', arguments); }, /** @private */ - handleModalCancel: function(/* ... */) { + handleModalCancel: function(modalMap, ev, isSaving) { var config_name = this.uciconfig || this.map.config, mapNode = this.getPreviousModalMap(), prevMap = mapNode ? dom.findClassInstance(mapNode) : this.map; - if (prevMap.addedSection != null) { + if (prevMap.addedSection != null && !isSaving) this.map.data.remove(config_name, prevMap.addedSection); - delete prevMap.addedSection; - } + + delete prevMap.addedSection; return this.super('handleModalCancel', arguments); }, @@ -3300,7 +3420,7 @@ var CBIGridSection = CBITableSection.extend(/** @lends LuCI.form.GridSection.pro 'data-title': (title != '') ? title : null, 'data-description': (descr != '') ? descr : null, 'data-name': opt.option, - 'data-widget': opt.typename || opt.__name__ + 'data-widget': 'CBI.DummyValue' }, (value != null) ? value : E('em', _('none'))); }, @@ -3545,7 +3665,7 @@ var CBIValue = CBIAbstractValue.extend(/** @lends LuCI.form.Value.prototype */ { * @param {string} key * The choice value to add. * - * @param {Node|string} value + * @param {Node|string} val * The caption for the choice value. May be a DOM node, a document fragment * or a plain text string. If omitted, the `key` value is used as caption. */ @@ -3642,7 +3762,7 @@ var CBIValue = CBIAbstractValue.extend(/** @lends LuCI.form.Value.prototype */ { if (!in_table && typeof(this.description) === 'string' && this.description !== '') dom.append(optionEl.lastChild || optionEl, - E('div', { 'class': 'cbi-value-description' }, this.description)); + E('div', { 'class': 'cbi-value-description' }, this.description.trim())); if (depend_list && depend_list.length) optionEl.classList.add('hidden'); @@ -3757,7 +3877,7 @@ var CBIDynamicList = CBIValue.extend(/** @lends LuCI.form.DynamicList.prototype * @classdesc * * The `ListValue` class implements a simple static HTML select element - * allowing the user to chose a single value from a set of predefined choices. + * allowing the user chose a single value from a set of predefined choices. * It builds upon the {@link LuCI.ui.Select} widget. * * @param {LuCI.form.Map|LuCI.form.JSONMap} form @@ -4206,7 +4326,7 @@ var CBIDummyValue = CBIValue.extend(/** @lends LuCI.form.DummyValue.prototype */ __name__: 'CBI.DummyValue', /** - * Set an URL which is opened when clicking on the dummy value text. + * Set a URL which is opened when clicking on the dummy value text. * * By setting this property, the dummy value text is wrapped in an `<a>` * element with the property value used as `href` attribute. @@ -4679,7 +4799,7 @@ var CBISectionValue = CBIValue.extend(/** @lends LuCI.form.SectionValue.prototyp * @hideconstructor * @classdesc * - * The LuCI form class provides high level abstractions for creating creating + * The LuCI form class provides high level abstractions for creating * UCI- or JSON backed configurations forms. * * To import the class in views, use `'require form'`, to import it in diff --git a/modules/luci-base/htdocs/luci-static/resources/fs.js b/modules/luci-base/htdocs/luci-static/resources/fs.js index 99defb76c5..b64af04e56 100644 --- a/modules/luci-base/htdocs/luci-static/resources/fs.js +++ b/modules/luci-base/htdocs/luci-static/resources/fs.js @@ -229,7 +229,7 @@ var FileSystem = baseclass.extend(/** @lends LuCI.fs.prototype */ { /** * Unlink the given file. * - * @param {string} + * @param {string} path * The file path to remove. * * @returns {Promise<number>} @@ -345,7 +345,7 @@ var FileSystem = baseclass.extend(/** @lends LuCI.fs.prototype */ { * @param {string} path * The file path to read. * - * @param {string} [type=text] + * @param {"blob"|"text"|"blob"} [type=text] * The expected type of read file contents. Valid values are `text` to * interpret the contents as string, `json` to parse the contents as JSON * or `blob` to return the contents as Blob instance. @@ -387,7 +387,7 @@ var FileSystem = baseclass.extend(/** @lends LuCI.fs.prototype */ { * @param {string[]} [params] * The arguments to pass to the command. * - * @param {string} [type=text] + * @param {"blob"|"text"|"blob"} [type=text] * The expected output type of the invoked program. Valid values are * `text` to interpret the output as string, `json` to parse the output * as JSON or `blob` to return the output as Blob instance. diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/alias.png b/modules/luci-base/htdocs/luci-static/resources/icons/alias.png Binary files differindex a0c452c87a..94d556afa8 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/alias.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/alias.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/alias_disabled.png b/modules/luci-base/htdocs/luci-static/resources/icons/alias_disabled.png Binary files differindex 38d0531e36..48c41167df 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/alias_disabled.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/alias_disabled.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/bridge.png b/modules/luci-base/htdocs/luci-static/resources/icons/bridge.png Binary files differindex 7faadecf92..beec3caf23 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/bridge.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/bridge.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/bridge_disabled.png b/modules/luci-base/htdocs/luci-static/resources/icons/bridge_disabled.png Binary files differindex b3e620b3a1..dce152a1dc 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/bridge_disabled.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/bridge_disabled.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/ethernet.png b/modules/luci-base/htdocs/luci-static/resources/icons/ethernet.png Binary files differindex e3d24f2791..e7d37504aa 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/ethernet.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/ethernet.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/ethernet_disabled.png b/modules/luci-base/htdocs/luci-static/resources/icons/ethernet_disabled.png Binary files differindex d8792df54b..73086b3de7 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/ethernet_disabled.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/ethernet_disabled.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/port_down.png b/modules/luci-base/htdocs/luci-static/resources/icons/port_down.png Binary files differindex 1ddf439f29..b3806146e9 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/port_down.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/port_down.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/port_up.png b/modules/luci-base/htdocs/luci-static/resources/icons/port_up.png Binary files differindex fd801a4992..efb4fea585 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/port_up.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/port_up.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/signal-0-25.png b/modules/luci-base/htdocs/luci-static/resources/icons/signal-0-25.png Binary files differindex 382cf540bf..6455093c94 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/signal-0-25.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/signal-0-25.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/signal-0.png b/modules/luci-base/htdocs/luci-static/resources/icons/signal-0.png Binary files differindex c8192c8b9a..ed7d1cdfa9 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/signal-0.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/signal-0.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/signal-25-50.png b/modules/luci-base/htdocs/luci-static/resources/icons/signal-25-50.png Binary files differindex b465de3f57..43387ff666 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/signal-25-50.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/signal-25-50.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/signal-50-75.png b/modules/luci-base/htdocs/luci-static/resources/icons/signal-50-75.png Binary files differindex cd7bcaf9a6..48eeaa831b 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/signal-50-75.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/signal-50-75.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/signal-75-100.png b/modules/luci-base/htdocs/luci-static/resources/icons/signal-75-100.png Binary files differindex f7a3658df8..fd7a80da4c 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/signal-75-100.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/signal-75-100.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/signal-none.png b/modules/luci-base/htdocs/luci-static/resources/icons/signal-none.png Binary files differindex 4a11356af2..944dd094be 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/signal-none.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/signal-none.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/switch.png b/modules/luci-base/htdocs/luci-static/resources/icons/switch.png Binary files differindex 2691874a18..5c780fe66f 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/switch.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/switch.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/switch_disabled.png b/modules/luci-base/htdocs/luci-static/resources/icons/switch_disabled.png Binary files differindex 54588d24d1..a069afec4b 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/switch_disabled.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/switch_disabled.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/tunnel.png b/modules/luci-base/htdocs/luci-static/resources/icons/tunnel.png Binary files differindex 63eabfef59..961467fe65 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/tunnel.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/tunnel.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/tunnel_disabled.png b/modules/luci-base/htdocs/luci-static/resources/icons/tunnel_disabled.png Binary files differindex ca79d81707..406b3508c1 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/tunnel_disabled.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/tunnel_disabled.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/vlan.png b/modules/luci-base/htdocs/luci-static/resources/icons/vlan.png Binary files differindex 2691874a18..5c780fe66f 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/vlan.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/vlan.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/vlan_disabled.png b/modules/luci-base/htdocs/luci-static/resources/icons/vlan_disabled.png Binary files differindex 54588d24d1..a069afec4b 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/vlan_disabled.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/vlan_disabled.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/wifi.png b/modules/luci-base/htdocs/luci-static/resources/icons/wifi.png Binary files differindex 80a23e8e9a..40da21f4f3 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/wifi.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/wifi.png diff --git a/modules/luci-base/htdocs/luci-static/resources/icons/wifi_disabled.png b/modules/luci-base/htdocs/luci-static/resources/icons/wifi_disabled.png Binary files differindex e989a2bd3d..4948024453 100644 --- a/modules/luci-base/htdocs/luci-static/resources/icons/wifi_disabled.png +++ b/modules/luci-base/htdocs/luci-static/resources/icons/wifi_disabled.png diff --git a/modules/luci-base/htdocs/luci-static/resources/luci.js b/modules/luci-base/htdocs/luci-static/resources/luci.js index 78e8b8b30b..ca0e80a82a 100644 --- a/modules/luci-base/htdocs/luci-static/resources/luci.js +++ b/modules/luci-base/htdocs/luci-static/resources/luci.js @@ -81,7 +81,7 @@ * subclass. * * @returns {LuCI.baseclass} - * Returns a new LuCI.baseclass sublassed from this class, extended + * Returns a new LuCI.baseclass subclassed from this class, extended * by the given properties and with its prototype set to this base * class to enable inheritance. The resulting value represents a * class constructor and can be instantiated with `new`. @@ -218,7 +218,7 @@ * would copy all values till the end. * * @param {...*} [extra_args] - * Extra arguments to add to prepend to the resultung array. + * Extra arguments to add to prepend to the resulting array. * * @returns {Array<*>} * Returns a new array consisting of the optional extra arguments @@ -695,115 +695,117 @@ * The resulting HTTP response. */ request: function(target, options) { - var state = { xhr: new XMLHttpRequest(), url: this.expandURL(target), start: Date.now() }, - opt = Object.assign({}, options, state), - content = null, - contenttype = null, - callback = this.handleReadyStateChange; - - return new Promise(function(resolveFn, rejectFn) { - opt.xhr.onreadystatechange = callback.bind(opt, resolveFn, rejectFn); - opt.method = String(opt.method || 'GET').toUpperCase(); - - if ('query' in opt) { - var q = (opt.query != null) ? Object.keys(opt.query).map(function(k) { - if (opt.query[k] != null) { - var v = (typeof(opt.query[k]) == 'object') - ? JSON.stringify(opt.query[k]) - : String(opt.query[k]); - - return '%s=%s'.format(encodeURIComponent(k), encodeURIComponent(v)); - } - else { - return encodeURIComponent(k); - } - }).join('&') : ''; - - if (q !== '') { - switch (opt.method) { - case 'GET': - case 'HEAD': - case 'OPTIONS': - opt.url += ((/\?/).test(opt.url) ? '&' : '?') + q; - break; - - default: - if (content == null) { - content = q; - contenttype = 'application/x-www-form-urlencoded'; + return Promise.resolve(target).then((function(url) { + var state = { xhr: new XMLHttpRequest(), url: this.expandURL(url), start: Date.now() }, + opt = Object.assign({}, options, state), + content = null, + contenttype = null, + callback = this.handleReadyStateChange; + + return new Promise(function(resolveFn, rejectFn) { + opt.xhr.onreadystatechange = callback.bind(opt, resolveFn, rejectFn); + opt.method = String(opt.method || 'GET').toUpperCase(); + + if ('query' in opt) { + var q = (opt.query != null) ? Object.keys(opt.query).map(function(k) { + if (opt.query[k] != null) { + var v = (typeof(opt.query[k]) == 'object') + ? JSON.stringify(opt.query[k]) + : String(opt.query[k]); + + return '%s=%s'.format(encodeURIComponent(k), encodeURIComponent(v)); + } + else { + return encodeURIComponent(k); + } + }).join('&') : ''; + + if (q !== '') { + switch (opt.method) { + case 'GET': + case 'HEAD': + case 'OPTIONS': + opt.url += ((/\?/).test(opt.url) ? '&' : '?') + q; + break; + + default: + if (content == null) { + content = q; + contenttype = 'application/x-www-form-urlencoded'; + } } } } - } - if (!opt.cache) - opt.url += ((/\?/).test(opt.url) ? '&' : '?') + (new Date()).getTime(); + if (!opt.cache) + opt.url += ((/\?/).test(opt.url) ? '&' : '?') + (new Date()).getTime(); - if (isQueueableRequest(opt)) { - requestQueue.push([opt, rejectFn, resolveFn]); - requestAnimationFrame(flushRequestQueue); - return; - } + if (isQueueableRequest(opt)) { + requestQueue.push([opt, rejectFn, resolveFn]); + requestAnimationFrame(flushRequestQueue); + return; + } - if ('username' in opt && 'password' in opt) - opt.xhr.open(opt.method, opt.url, true, opt.username, opt.password); - else - opt.xhr.open(opt.method, opt.url, true); + if ('username' in opt && 'password' in opt) + opt.xhr.open(opt.method, opt.url, true, opt.username, opt.password); + else + opt.xhr.open(opt.method, opt.url, true); + + opt.xhr.responseType = opt.responseType || 'text'; - opt.xhr.responseType = opt.responseType || 'text'; + if ('overrideMimeType' in opt.xhr) + opt.xhr.overrideMimeType('application/octet-stream'); - if ('overrideMimeType' in opt.xhr) - opt.xhr.overrideMimeType('application/octet-stream'); + if ('timeout' in opt) + opt.xhr.timeout = +opt.timeout; - if ('timeout' in opt) - opt.xhr.timeout = +opt.timeout; + if ('credentials' in opt) + opt.xhr.withCredentials = !!opt.credentials; - if ('credentials' in opt) - opt.xhr.withCredentials = !!opt.credentials; + if (opt.content != null) { + switch (typeof(opt.content)) { + case 'function': + content = opt.content(opt.xhr); + break; - if (opt.content != null) { - switch (typeof(opt.content)) { - case 'function': - content = opt.content(opt.xhr); - break; + case 'object': + if (!(opt.content instanceof FormData)) { + content = JSON.stringify(opt.content); + contenttype = 'application/json'; + } + else { + content = opt.content; + } + break; - case 'object': - if (!(opt.content instanceof FormData)) { - content = JSON.stringify(opt.content); - contenttype = 'application/json'; - } - else { - content = opt.content; + default: + content = String(opt.content); } - break; - - default: - content = String(opt.content); } - } - if ('headers' in opt) - for (var header in opt.headers) - if (opt.headers.hasOwnProperty(header)) { - if (header.toLowerCase() != 'content-type') - opt.xhr.setRequestHeader(header, opt.headers[header]); - else - contenttype = opt.headers[header]; - } + if ('headers' in opt) + for (var header in opt.headers) + if (opt.headers.hasOwnProperty(header)) { + if (header.toLowerCase() != 'content-type') + opt.xhr.setRequestHeader(header, opt.headers[header]); + else + contenttype = opt.headers[header]; + } - if ('progress' in opt && 'upload' in opt.xhr) - opt.xhr.upload.addEventListener('progress', opt.progress); + if ('progress' in opt && 'upload' in opt.xhr) + opt.xhr.upload.addEventListener('progress', opt.progress); - if (contenttype != null) - opt.xhr.setRequestHeader('Content-Type', contenttype); + if (contenttype != null) + opt.xhr.setRequestHeader('Content-Type', contenttype); - try { - opt.xhr.send(content); - } - catch (e) { - rejectFn.call(opt, e); - } - }); + try { + opt.xhr.send(content); + } + catch (e) { + rejectFn.call(opt, e); + } + }); + }).bind(this)); }, handleReadyStateChange: function(resolveFn, rejectFn, ev) { @@ -834,7 +836,7 @@ * * @instance * @memberof LuCI.request - * @param {string} target + * @param {string} url * The URL to request. * * @param {LuCI.request.RequestOptions} [options] @@ -852,7 +854,7 @@ * * @instance * @memberof LuCI.request - * @param {string} target + * @param {string} url * The URL to request. * * @param {*} [data] @@ -923,7 +925,7 @@ * @hideconstructor * @classdesc * - * The `Request.poll` class provides some convience wrappers around + * The `Request.poll` class provides some convince wrappers around * {@link LuCI.poll} mainly to simplify registering repeating HTTP * request calls as polling functions. */ @@ -1053,7 +1055,7 @@ /** * Add a new operation to the polling loop. If the polling loop is not - * already started at this point, it will be implicitely started. + * already started at this point, it will be implicitly started. * * @instance * @memberof LuCI.poll @@ -1096,8 +1098,8 @@ }, /** - * Remove an operation from the polling loop. If no further operatons - * are registered, the polling loop is implicitely stopped. + * Remove an operation from the polling loop. If no further operations + * are registered, the polling loop is implicitly stopped. * * @instance * @memberof LuCI.poll @@ -1159,7 +1161,7 @@ * @instance * @memberof LuCI.poll * @returns {boolean} - * Returns `true` if polling has been stopped or `false` if it din't + * Returns `true` if polling has been stopped or `false` if it didn't * run to begin with. */ stop: function() { @@ -1329,7 +1331,7 @@ * The `Node` argument to append the children to. * * @param {*} [children] - * The childrens to append to the given node. + * The children to append to the given node. * * When `children` is an array, then each item of the array * will be either appended as child element or text node, @@ -1344,11 +1346,11 @@ * as first and the return value of the `children` function as * second parameter. * - * When `children` is is a DOM `Node` instance, it will be + * When `children` is a DOM `Node` instance, it will be * appended to the given `node`. * * When `children` is any other non-`null` value, it will be - * converted to a string and appened to the `innerHTML` property + * converted to a string and appended to the `innerHTML` property * of the given `node`. * * @returns {Node|null} @@ -1387,7 +1389,7 @@ * Replaces the content of the given node with the given children. * * This function first removes any children of the given DOM - * `Node` and then adds the given given children following the + * `Node` and then adds the given children following the * rules outlined below. * * @instance @@ -1396,7 +1398,7 @@ * The `Node` argument to replace the children of. * * @param {*} [children] - * The childrens to replace into the given node. + * The children to replace into the given node. * * When `children` is an array, then each item of the array * will be either appended as child element or text node, @@ -1411,11 +1413,11 @@ * as first and the return value of the `children` function as * second parameter. * - * When `children` is is a DOM `Node` instance, it will be + * When `children` is a DOM `Node` instance, it will be * appended to the given `node`. * * When `children` is any other non-`null` value, it will be - * converted to a string and appened to the `innerHTML` property + * converted to a string and appended to the `innerHTML` property * of the given `node`. * * @returns {Node|null} @@ -1469,7 +1471,7 @@ * * When `val` is of any other type, it will be added as attribute * to the given `node` as-is, with the underlying `setAttribute()` - * call implicitely turning it into a string. + * call implicitly turning it into a string. */ attr: function(node, key, val) { if (!this.elem(node)) @@ -2032,7 +2034,7 @@ * Any return values of this function are discarded, but * passed through `Promise.resolve()` to ensure that any * returned promise runs to completion before the button - * is reenabled. + * is re-enabled. */ handleSave: function(ev) { var tasks = []; @@ -2076,7 +2078,7 @@ * Any return values of this function are discarded, but * passed through `Promise.resolve()` to ensure that any * returned promise runs to completion before the button - * is reenabled. + * is re-enabled. */ handleSaveApply: function(ev, mode) { return this.handleSave(ev).then(function() { @@ -2113,7 +2115,7 @@ * Any return values of this function are discarded, but * passed through `Promise.resolve()` to ensure that any * returned promise runs to completion before the button - * is reenabled. + * is re-enabled. */ handleReset: function(ev) { var tasks = []; @@ -2181,7 +2183,7 @@ }).render() : E([]); if (this.handleSaveApply || this.handleSave || this.handleReset) { - footer.appendChild(E('div', { 'class': 'cbi-page-actions control-group' }, [ + footer.appendChild(E('div', { 'class': 'cbi-page-actions' }, [ saveApplyBtn, ' ', this.handleSave ? E('button', { 'class': 'cbi-button cbi-button-save', @@ -2218,6 +2220,8 @@ view: View }; + var naturalCompare = new Intl.Collator(undefined, { numeric: true }).compare; + var LuCI = Class.extend(/** @lends LuCI.prototype */ { __name__: 'LuCI', __init__: function(setenv) { @@ -2323,7 +2327,7 @@ /** * A wrapper around {@link LuCI#raise raise()} which also renders - * the error either as modal overlay when `ui.js` is already loaed + * the error either as modal overlay when `ui.js` is already loaded * or directly into the view body. * * @instance @@ -2667,7 +2671,7 @@ var loc = window.location; window.location = loc.protocol + '//' + loc.host + loc.pathname + loc.search; } - }, _('To login…'))) + }, _('Log in…'))) ]); LuCI.prototype.raise('SessionError', 'Login session is expired'); @@ -2798,7 +2802,7 @@ * omitted, it defaults to an empty string. * * @param {string[]} [parts] - * An array of parts to join into an URL path. Parts may contain + * An array of parts to join into a URL path. Parts may contain * slashes and any of the other characters mentioned above. * * @return {string} @@ -2818,7 +2822,7 @@ }, /** - * Construct an URL pathrelative to the script path of the server + * Construct a URL with path relative to the script path of the server * side LuCI application (usually `/cgi-bin/luci`). * * The resulting URL is guaranteed to only contain the characters @@ -2829,7 +2833,7 @@ * @memberof LuCI * * @param {string[]} [parts] - * An array of parts to join into an URL path. Parts may contain + * An array of parts to join into a URL path. Parts may contain * slashes and any of the other characters mentioned above. * * @return {string} @@ -2840,7 +2844,7 @@ }, /** - * Construct an URL path relative to the global static resource path + * Construct a URL path relative to the global static resource path * of the LuCI ui (usually `/luci-static/resources`). * * The resulting URL is guaranteed to only contain the characters @@ -2851,7 +2855,7 @@ * @memberof LuCI * * @param {string[]} [parts] - * An array of parts to join into an URL path. Parts may contain + * An array of parts to join into a URL path. Parts may contain * slashes and any of the other characters mentioned above. * * @return {string} @@ -2862,7 +2866,7 @@ }, /** - * Construct an URL path relative to the media resource path of the + * Construct a URL path relative to the media resource path of the * LuCI ui (usually `/luci-static/$theme_name`). * * The resulting URL is guaranteed to only contain the characters @@ -2873,7 +2877,7 @@ * @memberof LuCI * * @param {string[]} [parts] - * An array of parts to join into an URL path. Parts may contain + * An array of parts to join into a URL path. Parts may contain * slashes and any of the other characters mentioned above. * * @return {string} @@ -2927,14 +2931,14 @@ * The object to extract the keys from. If the given value is * not an object, the function will return an empty array. * - * @param {string} [key] + * @param {string|null} [key] * Specifies the key to order by. This is mainly useful for * nested objects of objects or objects of arrays when sorting * shall not be performed by the primary object keys but by * some other key pointing to a value within the nested values. * - * @param {string} [sortmode] - * May be either `addr` or `num` to override the natural + * @param {"addr"|"num"} [sortmode] + * Can be either `addr` or `num` to override the natural * lexicographic sorting with a sorting suitable for IP/MAC style * addresses or numeric values respectively. * @@ -2963,18 +2967,55 @@ }).filter(function(e) { return (e[1] != null); }).sort(function(a, b) { - if (a[1] < b[1]) - return -1; - else if (a[1] > b[1]) - return 1; - else - return 0; + return naturalCompare(a[1], b[1]); }).map(function(e) { return e[0]; }); }, /** + * Compares two values numerically and returns -1, 0 or 1 depending + * on whether the first value is smaller, equal to or larger than the + * second one respectively. + * + * This function is meant to be used as comparator function for + * Array.sort(). + * + * @type {function} + * + * @param {*} a + * The first value + * + * @param {*} b + * The second value. + * + * @return {number} + * Returns -1 if the first value is smaller than the second one. + * Returns 0 if both values are equal. + * Returns 1 if the first value is larger than the second one. + */ + naturalCompare: naturalCompare, + + /** + * Converts the given value to an array using toArray() if needed, + * performs a numerical sort using naturalCompare() and returns the + * result. If the input already is an array, no copy is being made + * and the sorting is performed in-place. + * + * @see toArray + * @see naturalCompare + * + * @param {*} val + * The input value to sort (and convert to an array if needed). + * + * @return {Array<*>} + * Returns the resulting, numerically sorted array. + */ + sortedArray: function(val) { + return this.toArray(val).sort(naturalCompare); + }, + + /** * Converts the given value to an array. If the given value is of * type array, it is returned as-is, values of type object are * returned as one-element array containing the object, empty @@ -3008,7 +3049,7 @@ }, /** - * Returns a promise resolving with either the given value or or with + * Returns a promise resolving with either the given value or with * the given default in case the input value is a rejecting promise. * * @instance diff --git a/modules/luci-base/htdocs/luci-static/resources/network.js b/modules/luci-base/htdocs/luci-static/resources/network.js index 8c9ee255ff..fc58c3d758 100644 --- a/modules/luci-base/htdocs/luci-static/resources/network.js +++ b/modules/luci-base/htdocs/luci-static/resources/network.js @@ -101,15 +101,6 @@ var _init = null, _protocols = {}, _protospecs = {}; -function strcmp(a, b) { - if (a > b) - return 1; - else if (a < b) - return -1; - else - return 0; -} - function getProtocolHandlers(cache) { return callNetworkProtoHandlers().then(function(protos) { /* Register "none" protocol */ @@ -219,8 +210,8 @@ function getWifiNetidBySid(sid) { var s = uci.get('wireless', sid); if (s != null && s['.type'] == 'wifi-iface') { var radioname = s.device; - if (typeof(s.device) == 'string') { - var i = 0, netid = null, sections = uci.sections('wireless', 'wifi-iface'); + if (typeof(radioname) == 'string') { + var sections = uci.sections('wireless', 'wifi-iface'); for (var i = 0, n = 0; i < sections.length; i++) { if (sections[i].device != s.device) continue; @@ -485,10 +476,7 @@ function initNetworkState(refresh) { } ports.sort(function(a, b) { - if (a.role != b.role) - return (a.role < b.role) ? -1 : 1; - - return (a.index - b.index); + return L.naturalCompare(a.role, b.role) || L.naturalCompare(a.index, b.index); }); for (var i = 0, port; (port = ports[i]) != null; i++) { @@ -562,18 +550,14 @@ function ifnameOf(obj) { } function networkSort(a, b) { - return strcmp(a.getName(), b.getName()); + return L.naturalCompare(a.getName(), b.getName()); } function deviceSort(a, b) { - var typeWeigth = { wifi: 2, alias: 3 }, - weightA = typeWeigth[a.getType()] || 1, - weightB = typeWeigth[b.getType()] || 1; + var typeWeigth = { wifi: 2, alias: 3 }; - if (weightA != weightB) - return weightA - weightB; - - return strcmp(a.getName(), b.getName()); + return L.naturalCompare(typeWeigth[a.getType()] || 1, typeWeigth[b.getType()] || 1) || + L.naturalCompare(a.getName(), b.getName()); } function formatWifiEncryption(enc) { @@ -695,7 +679,7 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ { * @method * * @param {string} netmask - * The netmask to convert into a bit count. + * The netmask to convert into a bits count. * * @param {boolean} [v6=false] * Whether to parse the given netmask as IPv4 (`false`) or IPv6 (`true`) @@ -1441,7 +1425,7 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ { rv.push(this.lookupWifiNetwork(wifiIfaces[i]['.name'])); rv.sort(function(a, b) { - return strcmp(a.getID(), b.getID()); + return L.naturalCompare(a.getID(), b.getID()); }); return rv; @@ -1539,10 +1523,7 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ { } rv.sort(function(a, b) { - if (a.metric != b.metric) - return (a.metric - b.metric); - - return strcmp(a.interface, b.interface); + return L.naturalCompare(a.metric, b.metric) || L.naturalCompare(a.interface, b.interface); }); return rv; @@ -1630,7 +1611,7 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ { }, /** - * Describes an swconfig switch topology by specifying the CPU + * Describes a swconfig switch topology by specifying the CPU * connections and external port labels of a switch. * * @typedef {Object<string, Object|Array>} SwitchTopology @@ -1645,7 +1626,7 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ { * @property {Array<Object<string, boolean|number|string>>} ports * The `ports` property points to an array describing the populated * ports of the switch in the external label order. Each array item is - * an object containg the following keys: + * an object containing the following keys: * - `num` - the internal switch port number * - `label` - the label of the port, e.g. `LAN 1` or `CPU (eth0)` * - `device` - the connected Linux network device name (CPU ports only) @@ -1749,7 +1730,7 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ { }, /** - * Obtains the the network device name of the given object. + * Obtains the network device name of the given object. * * @param {LuCI.network.Protocol|LuCI.network.Device|LuCI.network.WifiDevice|LuCI.network.WifiNetwork|string} obj * The object to get the device name from. @@ -1990,7 +1971,7 @@ Hosts = baseclass.extend(/** @lends LuCI.network.Hosts.prototype */ { } return rv.sort(function(a, b) { - return strcmp(a[0], b[0]); + return L.naturalCompare(a[0], b[0]); }); } }); @@ -2057,7 +2038,7 @@ Protocol = baseclass.extend(/** @lends LuCI.network.Protocol.prototype */ { }, /** - * Get the associared Linux network device of this network. + * Get the associated Linux network device of this network. * * @returns {null|string} * Returns the name of the associated network device or `null` if @@ -2094,7 +2075,7 @@ Protocol = baseclass.extend(/** @lends LuCI.network.Protocol.prototype */ { }, /** - * Return a human readable description for the protcol, such as + * Return a human readable description for the protocol, such as * `Static address` or `DHCP client`. * * This function should be overwritten by subclasses. @@ -2445,7 +2426,7 @@ Protocol = baseclass.extend(/** @lends LuCI.network.Protocol.prototype */ { * * @returns {string} * Returns the name of the opkg package required for the protocol to - * function, e.g. `odhcp6c` for the `dhcpv6` prototocol. + * function, e.g. `odhcp6c` for the `dhcpv6` protocol. */ getOpkgPackage: function() { return null; @@ -2492,7 +2473,7 @@ Protocol = baseclass.extend(/** @lends LuCI.network.Protocol.prototype */ { * on demand instead of using existing physical interfaces. * * Examples for virtual protocols are `6in4` which `gre` spawn tunnel - * network device on startup, examples for non-virtual protcols are + * network device on startup, examples for non-virtual protocols are * `dhcp` or `static` which apply IP configuration to existing interfaces. * * This function should be overwritten by subclasses. @@ -2509,7 +2490,7 @@ Protocol = baseclass.extend(/** @lends LuCI.network.Protocol.prototype */ { * Checks whether this protocol is "floating". * * A "floating" protocol is a protocol which spawns its own interfaces - * on demand, like a virtual one but which relies on an existinf lower + * on demand, like a virtual one but which relies on an existing lower * level interface to initiate the connection. * * An example for such a protocol is "pppoe". @@ -2722,7 +2703,7 @@ Protocol = baseclass.extend(/** @lends LuCI.network.Protocol.prototype */ { * interface. * * @returns {null|Array<LuCI.network.Device>} - * Returns an array of of `Network.Device` class instances representing + * Returns an array of `Network.Device` class instances representing * the sub-devices attached to this logical interface or `null` if the * logical interface does not support sub-devices, e.g. because it is * virtual and not a bridge. @@ -2852,6 +2833,15 @@ Device = baseclass.extend(/** @lends LuCI.network.Device.prototype */ { this.device = this.device || device; this.dev = Object.assign({}, _state.netdevs[this.device]); this.network = network; + + var conf; + + uci.sections('network', 'device', function(s) { + if (s.name == device) + conf = s; + }); + + this.config = Object.assign({}, conf); }, _devstate: function(/* ... */) { @@ -2946,6 +2936,10 @@ Device = baseclass.extend(/** @lends LuCI.network.Device.prototype */ { return 'vlan'; else if (this.dev.devtype == 'dsa' || _state.isSwitch[this.device]) return 'switch'; + else if (this.config.type == '8021q' || this.config.type == '8021ad') + return 'vlan'; + else if (this.config.type == 'bridge') + return 'bridge'; else return 'ethernet'; }, @@ -3245,7 +3239,13 @@ Device = baseclass.extend(/** @lends LuCI.network.Device.prototype */ { * ordinary ethernet interfaces. */ getParent: function() { - return this.dev.parent ? Network.prototype.instantiateDevice(this.dev.parent) : null; + if (this.dev.parent) + return Network.prototype.instantiateDevice(this.dev.parent); + + if ((this.config.type == '8021q' || this.config.type == '802ad') && typeof(this.config.ifname) == 'string') + return Network.prototype.instantiateDevice(this.config.ifname); + + return null; } }); @@ -3309,7 +3309,7 @@ WifiDevice = baseclass.extend(/** @lends LuCI.network.WifiDevice.prototype */ { * @param {string} opt * The name of the UCI option to set. * - * @param {null|string|string[]} val + * @param {null|string|string[]} value * The value to set or `null` to remove the given option from the * configuration. */ @@ -3396,28 +3396,20 @@ WifiDevice = baseclass.extend(/** @lends LuCI.network.WifiDevice.prototype */ { getI18n: function() { var hw = this.ubus('dev', 'iwinfo', 'hardware'), type = L.isObject(hw) ? hw.name : null; + var modes = this.ubus('dev', 'iwinfo', 'hwmodes_text'); if (this.ubus('dev', 'iwinfo', 'type') == 'wl') type = 'Broadcom'; - var hwmodes = this.getHWModes(), - modestr = ''; - - hwmodes.sort(function(a, b) { - if (a.length != b.length) - return a.length - b.length; - - return strcmp(a, b); - }); - - modestr = hwmodes.join(''); - - return '%s 802.11%s Wireless Controller (%s)'.format(type || 'Generic', modestr, this.getName()); + return '%s %s Wireless Controller (%s)'.format( + type || 'Generic', + modes ? '802.11' + modes : 'unknown', + this.getName()); }, /** * A wireless scan result object describes a neighbouring wireless - * network found in the vincinity. + * network found in the vicinity. * * @typedef {Object<string, number|string|LuCI.network.WifiEncryption>} WifiScanResult * @memberof LuCI.network @@ -3455,7 +3447,7 @@ WifiDevice = baseclass.extend(/** @lends LuCI.network.WifiDevice.prototype */ { * * @returns {Promise<Array<LuCI.network.WifiScanResult>>} * Returns a promise resolving to an array of scan result objects - * describing the networks found in the vincinity. + * describing the networks found in the vicinity. */ getScanList: function() { return callIwinfoScan(this.sid); @@ -3506,7 +3498,7 @@ WifiDevice = baseclass.extend(/** @lends LuCI.network.WifiDevice.prototype */ { * * @returns {Promise<Array<LuCI.network.WifiNetwork>>} * Returns a promise resolving to an array of `Network.WifiNetwork` - * instances respresenting the wireless networks associated with this + * instances representing the wireless networks associated with this * radio device. */ getWifiNetworks: function() { @@ -3635,7 +3627,7 @@ WifiNetwork = baseclass.extend(/** @lends LuCI.network.WifiNetwork.prototype */ * @param {string} opt * The name of the UCI option to set. * - * @param {null|string|string[]} val + * @param {null|string|string[]} value * The value to set or `null` to remove the given option from the * configuration. */ @@ -3722,7 +3714,7 @@ WifiNetwork = baseclass.extend(/** @lends LuCI.network.WifiNetwork.prototype */ /** * Get the internal network ID of this wireless network. * - * The network ID is a LuCI specific identifer in the form + * The network ID is a LuCI specific identifier in the form * `radio#.network#` to identify wireless networks by their corresponding * radio and network index numbers. * @@ -3904,7 +3896,7 @@ WifiNetwork = baseclass.extend(/** @lends LuCI.network.WifiNetwork.prototype */ * Query the current encryption settings from runtime information. * * @returns {string} - * Returns a string describing the current encryption or `-` if the the + * Returns a string describing the current encryption or `-` if the * encryption state could not be found in `ubus` runtime information. */ getActiveEncryption: function() { @@ -4005,7 +3997,7 @@ WifiNetwork = baseclass.extend(/** @lends LuCI.network.WifiNetwork.prototype */ * - `UNKNOWN` * * @property {number} [mesh non-peer PS] - * The powersafe mode for all non-peer neigbours, may be an empty + * The powersafe mode for all non-peer neighbours, may be an empty * string (`''`) or absent if not applicable or supported by the driver. * * The following modes are known: @@ -4040,7 +4032,7 @@ WifiNetwork = baseclass.extend(/** @lends LuCI.network.WifiNetwork.prototype */ * The amount of bytes that have been received or sent. * * @property {number} [failed] - * The amount of failed tranmission attempts. Only applicable to + * The amount of failed transmission attempts. Only applicable to * transmit rates. * * @property {number} [retries] @@ -4064,7 +4056,7 @@ WifiNetwork = baseclass.extend(/** @lends LuCI.network.WifiNetwork.prototype */ * HT or VHT rates. * * @property {number} [40mhz] - * Specifies whether the tranmission rate used 40MHz wide channel. + * Specifies whether the transmission rate used 40MHz wide channel. * Only applicable to HT or VHT rates. * * Note: this option exists for backwards compatibility only and its @@ -4329,18 +4321,18 @@ WifiNetwork = baseclass.extend(/** @lends LuCI.network.WifiNetwork.prototype */ * * @returns {LuCI.network.Device} * Returns a `Network.Device` instance representing the Linux network - * device associted with this wireless network. + * device associated with this wireless network. */ getDevice: function() { return Network.prototype.instantiateDevice(this.getIfname()); }, /** - * Check whether this wifi network supports deauthenticating clients. + * Check whether this wifi network supports de-authenticating clients. * * @returns {boolean} * Returns `true` when this wifi network instance supports forcibly - * deauthenticating clients, otherwise `false`. + * de-authenticating clients, otherwise `false`. */ isClientDisconnectSupported: function() { return L.isObject(this.ubus('hostapd', 'del_client')); @@ -4353,7 +4345,7 @@ WifiNetwork = baseclass.extend(/** @lends LuCI.network.WifiNetwork.prototype */ * The MAC address of the client to disconnect. * * @param {boolean} [deauth=false] - * Specifies whether to deauthenticate (`true`) or disassociate (`false`) + * Specifies whether to de-authenticate (`true`) or disassociate (`false`) * the client. * * @param {number} [reason=1] @@ -4363,7 +4355,7 @@ WifiNetwork = baseclass.extend(/** @lends LuCI.network.WifiNetwork.prototype */ * @param {number} [ban_time=0] * Specifies the amount of milliseconds to ban the client from * reconnecting. By default, no ban time is set which allows the client - * to reassociate / reauthenticate immediately. + * to re-associate / reauthenticate immediately. * * @returns {Promise<number>} * Returns a promise resolving to the underlying ubus call result code diff --git a/modules/luci-base/htdocs/luci-static/resources/rpc.js b/modules/luci-base/htdocs/luci-static/resources/rpc.js index f37f7bb6a4..8010066594 100644 --- a/modules/luci-base/htdocs/luci-static/resources/rpc.js +++ b/modules/luci-base/htdocs/luci-static/resources/rpc.js @@ -186,7 +186,7 @@ return baseclass.extend(/** @lends LuCI.rpc.prototype */ { * the corresponding args object sent to the remote procedure will be * `{ foo: true, bar: false }`. * - `params: [ "test" ], filter: function(reply, args, extra) { ... }` - - * When the resultung generated function is invoked with + * When the resulting generated function is invoked with * `fn("foo", "bar", "baz")` then `{ "test": "foo" }` will be sent as * argument to the remote procedure and the filter function will be * invoked with `filterFn(reply, [ "foo" ], "bar", "baz")` @@ -226,7 +226,7 @@ return baseclass.extend(/** @lends LuCI.rpc.prototype */ { * be returned as default instead. * * @property {LuCI.rpc~filterFn} [filter] - * Specfies an optional filter function which is invoked to transform the + * Specifies an optional filter function which is invoked to transform the * received reply data before it is returned to the caller. * * @property {boolean} [reject=false] @@ -379,7 +379,7 @@ return baseclass.extend(/** @lends LuCI.rpc.prototype */ { /** * Set the RPC base URL to use. * - * @param {string} sid + * @param {string} url * Sets the RPC URL endpoint to issue requests against. */ setBaseURL: function(url) { @@ -454,7 +454,7 @@ return baseclass.extend(/** @lends LuCI.rpc.prototype */ { * Registers a new interceptor function. * * @param {LuCI.rpc~interceptorFn} interceptorFn - * The inteceptor function to register. + * The interceptor function to register. * * @returns {LuCI.rpc~interceptorFn} * Returns the given function value. @@ -469,7 +469,7 @@ return baseclass.extend(/** @lends LuCI.rpc.prototype */ { * Removes a registered interceptor function. * * @param {LuCI.rpc~interceptorFn} interceptorFn - * The inteceptor function to remove. + * The interceptor function to remove. * * @returns {boolean} * Returns `true` if the given function has been removed or `false` diff --git a/modules/luci-base/htdocs/luci-static/resources/tools/widgets.js b/modules/luci-base/htdocs/luci-static/resources/tools/widgets.js index 10b65be8ec..14948bbf0f 100644 --- a/modules/luci-base/htdocs/luci-static/resources/tools/widgets.js +++ b/modules/luci-base/htdocs/luci-static/resources/tools/widgets.js @@ -187,9 +187,10 @@ var CBIZoneSelect = form.ListValue.extend({ emptyval.setAttribute('data-value', ''); } - L.dom.content(emptyval.querySelector('span'), [ - E('strong', _('Device')), E('span', ' (%s)'.format(_('input'))) - ]); + if (opt[0].allowlocal) + L.dom.content(emptyval.querySelector('span'), [ + E('strong', _('Device')), E('span', ' (%s)'.format(_('input'))) + ]); L.dom.content(anyval.querySelector('span'), [ E('strong', _('Any zone')), E('span', ' (%s)'.format(_('forward'))) @@ -536,7 +537,7 @@ var CBIDeviceSelect = form.ListValue.extend({ } if (!this.nocreate) { - var keys = Object.keys(checked).sort(); + var keys = Object.keys(checked).sort(L.naturalCompare); for (var i = 0; i < keys.length; i++) { if (choices.hasOwnProperty(keys[i])) diff --git a/modules/luci-base/htdocs/luci-static/resources/uci.js b/modules/luci-base/htdocs/luci-static/resources/uci.js index 41e902c5fe..76b274470b 100644 --- a/modules/luci-base/htdocs/luci-static/resources/uci.js +++ b/modules/luci-base/htdocs/luci-static/resources/uci.js @@ -2,6 +2,14 @@ 'require rpc'; 'require baseclass'; +function isEmpty(object, ignore) { + for (var property in object) + if (object.hasOwnProperty(property) && property != ignore) + return false; + + return true; +} + /** * @class uci * @memberof LuCI @@ -10,7 +18,7 @@ * * The `LuCI.uci` class utilizes {@link LuCI.rpc} to declare low level * remote UCI `ubus` procedures and implements a local caching and data - * manipulation layer on top to allow for synchroneous operations on + * manipulation layer on top to allow for synchronous operations on * UCI configuration data. */ return baseclass.extend(/** @lends LuCI.uci.prototype */ { @@ -85,7 +93,7 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * identifier in the form `cfgXXXXXX` once the configuration is saved * by the remote `ubus` UCI api. * - * @param {string} config + * @param {string} conf * The configuration to generate the new section ID for. * * @returns {string} @@ -108,7 +116,7 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * Resolves a given section ID in extended notation to the internal * section ID value. * - * @param {string} config + * @param {string} conf * The configuration to resolve the section ID for. * * @param {string} sid @@ -201,7 +209,7 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * To force reloading a configuration, it has to be unloaded with * {@link LuCI.uci#unload uci.unload()} first. * - * @param {string|string[]} config + * @param {string|string[]} packages * The name of the configuration or an array of configuration * names to load. * @@ -237,7 +245,7 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { /** * Unloads the given UCI configurations from the local cache. * - * @param {string|string[]} config + * @param {string|string[]} packages * The name of the configuration or an array of configuration * names to unload. */ @@ -259,7 +267,7 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * Adds a new section of the given type to the given configuration, * optionally named according to the given name. * - * @param {string} config + * @param {string} conf * The name of the configuration to add the section to. * * @param {string} type @@ -294,7 +302,7 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { /** * Removes the section with the given ID from the given configuration. * - * @param {string} config + * @param {string} conf * The name of the configuration to remove the section from. * * @param {string} sid @@ -326,7 +334,7 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * enclosed within a configuration section, as well as some additional * meta data such as sort indexes and internal ID. * - * Any internal metadata fields are prefixed with a dot which is isn't + * Any internal metadata fields are prefixed with a dot which isn't * an allowed character for normal option names. * * @typedef {Object<string, boolean|number|string|string[]>} SectionObject @@ -337,7 +345,7 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * anonymous (`true`) or named (`false`). * * @property {number} .index - * The `.index` property specifes the sort order of the section. + * The `.index` property specifies the sort order of the section. * * @property {string} .name * The `.name` property holds the name of the section object. It may be @@ -375,7 +383,7 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * Enumerates the sections of the given configuration, optionally * filtered by type. * - * @param {string} config + * @param {string} conf * The name of the configuration to enumerate the sections for. * * @param {string} [type] @@ -428,13 +436,13 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * of the given configuration or the entire section object if the * option name is omitted. * - * @param {string} config + * @param {string} conf * The name of the configuration to read the value from. * * @param {string} sid * The name or ID of the section to read. * - * @param {string} [option] + * @param {string} [opt] * The option name to read the value from. If the option name is * omitted or `null`, the entire section is returned instead. * @@ -522,16 +530,16 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * If either config, section or option is null, or if `option` begins * with a dot, the function will do nothing. * - * @param {string} config + * @param {string} conf * The name of the configuration to set the option value in. * * @param {string} sid * The name or ID of the section to set the option value in. * - * @param {string} option + * @param {string} opt * The option name to set the value for. * - * @param {null|string|string[]} value + * @param {null|string|string[]} val * The option value to set. If the value is `null` or an empty string, * the option will be removed, otherwise it will be set or overwritten * with the given value. @@ -570,16 +578,7 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { /* undelete option */ if (d[conf] && d[conf][sid]) { - var empty = true; - - for (var key in d[conf][sid]) { - if (key != opt && d[conf][sid].hasOwnProperty(key)) { - empty = false; - break; - } - } - - if (empty) + if (isEmpty(d[conf][sid], opt)) delete d[conf][sid]; else delete d[conf][sid][opt]; @@ -589,8 +588,12 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { } else { /* revert any change for to-be-deleted option */ - if (c[conf] && c[conf][sid]) - delete c[conf][sid][opt]; + if (c[conf] && c[conf][sid]) { + if (isEmpty(c[conf][sid], opt)) + delete c[conf][sid]; + else + delete c[conf][sid][opt]; + } /* only delete existing options */ if (v[conf] && v[conf][sid] && v[conf][sid].hasOwnProperty(opt)) { @@ -613,13 +616,13 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * This function is a convenience wrapper around * `uci.set(config, section, option, null)`. * - * @param {string} config + * @param {string} conf * The name of the configuration to remove the option from. * * @param {string} sid * The name or ID of the section to remove the option from. * - * @param {string} option + * @param {string} opt * The name of the option to remove. */ unset: function(conf, sid, opt) { @@ -629,9 +632,9 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { /** * Gets the value of the given option or the entire section object of * the first found section of the specified type or the first found - * section of the entire configuration if no type is specfied. + * section of the entire configuration if no type is specified. * - * @param {string} config + * @param {string} conf * The name of the configuration to read the value from. * * @param {string} [type] @@ -639,7 +642,7 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * section of the entire config is read, otherwise the first section * matching the given type. * - * @param {string} [option] + * @param {string} [opt] * The option name to read the value from. If the option name is * omitted or `null`, the entire section is returned instead. * @@ -672,7 +675,7 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * If either config, type or option is null, or if `option` begins * with a dot, the function will do nothing. * - * @param {string} config + * @param {string} conf * The name of the configuration to set the option value in. * * @param {string} [type] @@ -680,10 +683,10 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * section of the entire config is written to, otherwise the first * section matching the given type is used. * - * @param {string} option + * @param {string} opt * The option name to set the value for. * - * @param {null|string|string[]} value + * @param {null|string|string[]} val * The option value to set. If the value is `null` or an empty string, * the option will be removed, otherwise it will be set or overwritten * with the given value. @@ -707,7 +710,7 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * This function is a convenience wrapper around * `uci.set_first(config, type, option, null)`. * - * @param {string} config + * @param {string} conf * The name of the configuration to set the option value in. * * @param {string} [type] @@ -715,7 +718,7 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * section of the entire config is written to, otherwise the first * section matching the given type is used. * - * @param {string} option + * @param {string} opt * The option name to set the value for. */ unset_first: function(conf, type, opt) { @@ -726,7 +729,7 @@ return baseclass.extend(/** @lends LuCI.uci.prototype */ { * Move the first specified section within the given configuration * before or after the second specified section. * - * @param {string} config + * @param {string} conf * The configuration to move the section within. * * @param {string} sid1 diff --git a/modules/luci-base/htdocs/luci-static/resources/ui.js b/modules/luci-base/htdocs/luci-static/resources/ui.js index ac158f5260..5502dfed25 100644 --- a/modules/luci-base/htdocs/luci-static/resources/ui.js +++ b/modules/luci-base/htdocs/luci-static/resources/ui.js @@ -26,7 +26,7 @@ var modalDiv = null, * events. * * UI widget instances are usually not supposed to be created by view code - * directly, instead they're implicitely created by `LuCI.form` when + * directly, instead they're implicitly created by `LuCI.form` when * instantiating CBI forms. * * This class is automatically instantiated as part of `LuCI.ui`. To use it @@ -134,7 +134,7 @@ var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */ * @memberof LuCI.ui.AbstractElement * @returns {boolean} * Returns `true` if the input value has been altered by the user or - * `false` if it is unchaged. Note that if the user modifies the initial + * `false` if it is unchanged. Note that if the user modifies the initial * value and changes it back to the original state, it is still reported * as changed. */ @@ -316,7 +316,7 @@ var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */ * The `Textfield` class implements a standard single line text input field. * * UI widget instances are usually not supposed to be created by view code - * directly, instead they're implicitely created by `LuCI.form` when + * directly, instead they're implicitly created by `LuCI.form` when * instantiating CBI forms. * * This class is automatically instantiated as part of `LuCI.ui`. To use it @@ -374,6 +374,7 @@ var UITextfield = UIElement.extend(/** @lends LuCI.ui.Textfield.prototype */ { 'disabled': this.options.disabled ? '' : null, 'maxlength': this.options.maxlength, 'placeholder': this.options.placeholder, + 'autocomplete': this.options.password ? 'new-password' : null, 'value': this.value, }); @@ -440,7 +441,7 @@ var UITextfield = UIElement.extend(/** @lends LuCI.ui.Textfield.prototype */ { * The `Textarea` class implements a multiline text area input field. * * UI widget instances are usually not supposed to be created by view code - * directly, instead they're implicitely created by `LuCI.form` when + * directly, instead they're implicitly created by `LuCI.form` when * instantiating CBI forms. * * This class is automatically instantiated as part of `LuCI.ui`. To use it @@ -556,7 +557,7 @@ var UITextarea = UIElement.extend(/** @lends LuCI.ui.Textarea.prototype */ { * The `Checkbox` class implements a simple checkbox input field. * * UI widget instances are usually not supposed to be created by view code - * directly, instead they're implicitely created by `LuCI.form` when + * directly, instead they're implicitly created by `LuCI.form` when * instantiating CBI forms. * * This class is automatically instantiated as part of `LuCI.ui`. To use it @@ -695,7 +696,7 @@ var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ { * values are enabled or not. * * UI widget instances are usually not supposed to be created by view code - * directly, instead they're implicitely created by `LuCI.form` when + * directly, instead they're implicitly created by `LuCI.form` when * instantiating CBI forms. * * This class is automatically instantiated as part of `LuCI.ui`. To use it @@ -725,7 +726,7 @@ var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ { * @property {boolean} [multiple=false] * Specifies whether multiple choice values may be selected. * - * @property {string} [widget=select] + * @property {"select"|"individual"} [widget=select] * Specifies the kind of widget to render. May be either `select` or * `individual`. When set to `select` an HTML `<select>` element will be * used, otherwise a group of checkbox or radio button elements is created, @@ -777,7 +778,7 @@ var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ { keys = Object.keys(this.choices); if (this.options.sort === true) - keys.sort(); + keys.sort(L.naturalCompare); else if (Array.isArray(this.options.sort)) keys = this.options.sort; @@ -903,7 +904,7 @@ var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ { * supports non-text choice labels. * * UI widget instances are usually not supposed to be created by view code - * directly, instead they're implicitely created by `LuCI.form` when + * directly, instead they're implicitly created by `LuCI.form` when * instantiating CBI forms. * * This class is automatically instantiated as part of `LuCI.ui`. To use it @@ -1050,13 +1051,14 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ { 'class': 'cbi-dropdown', 'multiple': this.options.multiple ? '' : null, 'optional': this.options.optional ? '' : null, - 'disabled': this.options.disabled ? '' : null + 'disabled': this.options.disabled ? '' : null, + 'tabindex': -1 }, E('ul')); var keys = Object.keys(this.choices); if (this.options.sort === true) - keys.sort(); + keys.sort(L.naturalCompare); else if (Array.isArray(this.options.sort)) keys = this.options.sort; @@ -1186,11 +1188,11 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ { } else { sb.addEventListener('mouseover', this.handleMouseover.bind(this)); + sb.addEventListener('mouseout', this.handleMouseout.bind(this)); sb.addEventListener('focus', this.handleFocus.bind(this)); canary.addEventListener('focus', this.handleCanaryFocus.bind(this)); - window.addEventListener('mouseover', this.setFocus); window.addEventListener('click', this.closeAllDropdowns); } @@ -1343,7 +1345,12 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ { sb.lastElementChild.setAttribute('tabindex', 0); - this.setFocus(sb, sel || li[0], true); + var focusFn = L.bind(function(el) { + this.setFocus(sb, el, true); + ul.removeEventListener('transitionend', focusFn); + }, this, sel || li[0]); + + ul.addEventListener('transitionend', focusFn); }, /** @private */ @@ -1559,26 +1566,33 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ { /** @private */ setFocus: function(sb, elem, scroll) { - if (sb && sb.hasAttribute && sb.hasAttribute('locked-in')) + if (sb.hasAttribute('locked-in')) return; - if (sb.target && findParent(sb.target, 'ul.dropdown')) + sb.querySelectorAll('.focus').forEach(function(e) { + e.classList.remove('focus'); + }); + + elem.classList.add('focus'); + + if (scroll) + elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop; + + elem.focus(); + }, + + /** @private */ + handleMouseout: function(ev) { + var sb = ev.currentTarget; + + if (!sb.hasAttribute('open')) return; - document.querySelectorAll('.focus').forEach(function(e) { - if (!matchesElem(e, 'input')) { - e.classList.remove('focus'); - e.blur(); - } + sb.querySelectorAll('.focus').forEach(function(e) { + e.classList.remove('focus'); }); - if (elem) { - elem.focus(); - elem.classList.add('focus'); - - if (scroll) - elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop; - } + sb.querySelector('ul.dropdown').focus(); }, /** @private */ @@ -1758,7 +1772,8 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ { /** @private */ handleKeydown: function(ev) { - var sb = ev.currentTarget; + var sb = ev.currentTarget, + ul = sb.querySelector('ul.dropdown'); if (matchesElem(ev.target, 'input')) return; @@ -1779,6 +1794,7 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ { switch (ev.keyCode) { case 27: this.closeDropdown(sb); + ev.stopPropagation(); break; case 13: @@ -1802,6 +1818,10 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ { this.setFocus(sb, active.previousElementSibling); ev.preventDefault(); } + else if (document.activeElement === ul) { + this.setFocus(sb, ul.lastElementChild); + ev.preventDefault(); + } break; case 40: @@ -1809,6 +1829,10 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ { this.setFocus(sb, active.nextElementSibling); ev.preventDefault(); } + else if (document.activeElement === ul) { + this.setFocus(sb, ul.firstElementChild); + ev.preventDefault(); + } break; } } @@ -1964,7 +1988,7 @@ var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ { * with a set of enforced default properties for easier instantiation. * * UI widget instances are usually not supposed to be created by view code - * directly, instead they're implicitely created by `LuCI.form` when + * directly, instead they're implicitly created by `LuCI.form` when * instantiating CBI forms. * * This class is automatically instantiated as part of `LuCI.ui`. To use it @@ -2031,7 +2055,7 @@ var UICombobox = UIDropdown.extend(/** @lends LuCI.ui.Combobox.prototype */ { * into a dropdown to chose from a set of different action choices. * * UI widget instances are usually not supposed to be created by view code - * directly, instead they're implicitely created by `LuCI.form` when + * directly, instead they're implicitly created by `LuCI.form` when * instantiating CBI forms. * * This class is automatically instantiated as part of `LuCI.ui`. To use it @@ -2054,7 +2078,7 @@ var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype * /** * ComboButtons support the same properties as * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce - * specific values for some properties and add aditional button specific + * specific values for some properties and add additional button specific * properties. * * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions @@ -2148,7 +2172,7 @@ var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype * * from a set of predefined choices. * * UI widget instances are usually not supposed to be created by view code - * directly, instead they're implicitely created by `LuCI.form` when + * directly, instead they're implicitly created by `LuCI.form` when * instantiating CBI forms. * * This class is automatically instantiated as part of `LuCI.ui`. To use it @@ -2171,7 +2195,7 @@ var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype * */ var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ { /** - * In case choices are passed to the dynamic list contructor, the widget + * In case choices are passed to the dynamic list constructor, the widget * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} * but enforces specific values for some dropdown properties. * @@ -2208,7 +2232,7 @@ var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ 'id': this.options.id, 'class': 'cbi-dynlist', 'disabled': this.options.disabled ? '' : null - }, E('div', { 'class': 'add-item' })); + }, E('div', { 'class': 'add-item control-group' })); if (this.choices) { if (this.options.placeholder != null) @@ -2503,7 +2527,7 @@ var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ * which allows to store form data without exposing it to the user. * * UI widget instances are usually not supposed to be created by view code - * directly, instead they're implicitely created by `LuCI.form` when + * directly, instead they're implicitly created by `LuCI.form` when * instantiating CBI forms. * * This class is automatically instantiated as part of `LuCI.ui`. To use it @@ -2569,7 +2593,7 @@ var UIHiddenfield = UIElement.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ * browse, select and delete files beneath a predefined remote directory. * * UI widget instances are usually not supposed to be created by view code - * directly, instead they're implicitely created by `LuCI.form` when + * directly, instead they're implicitly created by `LuCI.form` when * instantiating CBI forms. * * This class is automatically instantiated as part of `LuCI.ui`. To use it @@ -2614,9 +2638,9 @@ var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ { * * @property {string} [root_directory=/etc/luci-uploads] * Specifies the remote directory the upload and file browsing actions take - * place in. Browsing to directories outside of the root directory is + * place in. Browsing to directories outside the root directory is * prevented by the widget. Note that this is not a security feature. - * Whether remote directories are browseable or not solely depends on the + * Whether remote directories are browsable or not solely depends on the * ACL setup for the current session. */ __init__: function(value, options) { @@ -2859,13 +2883,8 @@ var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ { 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; + return L.naturalCompare(a.type == 'directory', b.type == 'directory') || + L.naturalCompare(a.name, b.name); }); for (var i = 0; i < list.length; i++) { @@ -3066,7 +3085,7 @@ var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ { * @property {string} name - The internal name of the node, as used in the URL * @property {number} order - The sort index of the menu node * @property {string} [title] - The title of the menu node, `null` if the node should be hidden - * @property {satisified} boolean - Boolean indicating whether the menu enries dependencies are satisfied + * @property {satisfied} boolean - Boolean indicating whether the menu entries dependencies are satisfied * @property {readonly} [boolean] - Boolean indicating whether the menu entries underlying ACLs are readonly * @property {LuCI.ui.menu.MenuNode[]} [children] - Array of child menu nodes. */ @@ -3125,7 +3144,24 @@ var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ { if (!node.children[k].hasOwnProperty('title')) continue; - children.push(Object.assign(node.children[k], { name: k })); + var subnode = Object.assign(node.children[k], { name: k }); + + if (L.isObject(subnode.action) && subnode.action.path != null && + (subnode.action.type == 'alias' || subnode.action.type == 'rewrite')) { + var root = this.menu, + path = subnode.action.path.split('/'); + + for (var i = 0; root != null && i < path.length; i++) + root = L.isObject(root.children) ? root.children[path[i]] : null; + + if (root) + subnode = Object.assign({}, subnode, { + children: root.children, + action: root.action + }); + } + + children.push(subnode); } return children.sort(function(a, b) { @@ -3135,11 +3171,316 @@ var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ { if (wA != wB) return wA - wB; - return a.name > b.name; + return L.naturalCompare(a.name, b.name); }); } }); +var UITable = baseclass.extend(/** @lends LuCI.ui.table.prototype */ { + __init__: function(captions, options, placeholder) { + if (!Array.isArray(captions)) { + this.initFromMarkup(captions); + + return; + } + + var id = options.id || 'table%08x'.format(Math.random() * 0xffffffff); + + var table = E('table', { 'id': id, 'class': 'table' }, [ + E('tr', { 'class': 'tr table-titles', 'click': UI.prototype.createHandlerFn(this, 'handleSort') }) + ]); + + this.id = id; + this.node = table + this.options = options; + + var sorting = this.getActiveSortState(); + + for (var i = 0; i < captions.length; i++) { + if (captions[i] == null) + continue; + + var th = E('th', { 'class': 'th' }, [ captions[i] ]); + + if (typeof(options.captionClasses) == 'object') + DOMTokenList.prototype.add.apply(th.classList, L.toArray(options.captionClasses[i])); + + if (options.sortable !== false && (typeof(options.sortable) != 'object' || options.sortable[i] !== false)) { + th.setAttribute('data-sortable-row', true); + + if (sorting && sorting[0] == i) + th.setAttribute('data-sort-direction', sorting[1] ? 'desc' : 'asc'); + } + + table.firstElementChild.appendChild(th); + } + + if (placeholder) { + var trow = table.appendChild(E('tr', { 'class': 'tr placeholder' })), + td = trow.appendChild(E('td', { 'class': 'td' }, placeholder)); + + if (typeof(captionClasses) == 'object') + DOMTokenList.prototype.add.apply(td.classList, L.toArray(captionClasses[0])); + } + + DOMTokenList.prototype.add.apply(table.classList, L.toArray(options.classes)); + }, + + update: function(data, placeholder) { + var placeholder = placeholder || this.options.placeholder || _('No data', 'empty table placeholder'), + sorting = this.getActiveSortState(); + + if (!Array.isArray(data)) + return; + + this.data = data; + this.placeholder = placeholder; + + var n = 0, + rows = this.node.querySelectorAll('tr, .tr'), + trows = [], + headings = [].slice.call(this.node.firstElementChild.querySelectorAll('th, .th')), + captionClasses = this.options.captionClasses, + trTag = (rows[0] && rows[0].nodeName == 'DIV') ? 'div' : 'tr', + tdTag = (headings[0] && headings[0].nodeName == 'DIV') ? 'div' : 'td'; + + if (sorting) { + var list = data.map(L.bind(function(row) { + return [ this.deriveSortKey(row[sorting[0]], sorting[0]), row ]; + }, this)); + + list.sort(function(a, b) { + return sorting[1] + ? -L.naturalCompare(a[0], b[0]) + : L.naturalCompare(a[0], b[0]); + }); + + data.length = 0; + + list.forEach(function(item) { + data.push(item[1]); + }); + + headings.forEach(function(th, i) { + if (i == sorting[0]) + th.setAttribute('data-sort-direction', sorting[1] ? 'desc' : 'asc'); + else + th.removeAttribute('data-sort-direction'); + }); + } + + data.forEach(function(row) { + trows[n] = E(trTag, { 'class': 'tr' }); + + for (var i = 0; i < headings.length; i++) { + var text = (headings[i].innerText || '').trim(); + var raw_val = Array.isArray(row[i]) ? row[i][0] : null; + var disp_val = Array.isArray(row[i]) ? row[i][1] : row[i]; + var td = trows[n].appendChild(E(tdTag, { + 'class': 'td', + 'data-title': (text !== '') ? text : null, + 'data-value': raw_val + }, (disp_val != null) ? ((disp_val instanceof DocumentFragment) ? disp_val.cloneNode(true) : disp_val) : '')); + + if (typeof(captionClasses) == 'object') + DOMTokenList.prototype.add.apply(td.classList, L.toArray(captionClasses[i])); + + if (!td.classList.contains('cbi-section-actions')) + headings[i].setAttribute('data-sortable-row', true); + } + + trows[n].classList.add('cbi-rowstyle-%d'.format((n++ % 2) ? 2 : 1)); + }); + + for (var i = 0; i < n; i++) { + if (rows[i+1]) + this.node.replaceChild(trows[i], rows[i+1]); + else + this.node.appendChild(trows[i]); + } + + while (rows[++n]) + this.node.removeChild(rows[n]); + + if (placeholder && this.node.firstElementChild === this.node.lastElementChild) { + var trow = this.node.appendChild(E(trTag, { 'class': 'tr placeholder' })), + td = trow.appendChild(E(tdTag, { 'class': 'td' }, placeholder)); + + if (typeof(captionClasses) == 'object') + DOMTokenList.prototype.add.apply(td.classList, L.toArray(captionClasses[0])); + } + + return this.node; + }, + + render: function() { + return this.node; + }, + + /** @private */ + initFromMarkup: function(node) { + if (!dom.elem(node)) + node = document.querySelector(node); + + if (!node) + throw 'Invalid table selector'; + + var options = {}, + headrow = node.querySelector('tr, .tr'); + + if (!headrow) + return; + + options.id = node.id; + options.classes = [].slice.call(node.classList).filter(function(c) { return c != 'table' }); + options.sortable = []; + options.captionClasses = []; + + headrow.querySelectorAll('th, .th').forEach(function(th, i) { + options.sortable[i] = !th.classList.contains('cbi-section-actions'); + options.captionClasses[i] = [].slice.call(th.classList).filter(function(c) { return c != 'th' }); + }); + + headrow.addEventListener('click', UI.prototype.createHandlerFn(this, 'handleSort')); + + this.id = node.id; + this.node = node; + this.options = options; + }, + + /** @private */ + deriveSortKey: function(value, index) { + var opts = this.options || {}, + hint, m; + + if (opts.sortable == true || opts.sortable == null) + hint = 'auto'; + else if (typeof( opts.sortable) == 'object') + hint = opts.sortable[index]; + + if (dom.elem(value)) { + if (value.hasAttribute('data-value')) + value = value.getAttribute('data-value'); + else + value = (value.innerText || '').trim(); + } + + switch (hint || 'auto') { + case true: + case 'auto': + m = /^([0-9a-fA-F:.]+)(?:\/([0-9a-fA-F:.]+))?$/.exec(value); + + if (m) { + var addr, mask; + + addr = validation.parseIPv6(m[1]); + mask = m[2] ? validation.parseIPv6(m[2]) : null; + + if (addr && mask != null) + return '%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x'.format( + addr[0], addr[1], addr[2], addr[3], addr[4], addr[5], addr[6], addr[7], + mask[0], mask[1], mask[2], mask[3], mask[4], mask[5], mask[6], mask[7] + ); + else if (addr) + return '%04x%04x%04x%04x%04x%04x%04x%04x%02x'.format( + addr[0], addr[1], addr[2], addr[3], addr[4], addr[5], addr[6], addr[7], + m[2] ? +m[2] : 128 + ); + + addr = validation.parseIPv4(m[1]); + mask = m[2] ? validation.parseIPv4(m[2]) : null; + + if (addr && mask != null) + return '%03d%03d%03d%03d%03d%03d%03d%03d'.format( + addr[0], addr[1], addr[2], addr[3], + mask[0], mask[1], mask[2], mask[3] + ); + else if (addr) + return '%03d%03d%03d%03d%02d'.format( + addr[0], addr[1], addr[2], addr[3], + m[2] ? +m[2] : 32 + ); + } + + m = /^(?:(\d+)d )?(\d+)h (\d+)m (\d+)s$/.exec(value); + + if (m) + return '%05d%02d%02d%02d'.format(+m[1], +m[2], +m[3], +m[4]); + + m = /^(\d+)\b(\D*)$/.exec(value); + + if (m) + return '%010d%s'.format(+m[1], m[2]); + + return String(value); + + case 'ignorecase': + return String(value).toLowerCase(); + + case 'numeric': + return +value; + + default: + return String(value); + } + }, + + /** @private */ + getActiveSortState: function() { + if (this.sortState) + return this.sortState; + + if (!this.options.id) + return null; + + var page = document.body.getAttribute('data-page'), + key = page + '.' + this.options.id, + state = session.getLocalData('tablesort'); + + if (L.isObject(state) && Array.isArray(state[key])) + return state[key]; + + return null; + }, + + /** @private */ + setActiveSortState: function(index, descending) { + this.sortState = [ index, descending ]; + + if (!this.options.id) + return; + + var page = document.body.getAttribute('data-page'), + key = page + '.' + this.options.id, + state = session.getLocalData('tablesort'); + + if (!L.isObject(state)) + state = {}; + + state[key] = this.sortState; + + session.setLocalData('tablesort', state); + }, + + /** @private */ + handleSort: function(ev) { + if (!ev.target.matches('th[data-sortable-row]')) + return; + + var index, direction; + + this.node.firstElementChild.querySelectorAll('th, .th').forEach(function(th, i) { + if (th === ev.target) { + index = i; + direction = th.getAttribute('data-sort-direction') == 'asc'; + } + }); + + this.setActiveSortState(index, direction); + this.update(this.data, this.placeholder); + } +}); + /** * @class ui * @memberof LuCI @@ -3153,8 +3494,17 @@ var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ { var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { __init__: function() { modalDiv = document.body.appendChild( - dom.create('div', { id: 'modal_overlay' }, - dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true }))); + dom.create('div', { + id: 'modal_overlay', + tabindex: -1, + keydown: this.cancelModal + }, [ + dom.create('div', { + class: 'modal', + role: 'dialog', + 'aria-modal': true + }) + ])); tooltipDiv = document.body.appendChild( dom.create('div', { class: 'cbi-tooltip' })); @@ -3184,7 +3534,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { * be opened. Invoking showModal() while a modal dialog is already open will * replace the open dialog with a new one having the specified contents. * - * Additional CSS class names may be passed to influence the appearence of + * Additional CSS class names may be passed to influence the appearance of * the dialog. Valid values for the classes depend on the underlying theme. * * @see LuCI.dom.content @@ -3192,7 +3542,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { * @param {string} [title] * The title of the dialog. If `null`, no title element will be rendered. * - * @param {*} contents + * @param {*} children * The contents to add to the modal dialog. This should be a DOM node or * a document fragment in most cases. The value is passed as-is to the * `dom.content()` function - refer to its documentation for applicable @@ -3218,6 +3568,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { document.body.classList.add('modal-overlay-active'); modalDiv.scrollTop = 0; + modalDiv.focus(); return dlg; }, @@ -3234,6 +3585,17 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { */ hideModal: function() { document.body.classList.remove('modal-overlay-active'); + modalDiv.blur(); + }, + + /** @private */ + cancelModal: function(ev) { + if (ev.key == 'Escape') { + var btn = modalDiv.querySelector('.right > button, .right > .btn'); + + if (btn) + btn.click(); + } }, /** @private */ @@ -3304,11 +3666,11 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { * Add a notification banner at the top of the current view. * * A notification banner is an alert message usually displayed at the - * top of the current view, spanning the entire availibe width. + * top of the current view, spanning the entire available width. * Notification banners will stay in place until dismissed by the user. * Multiple banners may be shown at the same time. * - * Additional CSS class names may be passed to influence the appearence of + * Additional CSS class names may be passed to influence the appearance of * the banner. Valid values for the classes depend on the underlying theme. * * @see LuCI.dom.content @@ -3317,7 +3679,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { * The title of the notification banner. If `null`, no title element * will be rendered. * - * @param {*} contents + * @param {*} children * The contents to add to the notification banner. This should be a DOM * node or a document fragment in most cases. The value is passed as-is * to the `dom.content()` function - refer to its documentation for @@ -3368,7 +3730,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { }, /** - * Display or update an header area indicator. + * Display or update a header area indicator. * * An indicator is a small label displayed in the header area of the screen * providing few amounts of status information such as item counts or state @@ -3395,7 +3757,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { * Note that this parameter only applies to new indicators, when updating * existing labels it is ignored. * - * @param {string} [style=active] + * @param {"active"|"inactive"} [style=active] * The indicator style to use. May be either `active` or `inactive`. * * @returns {boolean} @@ -3438,7 +3800,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { }, /** - * Remove an header area indicator. + * Remove a header area indicator. * * This function removes the given indicator label from the header indicator * area. When the given indicator is not found, this function does nothing. @@ -3472,7 +3834,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { * subsequently wrapped into a `<span class="nowrap">` element. * * The resulting `<span>` element tuples are joined by the given separators - * to form the final markup which is appened to the given parent DOM node. + * to form the final markup which is appended to the given parent DOM node. * * @param {Node} node * The parent DOM node to append the markup to. Any previous child elements @@ -3799,7 +4161,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { * @param {string} path * The remote file path to upload the local file to. * - * @param {Node} [progessStatusNode] + * @param {Node} [progressStatusNode] * An optional DOM text node whose content text is set to the progress * percentage value during file upload. * @@ -3849,7 +4211,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { 'class': 'btn', 'click': function() { UI.prototype.hideModal(); - rejectFn(new Error('Upload has been cancelled')); + rejectFn(new Error(_('Upload has been cancelled'))); } }, [ _('Cancel') ]), ' ', @@ -3913,7 +4275,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { /** * Perform a device connectivity test. * - * Attempt to fetch a well known ressource from the remote device via HTTP + * Attempt to fetch a well known resource from the remote device via HTTP * in order to test connectivity. This function is mainly useful to wait * for the router to come back online after a reboot or reconfiguration. * @@ -3921,7 +4283,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { * The protocol to use for fetching the resource. May be either `http` * (the default) or `https`. * - * @param {string} [host=window.location.host] + * @param {string} [ipaddr=window.location.host] * Override the host address to probe. By default the current host as seen * in the address bar is probed. * @@ -4010,7 +4372,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { * * @instance * @memberof LuCI.ui.changes - * @param {number} numChanges + * @param {number} n * The number of changes to indicate. */ setIndicator: function(n) { @@ -4089,10 +4451,16 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { 'class': 'btn', 'click': UI.prototype.hideModal }, [ _('Close') ]), ' ', - E('button', { - 'class': 'cbi-button cbi-button-positive important', - 'click': L.bind(this.apply, this, true) - }, [ _('Save & Apply') ]), ' ', + new UIComboButton('0', { + 0: [ _('Save & Apply') ], + 1: [ _('Apply unchecked') ] + }, { + classes: { + 0: 'btn cbi-button cbi-button-positive important', + 1: 'btn cbi-button cbi-button-negative important' + }, + click: L.bind(function(ev, mode) { this.apply(mode == '0') }, this) + }).render(), ' ', E('button', { 'class': 'cbi-button cbi-button-reset', 'click': L.bind(this.revert, this) @@ -4162,6 +4530,26 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { }, /** @private */ + checkConnectivityAffected: function() { + return L.resolveDefault(fs.exec_direct('/usr/libexec/luci-peeraddr', null, 'json')).then(L.bind(function(info) { + if (L.isObject(info) && Array.isArray(info.inbound_interfaces)) { + for (var i = 0; i < info.inbound_interfaces.length; i++) { + var iif = info.inbound_interfaces[i]; + + for (var j = 0; this.changes && this.changes.network && j < this.changes.network.length; j++) { + var chg = this.changes.network[j]; + + if (chg[0] == 'set' && chg[1] == iif && (chg[2] == 'proto' || chg[2] == 'ipaddr' || chg[2] == 'netmask')) + return iif; + } + } + } + + return null; + }, this)); + }, + + /** @private */ rollback: function(checked) { if (checked) { this.displayStatus('warning spinning', @@ -4198,7 +4586,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { method: 'post', timeout: L.env.apply_timeout * 1000, query: { sid: L.env.sessionid, token: L.env.token } - }).then(call); + }).then(call, call.bind(null, { status: 0 }, null, 0)); }, delay); }; @@ -4298,35 +4686,65 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { this.displayStatus('notice spinning', E('p', _('Starting configuration apply…'))); - request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), { - method: 'post', - query: { sid: L.env.sessionid, token: L.env.token } - }).then(function(r) { - if (r.status === (checked ? 200 : 204)) { - var tok = null; try { tok = r.json(); } catch(e) {} - if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string') - UI.prototype.changes.confirm_auth = tok; - - UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000); - } - else if (checked && r.status === 204) { - UI.prototype.changes.displayStatus('notice', - E('p', _('There are no changes to apply'))); + (new Promise(function(resolveFn, rejectFn) { + if (!checked) + return resolveFn(false); + + UI.prototype.changes.checkConnectivityAffected().then(function(affected) { + if (!affected) + return resolveFn(true); + + UI.prototype.changes.displayStatus('warning', [ + E('h4', _('Connectivity change')), + E('p', _('The network access to this device could be interrupted by changing settings of the "%h" interface.').format(affected)), + E('p', _('If the IP address used to access LuCI changes, a <strong>manual reconnect to the new IP</strong> is required within %d seconds to confirm the settings, otherwise modifications will be reverted.').format(L.env.apply_rollback)), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn', + 'click': rejectFn, + }, [ _('Cancel') ]), ' ', + E('button', { + 'class': 'btn cbi-button-action important', + 'click': resolveFn.bind(null, true) + }, [ _('Apply with revert after connectivity loss') ]), ' ', + E('button', { + 'class': 'btn cbi-button-negative important', + 'click': resolveFn.bind(null, false) + }, [ _('Apply and keep settings') ]) + ]) + ]); + }); + })).then(function(checked) { + request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), { + method: 'post', + query: { sid: L.env.sessionid, token: L.env.token } + }).then(function(r) { + if (r.status === (checked ? 200 : 204)) { + var tok = null; try { tok = r.json(); } catch(e) {} + if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string') + UI.prototype.changes.confirm_auth = tok; + + UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000); + } + else if (checked && r.status === 204) { + UI.prototype.changes.displayStatus('notice', + E('p', _('There are no changes to apply'))); - window.setTimeout(function() { - UI.prototype.changes.displayStatus(false); - }, L.env.apply_display * 1000); - } - else { - UI.prototype.changes.displayStatus('warning', - E('p', _('Apply request failed with status <code>%h</code>') - .format(r.responseText || r.statusText || r.status))); + window.setTimeout(function() { + UI.prototype.changes.displayStatus(false); + }, L.env.apply_display * 1000); + } + else { + UI.prototype.changes.displayStatus('warning', + E('p', _('Apply request failed with status <code>%h</code>') + .format(r.responseText || r.statusText || r.status))); - window.setTimeout(function() { - UI.prototype.changes.displayStatus(false); - }, L.env.apply_display * 1000); - } - }); + window.setTimeout(function() { + UI.prototype.changes.displayStatus(false); + }, L.env.apply_display * 1000); + } + }); + }, this.displayStatus.bind(this, false)); }, /** @@ -4495,7 +4913,7 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { * * By instantiating the view class, its corresponding contents are * rendered and included into the view area. Any runtime errors are - * catched and rendered using [LuCI.error()]{@link LuCI#error}. + * caught and rendered using [LuCI.error()]{@link LuCI#error}. * * @param {string} path * The view path to render. @@ -4519,6 +4937,8 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { menu: UIMenu, + Table: UITable, + AbstractElement: UIElement, /* Widgets */ diff --git a/modules/luci-base/htdocs/luci-static/resources/validation.js b/modules/luci-base/htdocs/luci-static/resources/validation.js index 6dddf964fb..791a84823d 100644 --- a/modules/luci-base/htdocs/luci-static/resources/validation.js +++ b/modules/luci-base/htdocs/luci-static/resources/validation.js @@ -426,6 +426,15 @@ var ValidatorFactory = baseclass.extend({ return this.assert(this.value.match(/^[a-zA-Z0-9_]+$/), _('valid UCI identifier')); }, + netdevname: function() { + var v = this.value; + + if (v == '.' || v == '..') + return this.assert(false, _('valid network device name, not "." or ".."')); + + return this.assert(v.match(/^[^:\/%\s]{1,15}$/), _('valid network device name between 1 and 15 characters not containing ":", "/", "%" or spaces')); + }, + range: function(min, max) { var val = this.factory.parseDecimal(this.value); return this.assert(val >= +min && val <= +max, _('value between %f and %f').format(min, max)); |