From b839ee87f2df52d5445bd668352adbca6d2c906b Mon Sep 17 00:00:00 2001 From: Jo-Philipp Wich Date: Mon, 1 Apr 2019 16:22:13 +0200 Subject: [PATCH] luci-base: introduce form.js Add a new client side form.js library which is a more or less direct reimplementation of the Lua side cbi.lua in JavaScript. Due to its client side nature, it supports a number of features which would be hard to realize on the server side, such as drag&drop sorting, modal sub-map dialogs, reload-free editing etc. Signed-off-by: Jo-Philipp Wich --- .../htdocs/luci-static/resources/form.js | 1669 +++++++++++++++++ .../htdocs/luci-static/resources/luci.js | 3 +- 2 files changed, 1671 insertions(+), 1 deletion(-) create mode 100644 modules/luci-base/htdocs/luci-static/resources/form.js diff --git a/modules/luci-base/htdocs/luci-static/resources/form.js b/modules/luci-base/htdocs/luci-static/resources/form.js new file mode 100644 index 000000000..835c1fbb6 --- /dev/null +++ b/modules/luci-base/htdocs/luci-static/resources/form.js @@ -0,0 +1,1669 @@ +'use strict'; +'require ui'; +'require uci'; + +var scope = this; + +var CBINode = Class.extend({ + __init__: function(title, description) { + this.title = title || ''; + this.description = description || ''; + this.children = []; + }, + + append: function(obj) { + this.children.push(obj); + }, + + parse: function() { + var args = arguments; + this.children.forEach(function(child) { + child.parse.apply(child, args); + }); + }, + + render: function() { + L.error('InternalError', 'Not implemented'); + }, + + loadChildren: function(/* ... */) { + var tasks = []; + + if (Array.isArray(this.children)) + for (var i = 0; i < this.children.length; i++) + if (!this.children[i].disable) + tasks.push(this.children[i].load.apply(this.children[i], arguments)); + + return Promise.all(tasks); + }, + + renderChildren: function(tab_name /*, ... */) { + var tasks = [], + index = 0; + + if (Array.isArray(this.children)) + for (var i = 0; i < this.children.length; i++) + if (tab_name === null || this.children[i].tab === tab_name) + if (!this.children[i].disable) + tasks.push(this.children[i].render.apply( + this.children[i], this.varargs(arguments, 1, index++))); + + return Promise.all(tasks); + }, + + stripTags: function(s) { + if (!s.match(/[<>]/)) + return s; + + var x = E('div', {}, s); + return x.textContent || x.innerText || ''; + } +}); + +var CBIMap = CBINode.extend({ + __init__: function(config /*, ... */) { + this.super('__init__', this.varargs(arguments, 1)); + + this.config = config; + this.parsechain = [ config ]; + }, + + findElements: function(/* ... */) { + var q = null; + + if (arguments.length == 1) + q = arguments[0]; + else if (arguments.length == 2) + q = '[%s="%s"]'.format(arguments[0], arguments[1]); + else + L.error('InternalError', 'Expecting one or two arguments to findElements()'); + + return this.root.querySelectorAll(q); + }, + + findElement: function(/* ... */) { + var res = this.findElements.apply(this, arguments); + return res.length ? res[0] : null; + }, + + chain: function(config) { + if (this.parsechain.indexOf(config) == -1) + this.parsechain.push(config); + }, + + section: function(cbiClass /*, ... */) { + if (!CBIAbstractSection.isSubclass(cbiClass)) + L.error('TypeError', 'Class must be a descendent of CBIAbstractSection'); + + var obj = cbiClass.instantiate(this.varargs(arguments, 1, this)); + this.append(obj); + return obj; + }, + + load: function() { + return uci.load(this.parsechain || [ this.config ]) + .then(this.loadChildren.bind(this)); + }, + + parse: function() { + var tasks = []; + + if (Array.isArray(this.children)) + for (var i = 0; i < this.children.length; i++) + tasks.push(this.children[i].parse()); + + return Promise.all(tasks); + }, + + save: function() { + return this.parse() + .then(uci.save.bind(uci)) + .then(this.load.bind(this)) + .then(this.renderContents.bind(this)) + .catch(function() { alert('Cannot save due to invalid values') }); + }, + + reset: function() { + return this.renderContents(); + }, + + render: function() { + return this.load().then(this.renderContents.bind(this)); + }, + + renderContents: function() { + var mapEl = this.root || (this.root = E('div', { + 'id': 'cbi-%s'.format(this.config), + 'class': 'cbi-map', + 'cbi-dependency-check': L.bind(this.checkDepends, this) + })); + + L.dom.bindClassInstance(mapEl, this); + + return this.renderChildren(null).then(L.bind(function(nodes) { + var initialRender = !mapEl.firstChild; + + L.dom.content(mapEl, null); + + if (this.title != null && this.title != '') + mapEl.appendChild(E('h2', { 'name': 'content' }, this.title)); + + if (this.description != null && this.description != '') + mapEl.appendChild(E('div', { 'class': 'cbi-map-descr' }, this.description)); + + L.dom.append(mapEl, nodes); + + if (!initialRender) { + mapEl.classList.remove('flash'); + + window.setTimeout(function() { + mapEl.classList.add('flash'); + }, 1); + } + + this.checkDepends(); + + return mapEl; + }, this)); + }, + + lookupOption: function(name, section_id) { + var id, elem, sid, inst; + + if (name.indexOf('.') > -1) + id = 'cbid.%s'.format(name); + else + id = 'cbid.%s.%s.%s'.format(this.config, section_id, name); + + elem = this.findElement('data-field', id); + sid = elem ? id.split(/\./)[2] : null; + inst = elem ? L.dom.findClassInstance(elem) : null; + + return (inst instanceof CBIAbstractValue) ? [ inst, sid ] : null; + }, + + checkDepends: function(ev, n) { + var changed = false; + + for (var i = 0, s = this.children[0]; (s = this.children[i]) != null; i++) + if (s.checkDepends(ev, n)) + changed = true; + + if (changed && (n || 0) < 10) + this.checkDepends(ev, (n || 10) + 1); + } +}); + +var CBIAbstractSection = CBINode.extend({ + __init__: function(map, sectionType /*, ... */) { + this.super('__init__', this.varargs(arguments, 2)); + + this.sectiontype = sectionType; + this.map = map; + this.config = map.config; + + this.optional = true; + this.addremove = false; + this.dynamic = false; + }, + + cfgsections: function() { + L.error('InternalError', 'Not implemented'); + }, + + filter: function(section_id) { + return true; + }, + + load: function() { + var section_ids = this.cfgsections(), + tasks = []; + + if (Array.isArray(this.children)) + for (var i = 0; i < section_ids.length; i++) + tasks.push(this.loadChildren(section_ids[i]) + .then(Function.prototype.bind.call(function(section_id, set_values) { + for (var i = 0; i < set_values.length; i++) + this.children[i].cfgvalue(section_id, set_values[i]); + }, this, section_ids[i]))); + + return Promise.all(tasks); + }, + + parse: function() { + var section_ids = this.cfgsections(), + tasks = []; + + if (Array.isArray(this.children)) + for (var i = 0; i < section_ids.length; i++) + for (var j = 0; j < this.children.length; j++) + tasks.push(this.children[j].parse(section_ids[i])); + + return Promise.all(tasks); + }, + + tab: function(name, title, description) { + if (this.tabs && this.tabs[name]) + throw 'Tab already declared'; + + var entry = { + name: name, + title: title, + description: description, + children: [] + }; + + this.tabs = this.tabs || []; + this.tabs.push(entry); + this.tabs[name] = entry; + + this.tab_names = this.tab_names || []; + this.tab_names.push(name); + }, + + option: function(cbiClass /*, ... */) { + if (!CBIAbstractValue.isSubclass(cbiClass)) + throw L.error('TypeError', 'Class must be a descendent of CBIAbstractValue'); + + var obj = cbiClass.instantiate(this.varargs(arguments, 1, this.map, this)); + this.append(obj); + return obj; + }, + + taboption: function(tabName /*, ... */) { + if (!this.tabs || !this.tabs[tabName]) + throw L.error('ReferenceError', 'Associated tab not declared'); + + var obj = this.option.apply(this, this.varargs(arguments, 1)); + obj.tab = tabName; + this.tabs[tabName].children.push(obj); + return obj; + }, + + renderUCISection: function(section_id) { + var renderTasks = []; + + if (!this.tabs) + return this.renderOptions(null, section_id); + + for (var i = 0; i < this.tab_names.length; i++) + renderTasks.push(this.renderOptions(this.tab_names[i], section_id)); + + return Promise.all(renderTasks) + .then(this.renderTabContainers.bind(this, section_id)); + }, + + renderTabContainers: function(section_id, nodes) { + var config_name = this.uciconfig || this.map.config, + containerEls = E([]); + + for (var i = 0; i < nodes.length; i++) { + var tab_name = this.tab_names[i], + tab_data = this.tabs[tab_name], + containerEl = E('div', { + 'id': 'container.%s.%s.%s'.format(config_name, section_id, tab_name), + 'data-tab': tab_name, + 'data-tab-title': tab_data.title, + 'data-tab-active': tab_name === this.selected_tab + }); + + if (tab_data.description != null && tab_data.description != '') + containerEl.appendChild( + E('div', { 'class': 'cbi-tab-descr' }, tab_data.description)); + + containerEl.appendChild(nodes[i]); + containerEls.appendChild(containerEl); + } + + return containerEls; + }, + + renderOptions: function(tab_name, section_id) { + var in_table = (this instanceof CBITableSection); + return this.renderChildren(tab_name, section_id, in_table).then(function(nodes) { + var optionEls = E([]); + for (var i = 0; i < nodes.length; i++) + optionEls.appendChild(nodes[i]); + return optionEls; + }); + }, + + checkDepends: function(ev, n) { + var changed = false, + sids = this.cfgsections(); + + for (var i = 0, sid = sids[0]; (sid = sids[i]) != null; i++) { + for (var j = 0, o = this.children[0]; (o = this.children[j]) != null; j++) { + var isActive = o.isActive(sid), + isSatisified = o.checkDepends(sid); + + if (isActive != isSatisified) { + o.setActive(sid, !isActive); + changed = true; + } + + if (!n && isActive) + o.triggerValidation(sid); + } + } + + return changed; + } +}); + + +var isEqual = function(x, y) { + if (x != null && y != null && typeof(x) != typeof(y)) + return false; + + if ((x == null && y != null) || (x != null && y == null)) + return false; + + if (Array.isArray(x)) { + if (x.length != y.length) + return false; + + for (var i = 0; i < x.length; i++) + if (!isEqual(x[i], y[i])) + return false; + } + else if (typeof(x) == 'object') { + for (var k in x) { + if (x.hasOwnProperty(k) && !y.hasOwnProperty(k)) + return false; + + if (!isEqual(x[k], y[k])) + return false; + } + + for (var k in y) + if (y.hasOwnProperty(k) && !x.hasOwnProperty(k)) + return false; + } + else if (x != y) { + return false; + } + + return true; +}; + +var CBIAbstractValue = CBINode.extend({ + __init__: function(map, section, option /*, ... */) { + this.super('__init__', this.varargs(arguments, 3)); + + this.section = section; + this.option = option; + this.map = map; + this.config = map.config; + + this.deps = []; + this.initial = {}; + this.rmempty = true; + this.default = null; + this.size = null; + this.optional = false; + }, + + depends: function(field, value) { + var deps; + + if (typeof(field) === 'string') + deps = {}, deps[field] = value; + else + deps = field; + + this.deps.push(deps); + }, + + transformDepList: function(section_id, deplist) { + var list = deplist || this.deps, + deps = []; + + if (Array.isArray(list)) { + for (var i = 0; i < list.length; i++) { + var dep = {}; + + for (var k in list[i]) { + if (list[i].hasOwnProperty(k)) { + if (k.charAt(0) === '!') + dep[k] = list[i][k]; + else if (k.indexOf('.') !== -1) + dep['cbid.%s'.format(k)] = list[i][k]; + else + dep['cbid.%s.%s.%s'.format(this.config, this.ucisection || section_id, k)] = list[i][k]; + } + } + + for (var k in dep) { + if (dep.hasOwnProperty(k)) { + deps.push(dep); + break; + } + } + } + } + + return deps; + }, + + transformChoices: function() { + if (!Array.isArray(this.keylist) || this.keylist.length == 0) + return null; + + var choices = {}; + + for (var i = 0; i < this.keylist.length; i++) + choices[this.keylist[i]] = this.vallist[i]; + + return choices; + }, + + checkDepends: function(section_id) { + var def = false; + + if (!Array.isArray(this.deps) || !this.deps.length) + return true; + + for (var i = 0; i < this.deps.length; i++) { + var istat = true, + reverse = false; + + for (var dep in this.deps[i]) { + if (dep == '!reverse') { + reverse = true; + } + else if (dep == '!default') { + def = true; + istat = false; + } + else { + var res = this.map.lookupOption(dep, section_id), + val = res ? res[0].formvalue(res[1]) : null; + + istat = (istat && isEqual(val, this.deps[i][dep])); + } + } + + if (istat ^ reverse) + return true; + } + + return def; + }, + + cbid: function(section_id) { + if (section_id == null) + L.error('TypeError', 'Section ID required'); + + return 'cbid.%s.%s.%s'.format(this.map.config, section_id, this.option); + }, + + load: function(section_id) { + if (section_id == null) + L.error('TypeError', 'Section ID required'); + + return uci.get( + this.uciconfig || this.map.config, + this.ucisection || section_id, + this.ucioption || this.option); + }, + + cfgvalue: function(section_id, set_value) { + if (section_id == null) + L.error('TypeError', 'Section ID required'); + + if (arguments.length == 2) { + this.data = this.data || {}; + this.data[section_id] = set_value; + } + + return this.data ? this.data[section_id] : null; + }, + + formvalue: function(section_id) { + var node = this.map.findElement('id', this.cbid(section_id)); + return node ? L.dom.callClassMethod(node, 'getValue') : null; + }, + + textvalue: function(section_id) { + var cval = this.cfgvalue(section_id); + + if (cval == null) + cval = this.default; + + return (cval != null) ? '%h'.format(cval) : null; + }, + + validate: function(section_id, value) { + return true; + }, + + isValid: function(section_id) { + var node = this.map.findElement('id', this.cbid(section_id)); + return node ? L.dom.callClassMethod(node, 'isValid') : true; + }, + + isActive: function(section_id) { + var field = this.map.findElement('data-field', this.cbid(section_id)); + return (field != null && !field.classList.contains('hidden')); + }, + + setActive: function(section_id, active) { + var field = this.map.findElement('data-field', this.cbid(section_id)); + + if (field && field.classList.contains('hidden') == active) { + field.classList[active ? 'remove' : 'add']('hidden'); + return true; + } + + return false; + }, + + triggerValidation: function(section_id) { + var node = this.map.findElement('id', this.cbid(section_id)); + return node ? L.dom.callClassMethod(node, 'triggerValidation') : true; + }, + + parse: function(section_id) { + var active = this.isActive(section_id), + cval = this.cfgvalue(section_id), + fval = active ? this.formvalue(section_id) : null; + + if (active && !this.isValid(section_id)) + return Promise.reject(); + + if (fval != '' && fval != null) { + if (this.forcewrite || !isEqual(cval, fval)) + return Promise.resolve(this.write(section_id, fval)); + } + else { + if (this.rmempty || this.optional) { + return Promise.resolve(this.remove(section_id)); + } + else if (!isEqual(cval, fval)) { + console.log('This should have been catched by isValid()'); + return Promise.reject(); + } + } + + return Promise.resolve(); + }, + + write: function(section_id, formvalue) { + return uci.set( + this.uciconfig || this.map.config, + this.ucisection || section_id, + this.ucioption || this.option, + formvalue); + }, + + remove: function(section_id) { + return uci.unset( + this.uciconfig || this.map.config, + this.ucisection || section_id, + this.ucioption || this.option); + } +}); + +var CBITypedSection = CBIAbstractSection.extend({ + __name__: 'CBI.TypedSection', + + cfgsections: function() { + return uci.sections(this.uciconfig || this.map.config, this.sectiontype) + .map(function(s) { return s['.name'] }) + .filter(L.bind(this.filter, this)); + }, + + handleAdd: function(ev, name) { + var config_name = this.uciconfig || this.map.config; + + uci.add(config_name, this.sectiontype, name); + this.map.save(); + }, + + handleRemove: function(section_id, ev) { + var config_name = this.uciconfig || this.map.config; + + uci.remove(config_name, section_id); + this.map.save(); + }, + + renderSectionAdd: function(extra_class) { + if (!this.addremove) + return E([]); + + var createEl = E('div', { 'class': 'cbi-section-create' }), + config_name = this.uciconfig || this.map.config; + + if (extra_class != null) + createEl.classList.add(extra_class); + + if (this.anonymous) { + createEl.appendChild(E('input', { + 'type': 'submit', + 'class': 'cbi-button cbi-button-add', + 'value': _('Add'), + 'title': _('Add'), + 'click': L.bind(this.handleAdd, this) + })); + } + else { + var nameEl = E('input', { + 'type': 'text', + 'class': 'cbi-section-create-name' + }); + + L.dom.append(createEl, [ + E('div', {}, nameEl), + E('input', { + 'class': 'cbi-button cbi-button-add', + 'type': 'submit', + 'value': _('Add'), + 'title': _('Add'), + 'click': L.bind(function(ev) { + if (nameEl.classList.contains('cbi-input-invalid')) + return; + + this.handleAdd(ev, nameEl.value); + }, this) + }) + ]); + + ui.addValidator(nameEl, 'uciname', true, 'blur', 'keyup'); + } + + return createEl; + }, + + renderContents: function(cfgsections, nodes) { + var section_id = null, + config_name = this.uciconfig || this.map.config, + sectionEl = E('div', { + 'id': 'cbi-%s-%s'.format(config_name, this.sectiontype), + 'class': 'cbi-section' + }); + + if (this.title != null && this.title != '') + sectionEl.appendChild(E('legend', {}, this.title)); + + if (this.description != null && this.description != '') + sectionEl.appendChild(E('div', { 'class': 'cbi-section-descr' }, this.description)); + + for (var i = 0; i < nodes.length; i++) { + if (this.addremove) { + sectionEl.appendChild( + E('div', { 'class': 'cbi-section-remove right' }, + E('input', { + 'type': 'submit', + 'class': 'cbi-button', + 'name': 'cbi.rts.%s.%s'.format(config_name, cfgsections[i]), + 'value': _('Delete'), + 'data-section-id': cfgsections[i], + 'click': L.bind(this.handleRemove, this, cfgsections[i]) + }))); + } + + if (!this.anonymous) + sectionEl.appendChild(E('h3', cfgsections[i].toUpperCase())); + + sectionEl.appendChild(E('div', { + 'id': 'cbi-%s-%s'.format(config_name, cfgsections[i]), + 'class': this.tabs + ? 'cbi-section-node cbi-section-node-tabbed' : 'cbi-section-node' + }, nodes[i])); + + if (this.tabs) + ui.tabs.initTabGroup(sectionEl.lastChild.childNodes); + } + + if (nodes.length == 0) + L.dom.append(sectionEl, [ + E('em', _('This section contains no values yet')), + E('br'), E('br') + ]); + + sectionEl.appendChild(this.renderSectionAdd()); + + L.dom.bindClassInstance(sectionEl, this); + + return sectionEl; + }, + + render: function() { + var cfgsections = this.cfgsections(), + renderTasks = []; + + for (var i = 0; i < cfgsections.length; i++) + renderTasks.push(this.renderUCISection(cfgsections[i])); + + return Promise.all(renderTasks).then(this.renderContents.bind(this, cfgsections)); + } +}); + +var CBITableSection = CBITypedSection.extend({ + __name__: 'CBI.TableSection', + + tab: function() { + throw 'Tabs are not supported by TableSection'; + }, + + renderContents: function(cfgsections, nodes) { + var section_id = null, + config_name = this.uciconfig || this.map.config, + max_cols = isNaN(this.max_cols) ? this.children.length : this.max_cols, + has_more = max_cols < this.children.length, + sectionEl = E('div', { + 'id': 'cbi-%s-%s'.format(config_name, this.sectiontype), + 'class': 'cbi-section cbi-tblsection' + }), + tableEl = E('div', { + 'class': 'table cbi-section-table' + }); + + if (this.title != null && this.title != '') + sectionEl.appendChild(E('h3', {}, this.title)); + + if (this.description != null && this.description != '') + sectionEl.appendChild(E('div', { 'class': 'cbi-section-descr' }, this.description)); + + tableEl.appendChild(this.renderHeaderRows(max_cols)); + + for (var i = 0; i < nodes.length; i++) { + var sectionname = this.stripTags((typeof(this.sectiontitle) == 'function') + ? String(this.sectiontitle(cfgsections[i]) || '') : cfgsections[i]).trim(); + + var trEl = E('div', { + 'id': 'cbi-%s-%s'.format(config_name, cfgsections[i]), + 'class': 'tr cbi-section-table-row', + 'data-sid': cfgsections[i], + 'draggable': this.sortable ? true : null, + 'mousedown': this.sortable ? L.bind(this.handleDragInit, this) : null, + 'dragstart': this.sortable ? L.bind(this.handleDragStart, this) : null, + 'dragover': this.sortable ? L.bind(this.handleDragOver, this) : null, + 'dragenter': this.sortable ? L.bind(this.handleDragEnter, this) : null, + 'dragleave': this.sortable ? L.bind(this.handleDragLeave, this) : null, + 'dragend': this.sortable ? L.bind(this.handleDragEnd, this) : null, + 'drop': this.sortable ? L.bind(this.handleDrop, this) : null, + 'data-title': (sectionname && (!this.anonymous || this.sectiontitle)) ? sectionname : null + }); + + if (this.extedit || this.rowcolors) + trEl.classList.add(!(tableEl.childNodes.length % 2) + ? 'cbi-rowstyle-1' : 'cbi-rowstyle-2'); + + for (var j = 0; j < max_cols && nodes[i].firstChild; j++) + trEl.appendChild(nodes[i].firstChild); + + trEl.appendChild(this.renderRowActions(cfgsections[i], has_more ? _('More…') : null)); + tableEl.appendChild(trEl); + } + + if (nodes.length == 0) + tableEl.appendChild(E('div', { 'class': 'tr cbi-section-table-row placeholder' }, + E('div', { 'class': 'td' }, + E('em', {}, _('This section contains no values yet'))))); + + sectionEl.appendChild(tableEl); + + sectionEl.appendChild(this.renderSectionAdd('cbi-tblsection-create')); + + L.dom.bindClassInstance(sectionEl, this); + + return sectionEl; + }, + + renderHeaderRows: function(max_cols) { + var has_titles = false, + has_descriptions = false, + anon_class = (!this.anonymous || this.sectiontitle) ? 'named' : 'anonymous', + trEls = E([]); + + for (var i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) { + if (opt.optional || opt.modalonly) + continue; + + has_titles = has_titles || !!opt.title; + has_descriptions = has_descriptions || !!opt.description; + } + + if (has_titles) { + var trEl = E('div', { + 'class': 'tr cbi-section-table-titles ' + anon_class, + 'data-title': (!this.anonymous || this.sectiontitle) ? _('Name') : null + }); + + for (var i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) { + if (opt.optional || opt.modalonly) + continue; + + trEl.appendChild(E('div', { + 'class': 'th cbi-section-table-cell', + 'data-type': opt.__name__ + })); + + if (opt.width != null) + trEl.lastElementChild.style.width = + (typeof(opt.width) == 'number') ? opt.width+'px' : opt.width; + + if (opt.titleref) + trEl.lastElementChild.appendChild(E('a', { + 'href': opt.titleref, + 'class': 'cbi-title-ref', + 'title': this.titledesc || _('Go to relevant configuration page') + }, opt.title)); + else + L.dom.content(trEl.lastElementChild, opt.title); + } + + if (this.sortable || this.extedit || this.addremove || has_more) + trEl.appendChild(E('div', { + 'class': 'th cbi-section-table-cell cbi-section-actions' + })); + + trEls.appendChild(trEl); + } + + if (has_descriptions) { + var trEl = E('div', { + 'class': 'tr cbi-section-table-descr ' + anon_class + }); + + for (var i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) { + if (opt.optional || opt.modalonly) + continue; + + trEl.appendChild(E('div', { + 'class': 'th cbi-section-table-cell', + 'data-type': opt.__name__ + }, opt.description)); + + if (opt.width != null) + trEl.lastElementChild.style.width = + (typeof(opt.width) == 'number') ? opt.width+'px' : opt.width; + } + + if (this.sortable || this.extedit || this.addremove || has_more) + trEl.appendChild(E('div', { + 'class': 'th cbi-section-table-cell cbi-section-actions' + })); + + trEls.appendChild(trEl); + } + + return trEls; + }, + + renderRowActions: function(section_id, more_label) { + var config_name = this.uciconfig || this.map.config; + + if (!this.sortable && !this.extedit && !this.addremove && !more_label) + return E([]); + + var tdEl = E('div', { + 'class': 'td cbi-section-table-cell nowrap cbi-section-actions' + }, E('div')); + + if (this.sortable) { + L.dom.append(tdEl.lastElementChild, [ + E('div', { + 'title': _('Drag to reorder'), + 'class': 'cbi-button drag-handle center', + 'style': 'cursor:move' + }, '☰') + ]); + } + + if (this.extedit) { + var evFn = null; + + if (typeof(this.extedit) == 'function') + evFn = L.bind(this.extedit, this); + else if (typeof(this.extedit) == 'string') + evFn = L.bind(function(sid, ev) { + location.href = this.extedit.format(sid); + }, this, section_id); + + L.dom.append(tdEl.lastElementChild, + E('input', { + 'type': 'button', + 'value': _('Edit'), + 'title': _('Edit'), + 'class': 'cbi-button cbi-button-edit', + 'click': evFn + }) + ); + } + + if (more_label) { + L.dom.append(tdEl.lastElementChild, + E('input', { + 'type': 'button', + 'value': more_label, + 'title': more_label, + 'class': 'cbi-button cbi-button-edit', + 'click': L.bind(this.renderMoreOptionsModal, this, section_id) + }) + ); + } + + if (this.addremove) { + L.dom.append(tdEl.lastElementChild, + E('input', { + 'type': 'submit', + 'value': _('Delete'), + 'title': _('Delete'), + 'class': 'cbi-button cbi-button-remove', + 'click': L.bind(function(sid, ev) { + uci.remove(config_name, sid); + this.map.save(); + }, this, section_id) + }) + ); + } + + return tdEl; + }, + + handleDragInit: function(ev) { + scope.dragState = { node: ev.target }; + }, + + handleDragStart: function(ev) { + if (!scope.dragState || !scope.dragState.node.classList.contains('drag-handle')) { + scope.dragState = null; + ev.preventDefault(); + return false; + } + + scope.dragState.node = L.dom.parent(scope.dragState.node, '.tr'); + ev.dataTransfer.setData('text', 'drag'); + ev.target.style.opacity = 0.4; + }, + + handleDragOver: function(ev) { + var n = scope.dragState.targetNode, + r = scope.dragState.rect, + t = r.top + r.height / 2; + + if (ev.clientY <= t) { + n.classList.remove('drag-over-below'); + n.classList.add('drag-over-above'); + } + else { + n.classList.remove('drag-over-above'); + n.classList.add('drag-over-below'); + } + + ev.dataTransfer.dropEffect = 'move'; + ev.preventDefault(); + return false; + }, + + handleDragEnter: function(ev) { + scope.dragState.rect = ev.currentTarget.getBoundingClientRect(); + scope.dragState.targetNode = ev.currentTarget; + }, + + handleDragLeave: function(ev) { + ev.currentTarget.classList.remove('drag-over-above'); + ev.currentTarget.classList.remove('drag-over-below'); + }, + + handleDragEnd: function(ev) { + var n = ev.target; + + n.style.opacity = ''; + n.classList.add('flash'); + n.parentNode.querySelectorAll('.drag-over-above, .drag-over-below') + .forEach(function(tr) { + tr.classList.remove('drag-over-above'); + tr.classList.remove('drag-over-below'); + }); + }, + + handleDrop: function(ev) { + var s = scope.dragState; + + if (s.node && s.targetNode) { + var config_name = this.uciconfig || this.map.config, + ref_node = s.targetNode, + after = false; + + if (ref_node.classList.contains('drag-over-below')) { + ref_node = ref_node.nextElementSibling; + after = true; + } + + var sid1 = s.node.getAttribute('data-sid'), + sid2 = s.targetNode.getAttribute('data-sid'); + + s.node.parentNode.insertBefore(s.node, ref_node); + uci.move(config_name, sid1, sid2, after); + } + + scope.dragState = null; + ev.target.style.opacity = ''; + ev.stopPropagation(); + ev.preventDefault(); + return false; + }, + + handleModalCancel: function(modalMap, ev) { + L.ui.hideModal(); + }, + + handleModalSave: function(modalMap, ev) { + modalMap.save() + .then(L.bind(this.map.reset, this.map)) + .then(L.ui.hideModal); + }, + + 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); + s.tabs = this.tabs; + s.tab_names = this.tab_names; + + if (typeof(this.sectiontitle) == 'function') + name = this.stripTags(String(this.sectiontitle(section_id) || '')); + else if (!this.anonymous) + name = section_id; + + if (name) + title += ' - ' + name; + + for (var i = 0; i < this.children.length; i++) { + var o1 = this.children[i]; + + if (o1.disabled || o1.modalonly === false) + continue; + + var o2 = s.option(o1.constructor, o1.option, o1.title, o1.description); + + for (var k in o1) { + if (!o1.hasOwnProperty(k)) + continue; + + switch (k) { + case 'map': + case 'section': + case 'option': + case 'title': + case 'description': + continue; + + default: + o2[k] = o1[k]; + } + } + } + + //ev.target.classList.add('spinning'); + m.render().then(L.bind(function(nodes) { + //ev.target.classList.remove('spinning'); + L.ui.showModal(title, [ + nodes, + E('div', { 'class': 'right' }, [ + E('input', { + 'type': 'button', + 'class': 'btn', + 'click': L.bind(this.handleModalCancel, this, m), + 'value': _('Dismiss') + }), ' ', + E('input', { + 'type': 'button', + 'class': 'cbi-button cbi-button-positive important', + 'click': L.bind(this.handleModalSave, this, m), + 'value': _('Save') + }) + ]) + ]); + }, this)).catch(L.error); + } +}); + +var CBIGridSection = CBITableSection.extend({ + tab: function(name, title, description) { + CBIAbstractSection.prototype.tab.call(this, name, title, description); + }, + + handleAdd: function(ev) { + var config_name = this.uciconfig || this.map.config, + section_id = uci.add(config_name, this.sectiontype); + + this.addedSection = section_id; + this.renderMoreOptionsModal(section_id); + }, + + handleModalSave: function(/* ... */) { + this.super('handleModalSave', arguments); + this.addedSection = null; + }, + + handleModalCancel: function(/* ... */) { + var config_name = this.uciconfig || this.map.config; + + if (this.addedSection != null) { + uci.remove(config_name, this.addedSection); + this.addedSection = null; + } + + this.super('handleModalCancel', arguments); + }, + + renderUCISection: function(section_id) { + return this.renderOptions(null, section_id); + }, + + renderChildren: function(tab_name, section_id, in_table) { + var tasks = [], index = 0; + + for (var i = 0, opt; (opt = this.children[i]) != null; i++) { + if (opt.disable || opt.modalonly) + continue; + + if (opt.editable) + tasks.push(opt.render(index++, section_id, in_table)); + else + tasks.push(this.renderTextValue(section_id, opt)); + } + + return Promise.all(tasks); + }, + + renderTextValue: function(section_id, opt) { + var title = this.stripTags(opt.title).trim(), + descr = this.stripTags(opt.description).trim(), + value = opt.textvalue(section_id); + + return E('div', { + 'class': 'td cbi-value-field', + 'data-title': (title != '') ? title : opt.option, + 'data-description': (descr != '') ? descr : null, + 'data-name': opt.option, + 'data-type': opt.typename || opt.__name__ + }, (value != null) ? value : E('em', _('none'))); + }, + + renderRowActions: function(section_id) { + return this.super('renderRowActions', [ section_id, _('Edit') ]); + }, + + parse: function() { + var section_ids = this.cfgsections(), + tasks = []; + + if (Array.isArray(this.children)) { + for (var i = 0; i < section_ids.length; i++) { + for (var j = 0; j < this.children.length; j++) { + if (!this.children[j].editable || this.children[j].modalonly) + continue; + + tasks.push(this.children[j].parse(section_ids[i])); + } + } + } + + return Promise.all(tasks); + } +}); + +var CBINamedSection = CBIAbstractSection.extend({ + __name__: 'CBI.NamedSection', + __init__: function(map, section_id /*, ... */) { + this.super('__init__', this.varargs(arguments, 2, map)); + + this.section = section_id; + }, + + cfgsections: function() { + return [ this.section ]; + }, + + handleAdd: function(ev) { + var section_id = this.section, + config_name = this.uciconfig || this.map.config; + + uci.add(config_name, this.sectiontype, section_id); + this.map.save(); + }, + + handleRemove: function(ev) { + var section_id = this.section, + config_name = this.uciconfig || this.map.config; + + uci.remove(config_name, section_id); + this.map.save(); + }, + + renderContents: function(data) { + var ucidata = data[0], nodes = data[1], + section_id = this.section, + config_name = this.uciconfig || this.map.config, + sectionEl = E('div', { + 'id': ucidata ? null : 'cbi-%s-%s'.format(config_name, section_id), + 'class': 'cbi-section' + }); + + if (typeof(this.title) === 'string' && this.title !== '') + sectionEl.appendChild(E('legend', {}, this.title)); + + if (typeof(this.description) === 'string' && this.description !== '') + sectionEl.appendChild(E('div', { 'class': 'cbi-section-descr' }, this.description)); + + if (ucidata) { + if (this.addremove) { + sectionEl.appendChild( + E('div', { 'class': 'cbi-section-remove right' }, + E('input', { + 'type': 'submit', + 'class': 'cbi-button', + 'value': _('Delete'), + 'click': L.bind(this.handleRemove, this) + }))); + } + + sectionEl.appendChild(E('div', { + 'id': 'cbi-%s-%s'.format(config_name, section_id), + 'class': this.tabs + ? 'cbi-section-node cbi-section-node-tabbed' : 'cbi-section-node' + }, nodes)); + + if (this.tabs) + ui.tabs.initTabGroup(sectionEl.lastChild.childNodes); + } + else if (this.addremove) { + sectionEl.appendChild( + E('input', { + 'type': 'submit', + 'class': 'cbi-button cbi-button-add', + 'value': _('Add'), + 'click': L.bind(this.handleAdd, this) + })); + } + + L.dom.bindClassInstance(sectionEl, this); + + return sectionEl; + }, + + render: function() { + var config_name = this.uciconfig || this.map.config, + section_id = this.section; + + return Promise.all([ + uci.get(config_name, section_id), + this.renderUCISection(section_id) + ]).then(this.renderContents.bind(this)); + } +}); + +var CBIValue = CBIAbstractValue.extend({ + __name__: 'CBI.Value', + + value: function(key, val) { + this.keylist = this.keylist || []; + this.keylist.push(String(key)); + + this.vallist = this.vallist || []; + this.vallist.push(String(val != null ? val : key)); + }, + + render: function(option_index, section_id, in_table) { + return Promise.resolve(this.cfgvalue(section_id)) + .then(this.renderWidget.bind(this, section_id, option_index)) + .then(this.renderFrame.bind(this, section_id, in_table, option_index)); + }, + + renderFrame: function(section_id, in_table, option_index, nodes) { + var config_name = this.uciconfig || this.map.config, + depend_list = this.transformDepList(section_id), + optionEl; + + if (in_table) { + optionEl = E('div', { + 'class': 'td cbi-value-field', + 'data-title': this.stripTags(this.title).trim(), + 'data-description': this.stripTags(this.description).trim(), + 'data-name': this.option, + 'data-type': this.typename || (this.template ? this.template.replace(/^.+\//, '') : null) || this.__name__ + }, E('div', { + 'id': 'cbi-%s-%s-%s'.format(config_name, section_id, this.option), + 'data-index': option_index, + 'data-depends': depend_list, + 'data-field': this.cbid(section_id) + })); + } + else { + optionEl = E('div', { + 'class': 'cbi-value', + 'id': 'cbi-%s-%s-%s'.format(config_name, section_id, this.option), + 'data-index': option_index, + 'data-depends': depend_list, + 'data-field': this.cbid(section_id), + 'data-name': this.option, + 'data-type': this.typename || (this.template ? this.template.replace(/^.+\//, '') : null) || this.__name__ + }); + + if (this.last_child) + optionEl.classList.add('cbi-value-last'); + + if (typeof(this.title) === 'string' && this.title !== '') { + optionEl.appendChild(E('label', { + 'class': 'cbi-value-title', + 'for': 'cbid.%s.%s.%s'.format(config_name, section_id, this.option) + }, + this.titleref ? E('a', { + 'class': 'cbi-title-ref', + 'href': this.titleref, + 'title': this.titledesc || _('Go to relevant configuration page') + }, this.title) : this.title)); + + optionEl.appendChild(E('div', { 'class': 'cbi-value-field' })); + } + } + + if (nodes) + (optionEl.lastChild || optionEl).appendChild(nodes); + + if (!in_table && typeof(this.description) === 'string' && this.description !== '') + L.dom.append(optionEl.lastChild || optionEl, + E('div', { 'class': 'cbi-value-description' }, this.description)); + + if (depend_list && depend_list.length) + optionEl.classList.add('hidden'); + + optionEl.addEventListener('widget-change', + L.bind(this.map.checkDepends, this.map)); + + L.dom.bindClassInstance(optionEl, this); + + return optionEl; + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var value = (cfgvalue != null) ? cfgvalue : this.default, + choices = this.transformChoices(), + widget; + + if (choices) { + var placeholder = (this.optional || this.rmempty) + ? E('em', _('unspecified')) : _('-- Please choose --'); + + widget = new ui.Combobox(Array.isArray(value) ? value.join(' ') : value, choices, { + id: this.cbid(section_id), + sort: this.keylist, + optional: this.optional || this.rmempty, + datatype: this.datatype, + select_placeholder: this.placeholder || placeholder, + validate: L.bind(this.validate, this, section_id) + }); + } + else { + widget = new ui.Textfield(Array.isArray(value) ? value.join(' ') : value, { + id: this.cbid(section_id), + password: this.password, + optional: this.optional || this.rmempty, + datatype: this.datatype, + placeholder: this.placeholder, + validate: L.bind(this.validate, this, section_id) + }); + } + + return widget.render(); + } +}); + +var CBIDynamicList = CBIValue.extend({ + __name__: 'CBI.DynamicList', + + renderWidget: function(section_id, option_index, cfgvalue) { + var value = (cfgvalue != null) ? cfgvalue : this.default, + choices = this.transformChoices(), + items = null; + + if (Array.isArray(value)) + items = value; + else if (value != null) + items = String(value).trim().split(/\s+/); + + var widget = new ui.DynamicList(items, choices, { + id: this.cbid(section_id), + sort: this.keylist, + optional: this.optional || this.rmempty, + datatype: this.datatype, + validate: L.bind(this.validate, this, section_id) + }); + + return widget.render(); + }, +}); + +var CBIListValue = CBIValue.extend({ + __name__: 'CBI.ListValue', + + __init__: function() { + this.super('__init__', arguments); + this.widget = 'select'; + this.deplist = []; + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var choices = this.transformChoices(); + var widget = new ui.Select((cfgvalue != null) ? cfgvalue : this.default, choices, { + id: this.cbid(section_id), + size: this.size, + sort: this.keylist, + optional: this.rmempty || this.optional, + validate: L.bind(this.validate, this, section_id) + }); + + return widget.render(); + }, +}); + +var CBIFlagValue = CBIValue.extend({ + __name__: 'CBI.FlagValue', + + __init__: function() { + this.super('__init__', arguments); + + this.enabled = '1'; + this.disabled = '0'; + this.default = this.disabled; + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var widget = new ui.Checkbox((cfgvalue != null) ? cfgvalue : this.default, { + id: this.cbid(section_id), + value_enabled: this.enabled, + value_disabled: this.disabled, + validate: L.bind(this.validate, this, section_id) + }); + + return widget.render(); + }, + + formvalue: function(section_id) { + var node = this.map.findElement('id', this.cbid(section_id)), + checked = node ? L.dom.callClassMethod(node, 'isChecked') : false; + + return checked ? this.enabled : this.disabled; + }, + + textvalue: function(section_id) { + var cval = this.cfgvalue(section_id); + + if (cval == null) + cval = this.default; + + return (cval == this.enabled) ? _('Yes') : _('No'); + }, + + parse: function(section_id) { + if (this.isActive(section_id)) { + var fval = this.formvalue(section_id); + + if (!this.isValid(section_id)) + return Promise.reject(); + + if (fval == this.default && (this.optional || this.rmempty)) + return Promise.resolve(this.remove(section_id)); + else + return Promise.resolve(this.write(section_id, fval)); + } + else { + return Promise.resolve(this.remove(section_id)); + } + }, +}); + +var CBIMultiValue = CBIDynamicList.extend({ + __name__: 'CBI.MultiValue', + + __init__: function() { + this.super('__init__', arguments); + this.placeholder = _('-- Please choose --'); + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var value = (cfgvalue != null) ? cfgvalue : this.default, + choices = this.transformChoices(); + + if (!Array.isArray(value)) + value = String(value).split(/\s+/); + + var widget = new ui.Dropdown(value, choices, { + id: this.cbid(section_id), + sort: this.keylist, + multiple: true, + optional: this.optional || this.rmempty, + select_placeholder: this.placeholder, + display_items: this.display_size || this.size || 3, + dropdown_items: this.dropdown_size || this.size || 5, + validate: L.bind(this.validate, this, section_id) + }); + + return widget.render(); + }, +}); + +var CBIDummyValue = CBIValue.extend({ + __name__: 'CBI.DummyValue', + + renderWidget: function(section_id, option_index, cfgvalue) { + var value = (cfgvalue != null) ? cfgvalue : this.default, + hiddenEl = new ui.Hiddenfield(value, { id: this.cbid(section_id) }), + outputEl = E('div'); + + if (this.href) + outputEl.appendChild(E('a', { 'href': this.href })); + + L.dom.append(outputEl.lastChild || outputEl, + this.rawhtml ? value : [ value ]); + + return E([ + outputEl, + hiddenEl.render() + ]); + }, +}); + +var CBIButtonValue = CBIValue.extend({ + __name__: 'CBI.ButtonValue', + + renderWidget: function(section_id, option_index, cfgvalue) { + var value = (cfgvalue != null) ? cfgvalue : this.default; + + if (value !== false) + return E([ + E('input', { + 'type': 'hidden', + 'id': this.cbid(section_id) + }), + E('input', { + 'class': 'cbi-button cbi-button-%s'.format(this.inputstyle || 'button'), + 'type': 'submit', + //'id': this.cbid(section_id), + //'name': this.cbid(section_id), + 'value': this.inputtitle || this.title, + 'click': L.bind(function(ev) { + ev.target.previousElementSibling.value = ev.target.value; + this.map.save(); + }, this) + }) + ]); + else + return document.createTextNode(' - '); + } +}); + +var CBIHiddenValue = CBIValue.extend({ + __name__: 'CBI.HiddenValue', + + renderWidget: function(section_id, option_index, cfgvalue) { + var widget = new ui.Hiddenfield((cfgvalue != null) ? cfgvalue : this.default, { + id: this.cbid(section_id) + }); + + return widget.render(); + } +}); + +var CBISectionValue = CBIValue.extend({ + __name__: 'CBI.ContainerValue', + __init__: function(map, section, option, cbiClass /*, ... */) { + this.super('__init__', [map, section, option]); + + if (!CBIAbstractSection.isSubclass(cbiClass)) + throw 'Sub section must be a descendent of CBIAbstractSection'; + + this.subsection = cbiClass.instantiate(this.varargs(arguments, 4, this.map)); + }, + + load: function(section_id) { + return this.subsection.load(); + }, + + parse: function(section_id) { + return this.subsection.parse(); + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + return this.subsection.render(); + }, + + checkDepends: function(section_id) { + this.subsection.checkDepends(); + return this.super('checkDepends'); + }, + + write: function() {}, + remove: function() {}, + cfgvalue: function() { return null }, + formvalue: function() { return null } +}); + +return L.Class.extend({ + Map: CBIMap, + AbstractSection: CBIAbstractSection, + AbstractValue: CBIAbstractValue, + + TypedSection: CBITypedSection, + TableSection: CBITableSection, + GridSection: CBIGridSection, + NamedSection: CBINamedSection, + + Value: CBIValue, + DynamicList: CBIDynamicList, + ListValue: CBIListValue, + Flag: CBIFlagValue, + MultiValue: CBIMultiValue, + DummyValue: CBIDummyValue, + Button: CBIButtonValue, + HiddenValue: CBIHiddenValue, + SectionValue: CBISectionValue +}); diff --git a/modules/luci-base/htdocs/luci-static/resources/luci.js b/modules/luci-base/htdocs/luci-static/resources/luci.js index 896ded3af..767d013d7 100644 --- a/modules/luci-base/htdocs/luci-static/resources/luci.js +++ b/modules/luci-base/htdocs/luci-static/resources/luci.js @@ -451,7 +451,8 @@ Promise.all([ domReady, - this.require('ui') + this.require('ui'), + this.require('form') ]).then(this.setupDOM.bind(this)).catch(function(error) { alert('LuCI class loading error:\n' + error); }); -- 2.25.1