luci-base: form.js / ui.js: tie form labels to widgets
[oweals/luci.git] / modules / luci-base / htdocs / luci-static / resources / form.js
1 'use strict';
2 'require ui';
3 'require uci';
4 'require dom';
5 'require baseclass';
6
7 var scope = this;
8
9 var CBIJSONConfig = baseclass.extend({
10         __init__: function(data) {
11                 data = Object.assign({}, data);
12
13                 this.data = {};
14
15                 var num_sections = 0,
16                     section_ids = [];
17
18                 for (var sectiontype in data) {
19                         if (!data.hasOwnProperty(sectiontype))
20                                 continue;
21
22                         if (L.isObject(data[sectiontype])) {
23                                 this.data[sectiontype] = Object.assign(data[sectiontype], {
24                                         '.anonymous': false,
25                                         '.name': sectiontype,
26                                         '.type': sectiontype
27                                 });
28
29                                 section_ids.push(sectiontype);
30                                 num_sections++;
31                         }
32                         else if (Array.isArray(data[sectiontype])) {
33                                 for (var i = 0, index = 0; i < data[sectiontype].length; i++) {
34                                         var item = data[sectiontype][i],
35                                             anonymous, name;
36
37                                         if (!L.isObject(item))
38                                                 continue;
39
40                                         if (typeof(item['.name']) == 'string') {
41                                                 name = item['.name'];
42                                                 anonymous = false;
43                                         }
44                                         else {
45                                                 name = sectiontype + num_sections;
46                                                 anonymous = true;
47                                         }
48
49                                         if (!this.data.hasOwnProperty(name))
50                                                 section_ids.push(name);
51
52                                         this.data[name] = Object.assign(item, {
53                                                 '.index': num_sections++,
54                                                 '.anonymous': anonymous,
55                                                 '.name': name,
56                                                 '.type': sectiontype
57                                         });
58                                 }
59                         }
60                 }
61
62                 section_ids.sort(L.bind(function(a, b) {
63                         var indexA = (this.data[a]['.index'] != null) ? +this.data[a]['.index'] : 9999,
64                             indexB = (this.data[b]['.index'] != null) ? +this.data[b]['.index'] : 9999;
65
66                         if (indexA != indexB)
67                                 return (indexA - indexB);
68
69                         return (a > b);
70                 }, this));
71
72                 for (var i = 0; i < section_ids.length; i++)
73                         this.data[section_ids[i]]['.index'] = i;
74         },
75
76         load: function() {
77                 return Promise.resolve(this.data);
78         },
79
80         save: function() {
81                 return Promise.resolve();
82         },
83
84         get: function(config, section, option) {
85                 if (section == null)
86                         return null;
87
88                 if (option == null)
89                         return this.data[section];
90
91                 if (!this.data.hasOwnProperty(section))
92                         return null;
93
94                 var value = this.data[section][option];
95
96                 if (Array.isArray(value))
97                         return value;
98
99                 if (value != null)
100                         return String(value);
101
102                 return null;
103         },
104
105         set: function(config, section, option, value) {
106                 if (section == null || option == null || option.charAt(0) == '.')
107                         return;
108
109                 if (!this.data.hasOwnProperty(section))
110                         return;
111
112                 if (value == null)
113                         delete this.data[section][option];
114                 else if (Array.isArray(value))
115                         this.data[section][option] = value;
116                 else
117                         this.data[section][option] = String(value);
118         },
119
120         unset: function(config, section, option) {
121                 return this.set(config, section, option, null);
122         },
123
124         sections: function(config, sectiontype, callback) {
125                 var rv = [];
126
127                 for (var section_id in this.data)
128                         if (sectiontype == null || this.data[section_id]['.type'] == sectiontype)
129                                 rv.push(this.data[section_id]);
130
131                 rv.sort(function(a, b) { return a['.index'] - b['.index'] });
132
133                 if (typeof(callback) == 'function')
134                         for (var i = 0; i < rv.length; i++)
135                                 callback.call(this, rv[i], rv[i]['.name']);
136
137                 return rv;
138         },
139
140         add: function(config, sectiontype, sectionname) {
141                 var num_sections_type = 0, next_index = 0;
142
143                 for (var name in this.data) {
144                         num_sections_type += (this.data[name]['.type'] == sectiontype);
145                         next_index = Math.max(next_index, this.data[name]['.index']);
146                 }
147
148                 var section_id = sectionname || sectiontype + num_sections_type;
149
150                 if (!this.data.hasOwnProperty(section_id)) {
151                         this.data[section_id] = {
152                                 '.name': section_id,
153                                 '.type': sectiontype,
154                                 '.anonymous': (sectionname == null),
155                                 '.index': next_index + 1
156                         };
157                 }
158
159                 return section_id;
160         },
161
162         remove: function(config, section) {
163                 if (this.data.hasOwnProperty(section))
164                         delete this.data[section];
165         },
166
167         resolveSID: function(config, section_id) {
168                 return section_id;
169         },
170
171         move: function(config, section_id1, section_id2, after) {
172                 return uci.move.apply(this, [config, section_id1, section_id2, after]);
173         }
174 });
175
176 var CBINode = baseclass.extend({
177         __init__: function(title, description) {
178                 this.title = title || '';
179                 this.description = description || '';
180                 this.children = [];
181         },
182
183         append: function(obj) {
184                 this.children.push(obj);
185         },
186
187         parse: function() {
188                 var args = arguments;
189                 this.children.forEach(function(child) {
190                         child.parse.apply(child, args);
191                 });
192         },
193
194         render: function() {
195                 L.error('InternalError', 'Not implemented');
196         },
197
198         loadChildren: function(/* ... */) {
199                 var tasks = [];
200
201                 if (Array.isArray(this.children))
202                         for (var i = 0; i < this.children.length; i++)
203                                 if (!this.children[i].disable)
204                                         tasks.push(this.children[i].load.apply(this.children[i], arguments));
205
206                 return Promise.all(tasks);
207         },
208
209         renderChildren: function(tab_name /*, ... */) {
210                 var tasks = [],
211                     index = 0;
212
213                 if (Array.isArray(this.children))
214                         for (var i = 0; i < this.children.length; i++)
215                                 if (tab_name === null || this.children[i].tab === tab_name)
216                                         if (!this.children[i].disable)
217                                                 tasks.push(this.children[i].render.apply(
218                                                         this.children[i], this.varargs(arguments, 1, index++)));
219
220                 return Promise.all(tasks);
221         },
222
223         stripTags: function(s) {
224                 if (typeof(s) == 'string' && !s.match(/[<>]/))
225                         return s;
226
227                 var x = E('div', {}, s);
228                 return x.textContent || x.innerText || '';
229         },
230
231         titleFn: function(attr /*, ... */) {
232                 var s = null;
233
234                 if (typeof(this[attr]) == 'function')
235                         s = this[attr].apply(this, this.varargs(arguments, 1));
236                 else if (typeof(this[attr]) == 'string')
237                         s = (arguments.length > 1) ? ''.format.apply(this[attr], this.varargs(arguments, 1)) : this[attr];
238
239                 if (s != null)
240                         s = this.stripTags(String(s)).trim();
241
242                 if (s == null || s == '')
243                         return null;
244
245                 return s;
246         }
247 });
248
249 var CBIMap = CBINode.extend({
250         __init__: function(config /*, ... */) {
251                 this.super('__init__', this.varargs(arguments, 1));
252
253                 this.config = config;
254                 this.parsechain = [ config ];
255                 this.data = uci;
256         },
257
258         findElements: function(/* ... */) {
259                 var q = null;
260
261                 if (arguments.length == 1)
262                         q = arguments[0];
263                 else if (arguments.length == 2)
264                         q = '[%s="%s"]'.format(arguments[0], arguments[1]);
265                 else
266                         L.error('InternalError', 'Expecting one or two arguments to findElements()');
267
268                 return this.root.querySelectorAll(q);
269         },
270
271         findElement: function(/* ... */) {
272                 var res = this.findElements.apply(this, arguments);
273                 return res.length ? res[0] : null;
274         },
275
276         chain: function(config) {
277                 if (this.parsechain.indexOf(config) == -1)
278                         this.parsechain.push(config);
279         },
280
281         section: function(cbiClass /*, ... */) {
282                 if (!CBIAbstractSection.isSubclass(cbiClass))
283                         L.error('TypeError', 'Class must be a descendent of CBIAbstractSection');
284
285                 var obj = cbiClass.instantiate(this.varargs(arguments, 1, this));
286                 this.append(obj);
287                 return obj;
288         },
289
290         load: function() {
291                 return this.data.load(this.parsechain || [ this.config ])
292                         .then(this.loadChildren.bind(this));
293         },
294
295         parse: function() {
296                 var tasks = [];
297
298                 if (Array.isArray(this.children))
299                         for (var i = 0; i < this.children.length; i++)
300                                 tasks.push(this.children[i].parse());
301
302                 return Promise.all(tasks);
303         },
304
305         save: function(cb, silent) {
306                 this.checkDepends();
307
308                 return this.parse()
309                         .then(cb)
310                         .then(this.data.save.bind(this.data))
311                         .then(this.load.bind(this))
312                         .catch(function(e) {
313                                 if (!silent)
314                                         alert('Cannot save due to invalid values');
315
316                                 return Promise.reject();
317                         }).finally(this.renderContents.bind(this));
318         },
319
320         reset: function() {
321                 return this.renderContents();
322         },
323
324         render: function() {
325                 return this.load().then(this.renderContents.bind(this));
326         },
327
328         renderContents: function() {
329                 var mapEl = this.root || (this.root = E('div', {
330                         'id': 'cbi-%s'.format(this.config),
331                         'class': 'cbi-map',
332                         'cbi-dependency-check': L.bind(this.checkDepends, this)
333                 }));
334
335                 dom.bindClassInstance(mapEl, this);
336
337                 return this.renderChildren(null).then(L.bind(function(nodes) {
338                         var initialRender = !mapEl.firstChild;
339
340                         dom.content(mapEl, null);
341
342                         if (this.title != null && this.title != '')
343                                 mapEl.appendChild(E('h2', { 'name': 'content' }, this.title));
344
345                         if (this.description != null && this.description != '')
346                                 mapEl.appendChild(E('div', { 'class': 'cbi-map-descr' }, this.description));
347
348                         if (this.tabbed)
349                                 dom.append(mapEl, E('div', { 'class': 'cbi-map-tabbed' }, nodes));
350                         else
351                                 dom.append(mapEl, nodes);
352
353                         if (!initialRender) {
354                                 mapEl.classList.remove('flash');
355
356                                 window.setTimeout(function() {
357                                         mapEl.classList.add('flash');
358                                 }, 1);
359                         }
360
361                         this.checkDepends();
362
363                         var tabGroups = mapEl.querySelectorAll('.cbi-map-tabbed, .cbi-section-node-tabbed');
364
365                         for (var i = 0; i < tabGroups.length; i++)
366                                 ui.tabs.initTabGroup(tabGroups[i].childNodes);
367
368                         return mapEl;
369                 }, this));
370         },
371
372         lookupOption: function(name, section_id, config_name) {
373                 var id, elem, sid, inst;
374
375                 if (name.indexOf('.') > -1)
376                         id = 'cbid.%s'.format(name);
377                 else
378                         id = 'cbid.%s.%s.%s'.format(config_name || this.config, section_id, name);
379
380                 elem = this.findElement('data-field', id);
381                 sid  = elem ? id.split(/\./)[2] : null;
382                 inst = elem ? dom.findClassInstance(elem) : null;
383
384                 return (inst instanceof CBIAbstractValue) ? [ inst, sid ] : null;
385         },
386
387         checkDepends: function(ev, n) {
388                 var changed = false;
389
390                 for (var i = 0, s = this.children[0]; (s = this.children[i]) != null; i++)
391                         if (s.checkDepends(ev, n))
392                                 changed = true;
393
394                 if (changed && (n || 0) < 10)
395                         this.checkDepends(ev, (n || 10) + 1);
396
397                 ui.tabs.updateTabs(ev, this.root);
398         },
399
400         isDependencySatisfied: function(depends, config_name, section_id) {
401                 var def = false;
402
403                 if (!Array.isArray(depends) || !depends.length)
404                         return true;
405
406                 for (var i = 0; i < depends.length; i++) {
407                         var istat = true,
408                             reverse = depends[i]['!reverse'],
409                             contains = depends[i]['!contains'];
410
411                         for (var dep in depends[i]) {
412                                 if (dep == '!reverse' || dep == '!contains') {
413                                         continue;
414                                 }
415                                 else if (dep == '!default') {
416                                         def = true;
417                                         istat = false;
418                                 }
419                                 else {
420                                         var res = this.lookupOption(dep, section_id, config_name),
421                                             val = (res && res[0].isActive(res[1])) ? res[0].formvalue(res[1]) : null;
422
423                                         var equal = contains
424                                                 ? isContained(val, depends[i][dep])
425                                                 : isEqual(val, depends[i][dep]);
426
427                                         istat = (istat && equal);
428                                 }
429                         }
430
431                         if (istat ^ reverse)
432                                 return true;
433                 }
434
435                 return def;
436         }
437 });
438
439 var CBIJSONMap = CBIMap.extend({
440         __init__: function(data /*, ... */) {
441                 this.super('__init__', this.varargs(arguments, 1, 'json'));
442
443                 this.config = 'json';
444                 this.parsechain = [ 'json' ];
445                 this.data = new CBIJSONConfig(data);
446         }
447 });
448
449 var CBIAbstractSection = CBINode.extend({
450         __init__: function(map, sectionType /*, ... */) {
451                 this.super('__init__', this.varargs(arguments, 2));
452
453                 this.sectiontype = sectionType;
454                 this.map = map;
455                 this.config = map.config;
456
457                 this.optional = true;
458                 this.addremove = false;
459                 this.dynamic = false;
460         },
461
462         cfgsections: function() {
463                 L.error('InternalError', 'Not implemented');
464         },
465
466         filter: function(section_id) {
467                 return true;
468         },
469
470         load: function() {
471                 var section_ids = this.cfgsections(),
472                     tasks = [];
473
474                 if (Array.isArray(this.children))
475                         for (var i = 0; i < section_ids.length; i++)
476                                 tasks.push(this.loadChildren(section_ids[i])
477                                         .then(Function.prototype.bind.call(function(section_id, set_values) {
478                                                 for (var i = 0; i < set_values.length; i++)
479                                                         this.children[i].cfgvalue(section_id, set_values[i]);
480                                         }, this, section_ids[i])));
481
482                 return Promise.all(tasks);
483         },
484
485         parse: function() {
486                 var section_ids = this.cfgsections(),
487                     tasks = [];
488
489                 if (Array.isArray(this.children))
490                         for (var i = 0; i < section_ids.length; i++)
491                                 for (var j = 0; j < this.children.length; j++)
492                                         tasks.push(this.children[j].parse(section_ids[i]));
493
494                 return Promise.all(tasks);
495         },
496
497         tab: function(name, title, description) {
498                 if (this.tabs && this.tabs[name])
499                         throw 'Tab already declared';
500
501                 var entry = {
502                         name: name,
503                         title: title,
504                         description: description,
505                         children: []
506                 };
507
508                 this.tabs = this.tabs || [];
509                 this.tabs.push(entry);
510                 this.tabs[name] = entry;
511
512                 this.tab_names = this.tab_names || [];
513                 this.tab_names.push(name);
514         },
515
516         option: function(cbiClass /*, ... */) {
517                 if (!CBIAbstractValue.isSubclass(cbiClass))
518                         throw L.error('TypeError', 'Class must be a descendent of CBIAbstractValue');
519
520                 var obj = cbiClass.instantiate(this.varargs(arguments, 1, this.map, this));
521                 this.append(obj);
522                 return obj;
523         },
524
525         taboption: function(tabName /*, ... */) {
526                 if (!this.tabs || !this.tabs[tabName])
527                         throw L.error('ReferenceError', 'Associated tab not declared');
528
529                 var obj = this.option.apply(this, this.varargs(arguments, 1));
530                 obj.tab = tabName;
531                 this.tabs[tabName].children.push(obj);
532                 return obj;
533         },
534
535         renderUCISection: function(section_id) {
536                 var renderTasks = [];
537
538                 if (!this.tabs)
539                         return this.renderOptions(null, section_id);
540
541                 for (var i = 0; i < this.tab_names.length; i++)
542                         renderTasks.push(this.renderOptions(this.tab_names[i], section_id));
543
544                 return Promise.all(renderTasks)
545                         .then(this.renderTabContainers.bind(this, section_id));
546         },
547
548         renderTabContainers: function(section_id, nodes) {
549                 var config_name = this.uciconfig || this.map.config,
550                     containerEls = E([]);
551
552                 for (var i = 0; i < nodes.length; i++) {
553                         var tab_name = this.tab_names[i],
554                             tab_data = this.tabs[tab_name],
555                             containerEl = E('div', {
556                                 'id': 'container.%s.%s.%s'.format(config_name, section_id, tab_name),
557                                 'data-tab': tab_name,
558                                 'data-tab-title': tab_data.title,
559                                 'data-tab-active': tab_name === this.selected_tab
560                             });
561
562                         if (tab_data.description != null && tab_data.description != '')
563                                 containerEl.appendChild(
564                                         E('div', { 'class': 'cbi-tab-descr' }, tab_data.description));
565
566                         containerEl.appendChild(nodes[i]);
567                         containerEls.appendChild(containerEl);
568                 }
569
570                 return containerEls;
571         },
572
573         renderOptions: function(tab_name, section_id) {
574                 var in_table = (this instanceof CBITableSection);
575                 return this.renderChildren(tab_name, section_id, in_table).then(function(nodes) {
576                         var optionEls = E([]);
577                         for (var i = 0; i < nodes.length; i++)
578                                 optionEls.appendChild(nodes[i]);
579                         return optionEls;
580                 });
581         },
582
583         checkDepends: function(ev, n) {
584                 var changed = false,
585                     sids = this.cfgsections();
586
587                 for (var i = 0, sid = sids[0]; (sid = sids[i]) != null; i++) {
588                         for (var j = 0, o = this.children[0]; (o = this.children[j]) != null; j++) {
589                                 var isActive = o.isActive(sid),
590                                     isSatisified = o.checkDepends(sid);
591
592                                 if (isActive != isSatisified) {
593                                         o.setActive(sid, !isActive);
594                                         isActive = !isActive;
595                                         changed = true;
596                                 }
597
598                                 if (!n && isActive)
599                                         o.triggerValidation(sid);
600                         }
601                 }
602
603                 return changed;
604         }
605 });
606
607
608 var isEqual = function(x, y) {
609         if (x != null && y != null && typeof(x) != typeof(y))
610                 return false;
611
612         if ((x == null && y != null) || (x != null && y == null))
613                 return false;
614
615         if (Array.isArray(x)) {
616                 if (x.length != y.length)
617                         return false;
618
619                 for (var i = 0; i < x.length; i++)
620                         if (!isEqual(x[i], y[i]))
621                                 return false;
622         }
623         else if (typeof(x) == 'object') {
624                 for (var k in x) {
625                         if (x.hasOwnProperty(k) && !y.hasOwnProperty(k))
626                                 return false;
627
628                         if (!isEqual(x[k], y[k]))
629                                 return false;
630                 }
631
632                 for (var k in y)
633                         if (y.hasOwnProperty(k) && !x.hasOwnProperty(k))
634                                 return false;
635         }
636         else if (x != y) {
637                 return false;
638         }
639
640         return true;
641 };
642
643 var isContained = function(x, y) {
644         if (Array.isArray(x)) {
645                 for (var i = 0; i < x.length; i++)
646                         if (x[i] == y)
647                                 return true;
648         }
649         else if (L.isObject(x)) {
650                 if (x.hasOwnProperty(y) && x[y] != null)
651                         return true;
652         }
653         else if (typeof(x) == 'string') {
654                 return (x.indexOf(y) > -1);
655         }
656
657         return false;
658 };
659
660 var CBIAbstractValue = CBINode.extend({
661         __init__: function(map, section, option /*, ... */) {
662                 this.super('__init__', this.varargs(arguments, 3));
663
664                 this.section = section;
665                 this.option = option;
666                 this.map = map;
667                 this.config = map.config;
668
669                 this.deps = [];
670                 this.initial = {};
671                 this.rmempty = true;
672                 this.default = null;
673                 this.size = null;
674                 this.optional = false;
675         },
676
677         depends: function(field, value) {
678                 var deps;
679
680                 if (typeof(field) === 'string')
681                         deps = {}, deps[field] = value;
682                 else
683                         deps = field;
684
685                 this.deps.push(deps);
686         },
687
688         transformDepList: function(section_id, deplist) {
689                 var list = deplist || this.deps,
690                     deps = [];
691
692                 if (Array.isArray(list)) {
693                         for (var i = 0; i < list.length; i++) {
694                                 var dep = {};
695
696                                 for (var k in list[i]) {
697                                         if (list[i].hasOwnProperty(k)) {
698                                                 if (k.charAt(0) === '!')
699                                                         dep[k] = list[i][k];
700                                                 else if (k.indexOf('.') !== -1)
701                                                         dep['cbid.%s'.format(k)] = list[i][k];
702                                                 else
703                                                         dep['cbid.%s.%s.%s'.format(
704                                                                 this.uciconfig || this.section.uciconfig || this.map.config,
705                                                                 this.ucisection || section_id,
706                                                                 k
707                                                         )] = list[i][k];
708                                         }
709                                 }
710
711                                 for (var k in dep) {
712                                         if (dep.hasOwnProperty(k)) {
713                                                 deps.push(dep);
714                                                 break;
715                                         }
716                                 }
717                         }
718                 }
719
720                 return deps;
721         },
722
723         transformChoices: function() {
724                 if (!Array.isArray(this.keylist) || this.keylist.length == 0)
725                         return null;
726
727                 var choices = {};
728
729                 for (var i = 0; i < this.keylist.length; i++)
730                         choices[this.keylist[i]] = this.vallist[i];
731
732                 return choices;
733         },
734
735         checkDepends: function(section_id) {
736                 var config_name = this.uciconfig || this.section.uciconfig || this.map.config,
737                     active = this.map.isDependencySatisfied(this.deps, config_name, section_id);
738
739                 if (active)
740                         this.updateDefaultValue(section_id);
741
742                 return active;
743         },
744
745         updateDefaultValue: function(section_id) {
746                 if (!L.isObject(this.defaults))
747                         return;
748
749                 var config_name = this.uciconfig || this.section.uciconfig || this.map.config,
750                     cfgvalue = L.toArray(this.cfgvalue(section_id))[0],
751                     default_defval = null, satisified_defval = null;
752
753                 for (var value in this.defaults) {
754                         if (!this.defaults[value] || this.defaults[value].length == 0) {
755                                 default_defval = value;
756                                 continue;
757                         }
758                         else if (this.map.isDependencySatisfied(this.defaults[value], config_name, section_id)) {
759                                 satisified_defval = value;
760                                 break;
761                         }
762                 }
763
764                 if (satisified_defval == null)
765                         satisified_defval = default_defval;
766
767                 var node = this.map.findElement('id', this.cbid(section_id));
768                 if (node && node.getAttribute('data-changed') != 'true' && satisified_defval != null && cfgvalue == null)
769                         dom.callClassMethod(node, 'setValue', satisified_defval);
770
771                 this.default = satisified_defval;
772         },
773
774         cbid: function(section_id) {
775                 if (section_id == null)
776                         L.error('TypeError', 'Section ID required');
777
778                 return 'cbid.%s.%s.%s'.format(
779                         this.uciconfig || this.section.uciconfig || this.map.config,
780                         section_id, this.option);
781         },
782
783         load: function(section_id) {
784                 if (section_id == null)
785                         L.error('TypeError', 'Section ID required');
786
787                 return this.map.data.get(
788                         this.uciconfig || this.section.uciconfig || this.map.config,
789                         this.ucisection || section_id,
790                         this.ucioption || this.option);
791         },
792
793         getUIElement: function(section_id) {
794                 var node = this.map.findElement('id', this.cbid(section_id)),
795                     inst = node ? dom.findClassInstance(node) : null;
796                 return (inst instanceof ui.AbstractElement) ? inst : null;
797         },
798
799         cfgvalue: function(section_id, set_value) {
800                 if (section_id == null)
801                         L.error('TypeError', 'Section ID required');
802
803                 if (arguments.length == 2) {
804                         this.data = this.data || {};
805                         this.data[section_id] = set_value;
806                 }
807
808                 return this.data ? this.data[section_id] : null;
809         },
810
811         formvalue: function(section_id) {
812                 var elem = this.getUIElement(section_id);
813                 return elem ? elem.getValue() : null;
814         },
815
816         textvalue: function(section_id) {
817                 var cval = this.cfgvalue(section_id);
818
819                 if (cval == null)
820                         cval = this.default;
821
822                 return (cval != null) ? '%h'.format(cval) : null;
823         },
824
825         validate: function(section_id, value) {
826                 return true;
827         },
828
829         isValid: function(section_id) {
830                 var elem = this.getUIElement(section_id);
831                 return elem ? elem.isValid() : true;
832         },
833
834         isActive: function(section_id) {
835                 var field = this.map.findElement('data-field', this.cbid(section_id));
836                 return (field != null && !field.classList.contains('hidden'));
837         },
838
839         setActive: function(section_id, active) {
840                 var field = this.map.findElement('data-field', this.cbid(section_id));
841
842                 if (field && field.classList.contains('hidden') == active) {
843                         field.classList[active ? 'remove' : 'add']('hidden');
844                         return true;
845                 }
846
847                 return false;
848         },
849
850         triggerValidation: function(section_id) {
851                 var elem = this.getUIElement(section_id);
852                 return elem ? elem.triggerValidation() : true;
853         },
854
855         parse: function(section_id) {
856                 var active = this.isActive(section_id),
857                     cval = this.cfgvalue(section_id),
858                     fval = active ? this.formvalue(section_id) : null;
859
860                 if (active && !this.isValid(section_id))
861                         return Promise.reject();
862
863                 if (fval != '' && fval != null) {
864                         if (this.forcewrite || !isEqual(cval, fval))
865                                 return Promise.resolve(this.write(section_id, fval));
866                 }
867                 else {
868                         if (!active || this.rmempty || this.optional) {
869                                 return Promise.resolve(this.remove(section_id));
870                         }
871                         else if (!isEqual(cval, fval)) {
872                                 console.log('This should have been catched by isValid()');
873                                 return Promise.reject();
874                         }
875                 }
876
877                 return Promise.resolve();
878         },
879
880         write: function(section_id, formvalue) {
881                 return this.map.data.set(
882                         this.uciconfig || this.section.uciconfig || this.map.config,
883                         this.ucisection || section_id,
884                         this.ucioption || this.option,
885                         formvalue);
886         },
887
888         remove: function(section_id) {
889                 return this.map.data.unset(
890                         this.uciconfig || this.section.uciconfig || this.map.config,
891                         this.ucisection || section_id,
892                         this.ucioption || this.option);
893         }
894 });
895
896 var CBITypedSection = CBIAbstractSection.extend({
897         __name__: 'CBI.TypedSection',
898
899         cfgsections: function() {
900                 return this.map.data.sections(this.uciconfig || this.map.config, this.sectiontype)
901                         .map(function(s) { return s['.name'] })
902                         .filter(L.bind(this.filter, this));
903         },
904
905         handleAdd: function(ev, name) {
906                 var config_name = this.uciconfig || this.map.config;
907
908                 this.map.data.add(config_name, this.sectiontype, name);
909                 return this.map.save(null, true);
910         },
911
912         handleRemove: function(section_id, ev) {
913                 var config_name = this.uciconfig || this.map.config;
914
915                 this.map.data.remove(config_name, section_id);
916                 return this.map.save(null, true);
917         },
918
919         renderSectionAdd: function(extra_class) {
920                 if (!this.addremove)
921                         return E([]);
922
923                 var createEl = E('div', { 'class': 'cbi-section-create' }),
924                     config_name = this.uciconfig || this.map.config,
925                     btn_title = this.titleFn('addbtntitle');
926
927                 if (extra_class != null)
928                         createEl.classList.add(extra_class);
929
930                 if (this.anonymous) {
931                         createEl.appendChild(E('button', {
932                                 'class': 'cbi-button cbi-button-add',
933                                 'title': btn_title || _('Add'),
934                                 'click': ui.createHandlerFn(this, 'handleAdd')
935                         }, [ btn_title || _('Add') ]));
936                 }
937                 else {
938                         var nameEl = E('input', {
939                                 'type': 'text',
940                                 'class': 'cbi-section-create-name'
941                         });
942
943                         dom.append(createEl, [
944                                 E('div', {}, nameEl),
945                                 E('input', {
946                                         'class': 'cbi-button cbi-button-add',
947                                         'type': 'submit',
948                                         'value': btn_title || _('Add'),
949                                         'title': btn_title || _('Add'),
950                                         'click': ui.createHandlerFn(this, function(ev) {
951                                                 if (nameEl.classList.contains('cbi-input-invalid'))
952                                                         return;
953
954                                                 return this.handleAdd(ev, nameEl.value);
955                                         })
956                                 })
957                         ]);
958
959                         ui.addValidator(nameEl, 'uciname', true, 'blur', 'keyup');
960                 }
961
962                 return createEl;
963         },
964
965         renderSectionPlaceholder: function() {
966                 return E([
967                         E('em', _('This section contains no values yet')),
968                         E('br'), E('br')
969                 ]);
970         },
971
972         renderContents: function(cfgsections, nodes) {
973                 var section_id = null,
974                     config_name = this.uciconfig || this.map.config,
975                     sectionEl = E('div', {
976                                 'id': 'cbi-%s-%s'.format(config_name, this.sectiontype),
977                                 'class': 'cbi-section',
978                                 'data-tab': (this.map.tabbed && !this.parentoption) ? this.sectiontype : null,
979                                 'data-tab-title': (this.map.tabbed && !this.parentoption) ? this.title || this.sectiontype : null
980                         });
981
982                 if (this.title != null && this.title != '')
983                         sectionEl.appendChild(E('legend', {}, this.title));
984
985                 if (this.description != null && this.description != '')
986                         sectionEl.appendChild(E('div', { 'class': 'cbi-section-descr' }, this.description));
987
988                 for (var i = 0; i < nodes.length; i++) {
989                         if (this.addremove) {
990                                 sectionEl.appendChild(
991                                         E('div', { 'class': 'cbi-section-remove right' },
992                                                 E('button', {
993                                                         'class': 'cbi-button',
994                                                         'name': 'cbi.rts.%s.%s'.format(config_name, cfgsections[i]),
995                                                         'data-section-id': cfgsections[i],
996                                                         'click': ui.createHandlerFn(this, 'handleRemove', cfgsections[i])
997                                                 }, [ _('Delete') ])));
998                         }
999
1000                         if (!this.anonymous)
1001                                 sectionEl.appendChild(E('h3', cfgsections[i].toUpperCase()));
1002
1003                         sectionEl.appendChild(E('div', {
1004                                 'id': 'cbi-%s-%s'.format(config_name, cfgsections[i]),
1005                                 'class': this.tabs
1006                                         ? 'cbi-section-node cbi-section-node-tabbed' : 'cbi-section-node',
1007                                 'data-section-id': cfgsections[i]
1008                         }, nodes[i]));
1009                 }
1010
1011                 if (nodes.length == 0)
1012                         sectionEl.appendChild(this.renderSectionPlaceholder());
1013
1014                 sectionEl.appendChild(this.renderSectionAdd());
1015
1016                 dom.bindClassInstance(sectionEl, this);
1017
1018                 return sectionEl;
1019         },
1020
1021         render: function() {
1022                 var cfgsections = this.cfgsections(),
1023                     renderTasks = [];
1024
1025                 for (var i = 0; i < cfgsections.length; i++)
1026                         renderTasks.push(this.renderUCISection(cfgsections[i]));
1027
1028                 return Promise.all(renderTasks).then(this.renderContents.bind(this, cfgsections));
1029         }
1030 });
1031
1032 var CBITableSection = CBITypedSection.extend({
1033         __name__: 'CBI.TableSection',
1034
1035         tab: function() {
1036                 throw 'Tabs are not supported by TableSection';
1037         },
1038
1039         renderContents: function(cfgsections, nodes) {
1040                 var section_id = null,
1041                     config_name = this.uciconfig || this.map.config,
1042                     max_cols = isNaN(this.max_cols) ? this.children.length : this.max_cols,
1043                     has_more = max_cols < this.children.length,
1044                     sectionEl = E('div', {
1045                                 'id': 'cbi-%s-%s'.format(config_name, this.sectiontype),
1046                                 'class': 'cbi-section cbi-tblsection',
1047                                 'data-tab': (this.map.tabbed && !this.parentoption) ? this.sectiontype : null,
1048                                 'data-tab-title': (this.map.tabbed && !this.parentoption) ? this.title || this.sectiontype : null
1049                         }),
1050                         tableEl = E('div', {
1051                                 'class': 'table cbi-section-table'
1052                         });
1053
1054                 if (this.title != null && this.title != '')
1055                         sectionEl.appendChild(E('h3', {}, this.title));
1056
1057                 if (this.description != null && this.description != '')
1058                         sectionEl.appendChild(E('div', { 'class': 'cbi-section-descr' }, this.description));
1059
1060                 tableEl.appendChild(this.renderHeaderRows(max_cols));
1061
1062                 for (var i = 0; i < nodes.length; i++) {
1063                         var sectionname = this.titleFn('sectiontitle', cfgsections[i]);
1064
1065                         if (sectionname == null)
1066                                 sectionname = cfgsections[i];
1067
1068                         var trEl = E('div', {
1069                                 'id': 'cbi-%s-%s'.format(config_name, cfgsections[i]),
1070                                 'class': 'tr cbi-section-table-row',
1071                                 'data-sid': cfgsections[i],
1072                                 'draggable': this.sortable ? true : null,
1073                                 'mousedown': this.sortable ? L.bind(this.handleDragInit, this) : null,
1074                                 'dragstart': this.sortable ? L.bind(this.handleDragStart, this) : null,
1075                                 'dragover': this.sortable ? L.bind(this.handleDragOver, this) : null,
1076                                 'dragenter': this.sortable ? L.bind(this.handleDragEnter, this) : null,
1077                                 'dragleave': this.sortable ? L.bind(this.handleDragLeave, this) : null,
1078                                 'dragend': this.sortable ? L.bind(this.handleDragEnd, this) : null,
1079                                 'drop': this.sortable ? L.bind(this.handleDrop, this) : null,
1080                                 'data-title': (sectionname && (!this.anonymous || this.sectiontitle)) ? sectionname : null,
1081                                 'data-section-id': cfgsections[i]
1082                         });
1083
1084                         if (this.extedit || this.rowcolors)
1085                                 trEl.classList.add(!(tableEl.childNodes.length % 2)
1086                                         ? 'cbi-rowstyle-1' : 'cbi-rowstyle-2');
1087
1088                         for (var j = 0; j < max_cols && nodes[i].firstChild; j++)
1089                                 trEl.appendChild(nodes[i].firstChild);
1090
1091                         trEl.appendChild(this.renderRowActions(cfgsections[i], has_more ? _('More…') : null));
1092                         tableEl.appendChild(trEl);
1093                 }
1094
1095                 if (nodes.length == 0)
1096                         tableEl.appendChild(E('div', { 'class': 'tr cbi-section-table-row placeholder' },
1097                                 E('div', { 'class': 'td' },
1098                                         E('em', {}, _('This section contains no values yet')))));
1099
1100                 sectionEl.appendChild(tableEl);
1101
1102                 sectionEl.appendChild(this.renderSectionAdd('cbi-tblsection-create'));
1103
1104                 dom.bindClassInstance(sectionEl, this);
1105
1106                 return sectionEl;
1107         },
1108
1109         renderHeaderRows: function(max_cols) {
1110                 var has_titles = false,
1111                     has_descriptions = false,
1112                     max_cols = isNaN(this.max_cols) ? this.children.length : this.max_cols,
1113                     has_more = max_cols < this.children.length,
1114                     anon_class = (!this.anonymous || this.sectiontitle) ? 'named' : 'anonymous',
1115                     trEls = E([]);
1116
1117                 for (var i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) {
1118                         if (opt.optional || opt.modalonly)
1119                                 continue;
1120
1121                         has_titles = has_titles || !!opt.title;
1122                         has_descriptions = has_descriptions || !!opt.description;
1123                 }
1124
1125                 if (has_titles) {
1126                         var trEl = E('div', {
1127                                 'class': 'tr cbi-section-table-titles ' + anon_class,
1128                                 'data-title': (!this.anonymous || this.sectiontitle) ? _('Name') : null
1129                         });
1130
1131                         for (var i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) {
1132                                 if (opt.optional || opt.modalonly)
1133                                         continue;
1134
1135                                 trEl.appendChild(E('div', {
1136                                         'class': 'th cbi-section-table-cell',
1137                                         'data-widget': opt.__name__
1138                                 }));
1139
1140                                 if (opt.width != null)
1141                                         trEl.lastElementChild.style.width =
1142                                                 (typeof(opt.width) == 'number') ? opt.width+'px' : opt.width;
1143
1144                                 if (opt.titleref)
1145                                         trEl.lastElementChild.appendChild(E('a', {
1146                                                 'href': opt.titleref,
1147                                                 'class': 'cbi-title-ref',
1148                                                 'title': this.titledesc || _('Go to relevant configuration page')
1149                                         }, opt.title));
1150                                 else
1151                                         dom.content(trEl.lastElementChild, opt.title);
1152                         }
1153
1154                         if (this.sortable || this.extedit || this.addremove || has_more)
1155                                 trEl.appendChild(E('div', {
1156                                         'class': 'th cbi-section-table-cell cbi-section-actions'
1157                                 }));
1158
1159                         trEls.appendChild(trEl);
1160                 }
1161
1162                 if (has_descriptions) {
1163                         var trEl = E('div', {
1164                                 'class': 'tr cbi-section-table-descr ' + anon_class
1165                         });
1166
1167                         for (var i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) {
1168                                 if (opt.optional || opt.modalonly)
1169                                         continue;
1170
1171                                 trEl.appendChild(E('div', {
1172                                         'class': 'th cbi-section-table-cell',
1173                                         'data-widget': opt.__name__
1174                                 }, opt.description));
1175
1176                                 if (opt.width != null)
1177                                         trEl.lastElementChild.style.width =
1178                                                 (typeof(opt.width) == 'number') ? opt.width+'px' : opt.width;
1179                         }
1180
1181                         if (this.sortable || this.extedit || this.addremove || has_more)
1182                                 trEl.appendChild(E('div', {
1183                                         'class': 'th cbi-section-table-cell cbi-section-actions'
1184                                 }));
1185
1186                         trEls.appendChild(trEl);
1187                 }
1188
1189                 return trEls;
1190         },
1191
1192         renderRowActions: function(section_id, more_label) {
1193                 var config_name = this.uciconfig || this.map.config;
1194
1195                 if (!this.sortable && !this.extedit && !this.addremove && !more_label)
1196                         return E([]);
1197
1198                 var tdEl = E('div', {
1199                         'class': 'td cbi-section-table-cell nowrap cbi-section-actions'
1200                 }, E('div'));
1201
1202                 if (this.sortable) {
1203                         dom.append(tdEl.lastElementChild, [
1204                                 E('div', {
1205                                         'title': _('Drag to reorder'),
1206                                         'class': 'cbi-button drag-handle center',
1207                                         'style': 'cursor:move'
1208                                 }, '☰')
1209                         ]);
1210                 }
1211
1212                 if (this.extedit) {
1213                         var evFn = null;
1214
1215                         if (typeof(this.extedit) == 'function')
1216                                 evFn = L.bind(this.extedit, this);
1217                         else if (typeof(this.extedit) == 'string')
1218                                 evFn = L.bind(function(sid, ev) {
1219                                         location.href = this.extedit.format(sid);
1220                                 }, this, section_id);
1221
1222                         dom.append(tdEl.lastElementChild,
1223                                 E('button', {
1224                                         'title': _('Edit'),
1225                                         'class': 'cbi-button cbi-button-edit',
1226                                         'click': evFn
1227                                 }, [ _('Edit') ])
1228                         );
1229                 }
1230
1231                 if (more_label) {
1232                         dom.append(tdEl.lastElementChild,
1233                                 E('button', {
1234                                         'title': more_label,
1235                                         'class': 'cbi-button cbi-button-edit',
1236                                         'click': ui.createHandlerFn(this, 'renderMoreOptionsModal', section_id)
1237                                 }, [ more_label ])
1238                         );
1239                 }
1240
1241                 if (this.addremove) {
1242                         var btn_title = this.titleFn('removebtntitle', section_id);
1243
1244                         dom.append(tdEl.lastElementChild,
1245                                 E('button', {
1246                                         'title': btn_title || _('Delete'),
1247                                         'class': 'cbi-button cbi-button-remove',
1248                                         'click': ui.createHandlerFn(this, 'handleRemove', section_id)
1249                                 }, [ btn_title || _('Delete') ])
1250                         );
1251                 }
1252
1253                 return tdEl;
1254         },
1255
1256         handleDragInit: function(ev) {
1257                 scope.dragState = { node: ev.target };
1258         },
1259
1260         handleDragStart: function(ev) {
1261                 if (!scope.dragState || !scope.dragState.node.classList.contains('drag-handle')) {
1262                         scope.dragState = null;
1263                         ev.preventDefault();
1264                         return false;
1265                 }
1266
1267                 scope.dragState.node = dom.parent(scope.dragState.node, '.tr');
1268                 ev.dataTransfer.setData('text', 'drag');
1269                 ev.target.style.opacity = 0.4;
1270         },
1271
1272         handleDragOver: function(ev) {
1273                 var n = scope.dragState.targetNode,
1274                     r = scope.dragState.rect,
1275                     t = r.top + r.height / 2;
1276
1277                 if (ev.clientY <= t) {
1278                         n.classList.remove('drag-over-below');
1279                         n.classList.add('drag-over-above');
1280                 }
1281                 else {
1282                         n.classList.remove('drag-over-above');
1283                         n.classList.add('drag-over-below');
1284                 }
1285
1286                 ev.dataTransfer.dropEffect = 'move';
1287                 ev.preventDefault();
1288                 return false;
1289         },
1290
1291         handleDragEnter: function(ev) {
1292                 scope.dragState.rect = ev.currentTarget.getBoundingClientRect();
1293                 scope.dragState.targetNode = ev.currentTarget;
1294         },
1295
1296         handleDragLeave: function(ev) {
1297                 ev.currentTarget.classList.remove('drag-over-above');
1298                 ev.currentTarget.classList.remove('drag-over-below');
1299         },
1300
1301         handleDragEnd: function(ev) {
1302                 var n = ev.target;
1303
1304                 n.style.opacity = '';
1305                 n.classList.add('flash');
1306                 n.parentNode.querySelectorAll('.drag-over-above, .drag-over-below')
1307                         .forEach(function(tr) {
1308                                 tr.classList.remove('drag-over-above');
1309                                 tr.classList.remove('drag-over-below');
1310                         });
1311         },
1312
1313         handleDrop: function(ev) {
1314                 var s = scope.dragState;
1315
1316                 if (s.node && s.targetNode) {
1317                         var config_name = this.uciconfig || this.map.config,
1318                             ref_node = s.targetNode,
1319                             after = false;
1320
1321                     if (ref_node.classList.contains('drag-over-below')) {
1322                         ref_node = ref_node.nextElementSibling;
1323                         after = true;
1324                     }
1325
1326                     var sid1 = s.node.getAttribute('data-sid'),
1327                         sid2 = s.targetNode.getAttribute('data-sid');
1328
1329                     s.node.parentNode.insertBefore(s.node, ref_node);
1330                     this.map.data.move(config_name, sid1, sid2, after);
1331                 }
1332
1333                 scope.dragState = null;
1334                 ev.target.style.opacity = '';
1335                 ev.stopPropagation();
1336                 ev.preventDefault();
1337                 return false;
1338         },
1339
1340         handleModalCancel: function(modalMap, ev) {
1341                 return Promise.resolve(ui.hideModal());
1342         },
1343
1344         handleModalSave: function(modalMap, ev) {
1345                 return modalMap.save()
1346                         .then(L.bind(this.map.load, this.map))
1347                         .then(L.bind(this.map.reset, this.map))
1348                         .then(ui.hideModal)
1349                         .catch(function() {});
1350         },
1351
1352         addModalOptions: function(modalSection, section_id, ev) {
1353
1354         },
1355
1356         renderMoreOptionsModal: function(section_id, ev) {
1357                 var parent = this.map,
1358                     title = parent.title,
1359                     name = null,
1360                     m = new CBIMap(this.map.config, null, null),
1361                     s = m.section(CBINamedSection, section_id, this.sectiontype);
1362
1363                 m.parent = parent;
1364
1365                 s.tabs = this.tabs;
1366                 s.tab_names = this.tab_names;
1367
1368                 if ((name = this.titleFn('modaltitle', section_id)) != null)
1369                         title = name;
1370                 else if ((name = this.titleFn('sectiontitle', section_id)) != null)
1371                         title = '%s - %s'.format(parent.title, name);
1372                 else if (!this.anonymous)
1373                         title = '%s - %s'.format(parent.title, section_id);
1374
1375                 for (var i = 0; i < this.children.length; i++) {
1376                         var o1 = this.children[i];
1377
1378                         if (o1.modalonly === false)
1379                                 continue;
1380
1381                         var o2 = s.option(o1.constructor, o1.option, o1.title, o1.description);
1382
1383                         for (var k in o1) {
1384                                 if (!o1.hasOwnProperty(k))
1385                                         continue;
1386
1387                                 switch (k) {
1388                                 case 'map':
1389                                 case 'section':
1390                                 case 'option':
1391                                 case 'title':
1392                                 case 'description':
1393                                         continue;
1394
1395                                 default:
1396                                         o2[k] = o1[k];
1397                                 }
1398                         }
1399                 }
1400
1401                 return Promise.resolve(this.addModalOptions(s, section_id, ev)).then(L.bind(m.render, m)).then(L.bind(function(nodes) {
1402                         ui.showModal(title, [
1403                                 nodes,
1404                                 E('div', { 'class': 'right' }, [
1405                                         E('button', {
1406                                                 'class': 'btn',
1407                                                 'click': ui.createHandlerFn(this, 'handleModalCancel', m)
1408                                         }, [ _('Dismiss') ]), ' ',
1409                                         E('button', {
1410                                                 'class': 'cbi-button cbi-button-positive important',
1411                                                 'click': ui.createHandlerFn(this, 'handleModalSave', m)
1412                                         }, [ _('Save') ])
1413                                 ])
1414                         ], 'cbi-modal');
1415                 }, this)).catch(L.error);
1416         }
1417 });
1418
1419 var CBIGridSection = CBITableSection.extend({
1420         tab: function(name, title, description) {
1421                 CBIAbstractSection.prototype.tab.call(this, name, title, description);
1422         },
1423
1424         handleAdd: function(ev, name) {
1425                 var config_name = this.uciconfig || this.map.config,
1426                     section_id = this.map.data.add(config_name, this.sectiontype, name);
1427
1428                 this.addedSection = section_id;
1429                 return this.renderMoreOptionsModal(section_id);
1430         },
1431
1432         handleModalSave: function(/* ... */) {
1433                 return this.super('handleModalSave', arguments)
1434                         .then(L.bind(function() { this.addedSection = null }, this));
1435         },
1436
1437         handleModalCancel: function(/* ... */) {
1438                 var config_name = this.uciconfig || this.map.config;
1439
1440                 if (this.addedSection != null) {
1441                         this.map.data.remove(config_name, this.addedSection);
1442                         this.addedSection = null;
1443                 }
1444
1445                 return this.super('handleModalCancel', arguments);
1446         },
1447
1448         renderUCISection: function(section_id) {
1449                 return this.renderOptions(null, section_id);
1450         },
1451
1452         renderChildren: function(tab_name, section_id, in_table) {
1453                 var tasks = [], index = 0;
1454
1455                 for (var i = 0, opt; (opt = this.children[i]) != null; i++) {
1456                         if (opt.disable || opt.modalonly)
1457                                 continue;
1458
1459                         if (opt.editable)
1460                                 tasks.push(opt.render(index++, section_id, in_table));
1461                         else
1462                                 tasks.push(this.renderTextValue(section_id, opt));
1463                 }
1464
1465                 return Promise.all(tasks);
1466         },
1467
1468         renderTextValue: function(section_id, opt) {
1469                 var title = this.stripTags(opt.title).trim(),
1470                     descr = this.stripTags(opt.description).trim(),
1471                     value = opt.textvalue(section_id);
1472
1473                 return E('div', {
1474                         'class': 'td cbi-value-field',
1475                         'data-title': (title != '') ? title : null,
1476                         'data-description': (descr != '') ? descr : null,
1477                         'data-name': opt.option,
1478                         'data-widget': opt.typename || opt.__name__
1479                 }, (value != null) ? value : E('em', _('none')));
1480         },
1481
1482         renderRowActions: function(section_id) {
1483                 return this.super('renderRowActions', [ section_id, _('Edit') ]);
1484         },
1485
1486         parse: function() {
1487                 var section_ids = this.cfgsections(),
1488                     tasks = [];
1489
1490                 if (Array.isArray(this.children)) {
1491                         for (var i = 0; i < section_ids.length; i++) {
1492                                 for (var j = 0; j < this.children.length; j++) {
1493                                         if (!this.children[j].editable || this.children[j].modalonly)
1494                                                 continue;
1495
1496                                         tasks.push(this.children[j].parse(section_ids[i]));
1497                                 }
1498                         }
1499                 }
1500
1501                 return Promise.all(tasks);
1502         }
1503 });
1504
1505 var CBINamedSection = CBIAbstractSection.extend({
1506         __name__: 'CBI.NamedSection',
1507         __init__: function(map, section_id /*, ... */) {
1508                 this.super('__init__', this.varargs(arguments, 2, map));
1509
1510                 this.section = section_id;
1511         },
1512
1513         cfgsections: function() {
1514                 return [ this.section ];
1515         },
1516
1517         handleAdd: function(ev) {
1518                 var section_id = this.section,
1519                     config_name = this.uciconfig || this.map.config;
1520
1521                 this.map.data.add(config_name, this.sectiontype, section_id);
1522                 return this.map.save(null, true);
1523         },
1524
1525         handleRemove: function(ev) {
1526                 var section_id = this.section,
1527                     config_name = this.uciconfig || this.map.config;
1528
1529                 this.map.data.remove(config_name, section_id);
1530                 return this.map.save(null, true);
1531         },
1532
1533         renderContents: function(data) {
1534                 var ucidata = data[0], nodes = data[1],
1535                     section_id = this.section,
1536                     config_name = this.uciconfig || this.map.config,
1537                     sectionEl = E('div', {
1538                                 'id': ucidata ? null : 'cbi-%s-%s'.format(config_name, section_id),
1539                                 'class': 'cbi-section',
1540                                 'data-tab': (this.map.tabbed && !this.parentoption) ? this.sectiontype : null,
1541                                 'data-tab-title': (this.map.tabbed && !this.parentoption) ? this.title || this.sectiontype : null
1542                         });
1543
1544                 if (typeof(this.title) === 'string' && this.title !== '')
1545                         sectionEl.appendChild(E('legend', {}, this.title));
1546
1547                 if (typeof(this.description) === 'string' && this.description !== '')
1548                         sectionEl.appendChild(E('div', { 'class': 'cbi-section-descr' }, this.description));
1549
1550                 if (ucidata) {
1551                         if (this.addremove) {
1552                                 sectionEl.appendChild(
1553                                         E('div', { 'class': 'cbi-section-remove right' },
1554                                                 E('button', {
1555                                                         'class': 'cbi-button',
1556                                                         'click': ui.createHandlerFn(this, 'handleRemove')
1557                                                 }, [ _('Delete') ])));
1558                         }
1559
1560                         sectionEl.appendChild(E('div', {
1561                                 'id': 'cbi-%s-%s'.format(config_name, section_id),
1562                                 'class': this.tabs
1563                                         ? 'cbi-section-node cbi-section-node-tabbed' : 'cbi-section-node',
1564                                 'data-section-id': section_id
1565                         }, nodes));
1566                 }
1567                 else if (this.addremove) {
1568                         sectionEl.appendChild(
1569                                 E('button', {
1570                                         'class': 'cbi-button cbi-button-add',
1571                                         'click': ui.createHandlerFn(this, 'handleAdd')
1572                                 }, [ _('Add') ]));
1573                 }
1574
1575                 dom.bindClassInstance(sectionEl, this);
1576
1577                 return sectionEl;
1578         },
1579
1580         render: function() {
1581                 var config_name = this.uciconfig || this.map.config,
1582                     section_id = this.section;
1583
1584                 return Promise.all([
1585                         this.map.data.get(config_name, section_id),
1586                         this.renderUCISection(section_id)
1587                 ]).then(this.renderContents.bind(this));
1588         }
1589 });
1590
1591 var CBIValue = CBIAbstractValue.extend({
1592         __name__: 'CBI.Value',
1593
1594         value: function(key, val) {
1595                 this.keylist = this.keylist || [];
1596                 this.keylist.push(String(key));
1597
1598                 this.vallist = this.vallist || [];
1599                 this.vallist.push(dom.elem(val) ? val : String(val != null ? val : key));
1600         },
1601
1602         render: function(option_index, section_id, in_table) {
1603                 return Promise.resolve(this.cfgvalue(section_id))
1604                         .then(this.renderWidget.bind(this, section_id, option_index))
1605                         .then(this.renderFrame.bind(this, section_id, in_table, option_index));
1606         },
1607
1608         renderFrame: function(section_id, in_table, option_index, nodes) {
1609                 var config_name = this.uciconfig || this.section.uciconfig || this.map.config,
1610                     depend_list = this.transformDepList(section_id),
1611                     optionEl;
1612
1613                 if (in_table) {
1614                         var title = this.stripTags(this.title).trim();
1615                         optionEl = E('div', {
1616                                 'class': 'td cbi-value-field',
1617                                 'data-title': (title != '') ? title : null,
1618                                 'data-description': this.stripTags(this.description).trim(),
1619                                 'data-name': this.option,
1620                                 'data-widget': this.typename || (this.template ? this.template.replace(/^.+\//, '') : null) || this.__name__
1621                         }, E('div', {
1622                                 'id': 'cbi-%s-%s-%s'.format(config_name, section_id, this.option),
1623                                 'data-index': option_index,
1624                                 'data-depends': depend_list,
1625                                 'data-field': this.cbid(section_id)
1626                         }));
1627                 }
1628                 else {
1629                         optionEl = E('div', {
1630                                 'class': 'cbi-value',
1631                                 'id': 'cbi-%s-%s-%s'.format(config_name, section_id, this.option),
1632                                 'data-index': option_index,
1633                                 'data-depends': depend_list,
1634                                 'data-field': this.cbid(section_id),
1635                                 'data-name': this.option,
1636                                 'data-widget': this.typename || (this.template ? this.template.replace(/^.+\//, '') : null) || this.__name__
1637                         });
1638
1639                         if (this.last_child)
1640                                 optionEl.classList.add('cbi-value-last');
1641
1642                         if (typeof(this.title) === 'string' && this.title !== '') {
1643                                 optionEl.appendChild(E('label', {
1644                                         'class': 'cbi-value-title',
1645                                         'for': 'widget.cbid.%s.%s.%s'.format(config_name, section_id, this.option),
1646                                         'click': function(ev) {
1647                                                 var node = ev.currentTarget,
1648                                                     elem = node.nextElementSibling.querySelector('#' + node.getAttribute('for')) || node.nextElementSibling.querySelector('[data-widget-id="' + node.getAttribute('for') + '"]');
1649
1650                                                 if (elem) {
1651                                                         elem.click();
1652                                                         elem.focus();
1653                                                 }
1654                                         }
1655                                 },
1656                                 this.titleref ? E('a', {
1657                                         'class': 'cbi-title-ref',
1658                                         'href': this.titleref,
1659                                         'title': this.titledesc || _('Go to relevant configuration page')
1660                                 }, this.title) : this.title));
1661
1662                                 optionEl.appendChild(E('div', { 'class': 'cbi-value-field' }));
1663                         }
1664                 }
1665
1666                 if (nodes)
1667                         (optionEl.lastChild || optionEl).appendChild(nodes);
1668
1669                 if (!in_table && typeof(this.description) === 'string' && this.description !== '')
1670                         dom.append(optionEl.lastChild || optionEl,
1671                                 E('div', { 'class': 'cbi-value-description' }, this.description));
1672
1673                 if (depend_list && depend_list.length)
1674                         optionEl.classList.add('hidden');
1675
1676                 optionEl.addEventListener('widget-change',
1677                         L.bind(this.map.checkDepends, this.map));
1678
1679                 dom.bindClassInstance(optionEl, this);
1680
1681                 return optionEl;
1682         },
1683
1684         renderWidget: function(section_id, option_index, cfgvalue) {
1685                 var value = (cfgvalue != null) ? cfgvalue : this.default,
1686                     choices = this.transformChoices(),
1687                     widget;
1688
1689                 if (choices) {
1690                         var placeholder = (this.optional || this.rmempty)
1691                                 ? E('em', _('unspecified')) : _('-- Please choose --');
1692
1693                         widget = new ui.Combobox(Array.isArray(value) ? value.join(' ') : value, choices, {
1694                                 id: this.cbid(section_id),
1695                                 sort: this.keylist,
1696                                 optional: this.optional || this.rmempty,
1697                                 datatype: this.datatype,
1698                                 select_placeholder: this.placeholder || placeholder,
1699                                 validate: L.bind(this.validate, this, section_id)
1700                         });
1701                 }
1702                 else {
1703                         widget = new ui.Textfield(Array.isArray(value) ? value.join(' ') : value, {
1704                                 id: this.cbid(section_id),
1705                                 password: this.password,
1706                                 optional: this.optional || this.rmempty,
1707                                 datatype: this.datatype,
1708                                 placeholder: this.placeholder,
1709                                 validate: L.bind(this.validate, this, section_id)
1710                         });
1711                 }
1712
1713                 return widget.render();
1714         }
1715 });
1716
1717 var CBIDynamicList = CBIValue.extend({
1718         __name__: 'CBI.DynamicList',
1719
1720         renderWidget: function(section_id, option_index, cfgvalue) {
1721                 var value = (cfgvalue != null) ? cfgvalue : this.default,
1722                     choices = this.transformChoices(),
1723                     items = L.toArray(value);
1724
1725                 var widget = new ui.DynamicList(items, choices, {
1726                         id: this.cbid(section_id),
1727                         sort: this.keylist,
1728                         optional: this.optional || this.rmempty,
1729                         datatype: this.datatype,
1730                         placeholder: this.placeholder,
1731                         validate: L.bind(this.validate, this, section_id)
1732                 });
1733
1734                 return widget.render();
1735         },
1736 });
1737
1738 var CBIListValue = CBIValue.extend({
1739         __name__: 'CBI.ListValue',
1740
1741         __init__: function() {
1742                 this.super('__init__', arguments);
1743                 this.widget = 'select';
1744                 this.deplist = [];
1745         },
1746
1747         renderWidget: function(section_id, option_index, cfgvalue) {
1748                 var choices = this.transformChoices();
1749                 var widget = new ui.Select((cfgvalue != null) ? cfgvalue : this.default, choices, {
1750                         id: this.cbid(section_id),
1751                         size: this.size,
1752                         sort: this.keylist,
1753                         optional: this.optional,
1754                         placeholder: this.placeholder,
1755                         validate: L.bind(this.validate, this, section_id)
1756                 });
1757
1758                 return widget.render();
1759         },
1760 });
1761
1762 var CBIFlagValue = CBIValue.extend({
1763         __name__: 'CBI.FlagValue',
1764
1765         __init__: function() {
1766                 this.super('__init__', arguments);
1767
1768                 this.enabled = '1';
1769                 this.disabled = '0';
1770                 this.default = this.disabled;
1771         },
1772
1773         renderWidget: function(section_id, option_index, cfgvalue) {
1774                 var widget = new ui.Checkbox((cfgvalue != null) ? cfgvalue : this.default, {
1775                         id: this.cbid(section_id),
1776                         value_enabled: this.enabled,
1777                         value_disabled: this.disabled,
1778                         validate: L.bind(this.validate, this, section_id)
1779                 });
1780
1781                 return widget.render();
1782         },
1783
1784         formvalue: function(section_id) {
1785                 var elem = this.getUIElement(section_id),
1786                     checked = elem ? elem.isChecked() : false;
1787                 return checked ? this.enabled : this.disabled;
1788         },
1789
1790         textvalue: function(section_id) {
1791                 var cval = this.cfgvalue(section_id);
1792
1793                 if (cval == null)
1794                         cval = this.default;
1795
1796                 return (cval == this.enabled) ? _('Yes') : _('No');
1797         },
1798
1799         parse: function(section_id) {
1800                 if (this.isActive(section_id)) {
1801                         var fval = this.formvalue(section_id);
1802
1803                         if (!this.isValid(section_id))
1804                                 return Promise.reject();
1805
1806                         if (fval == this.default && (this.optional || this.rmempty))
1807                                 return Promise.resolve(this.remove(section_id));
1808                         else
1809                                 return Promise.resolve(this.write(section_id, fval));
1810                 }
1811                 else {
1812                         return Promise.resolve(this.remove(section_id));
1813                 }
1814         },
1815 });
1816
1817 var CBIMultiValue = CBIDynamicList.extend({
1818         __name__: 'CBI.MultiValue',
1819
1820         __init__: function() {
1821                 this.super('__init__', arguments);
1822                 this.placeholder = _('-- Please choose --');
1823         },
1824
1825         renderWidget: function(section_id, option_index, cfgvalue) {
1826                 var value = (cfgvalue != null) ? cfgvalue : this.default,
1827                     choices = this.transformChoices();
1828
1829                 var widget = new ui.Dropdown(L.toArray(value), choices, {
1830                         id: this.cbid(section_id),
1831                         sort: this.keylist,
1832                         multiple: true,
1833                         optional: this.optional || this.rmempty,
1834                         select_placeholder: this.placeholder,
1835                         display_items: this.display_size || this.size || 3,
1836                         dropdown_items: this.dropdown_size || this.size || -1,
1837                         validate: L.bind(this.validate, this, section_id)
1838                 });
1839
1840                 return widget.render();
1841         },
1842 });
1843
1844 var CBITextValue = CBIValue.extend({
1845         __name__: 'CBI.TextValue',
1846
1847         value: null,
1848
1849         renderWidget: function(section_id, option_index, cfgvalue) {
1850                 var value = (cfgvalue != null) ? cfgvalue : this.default;
1851
1852                 var widget = new ui.Textarea(value, {
1853                         id: this.cbid(section_id),
1854                         optional: this.optional || this.rmempty,
1855                         placeholder: this.placeholder,
1856                         monospace: this.monospace,
1857                         cols: this.cols,
1858                         rows: this.rows,
1859                         wrap: this.wrap,
1860                         validate: L.bind(this.validate, this, section_id)
1861                 });
1862
1863                 return widget.render();
1864         }
1865 });
1866
1867 var CBIDummyValue = CBIValue.extend({
1868         __name__: 'CBI.DummyValue',
1869
1870         renderWidget: function(section_id, option_index, cfgvalue) {
1871                 var value = (cfgvalue != null) ? cfgvalue : this.default,
1872                     hiddenEl = new ui.Hiddenfield(value, { id: this.cbid(section_id) }),
1873                     outputEl = E('div');
1874
1875                 if (this.href)
1876                         outputEl.appendChild(E('a', { 'href': this.href }));
1877
1878                 dom.append(outputEl.lastChild || outputEl,
1879                         this.rawhtml ? value : [ value ]);
1880
1881                 return E([
1882                         outputEl,
1883                         hiddenEl.render()
1884                 ]);
1885         },
1886
1887         remove: function() {},
1888         write: function() {}
1889 });
1890
1891 var CBIButtonValue = CBIValue.extend({
1892         __name__: 'CBI.ButtonValue',
1893
1894         renderWidget: function(section_id, option_index, cfgvalue) {
1895                 var value = (cfgvalue != null) ? cfgvalue : this.default,
1896                     hiddenEl = new ui.Hiddenfield(value, { id: this.cbid(section_id) }),
1897                     outputEl = E('div'),
1898                     btn_title = this.titleFn('inputtitle', section_id) || this.titleFn('title', section_id);
1899
1900                 if (value !== false)
1901                         dom.content(outputEl, [
1902                                 E('button', {
1903                                         'class': 'cbi-button cbi-button-%s'.format(this.inputstyle || 'button'),
1904                                         'click': ui.createHandlerFn(this, function(section_id, ev) {
1905                                                 if (this.onclick)
1906                                                         return this.onclick(ev, section_id);
1907
1908                                                 ev.currentTarget.parentNode.nextElementSibling.value = value;
1909                                                 return this.map.save();
1910                                         }, section_id)
1911                                 }, [ btn_title ])
1912                         ]);
1913                 else
1914                         dom.content(outputEl, ' - ');
1915
1916                 return E([
1917                         outputEl,
1918                         hiddenEl.render()
1919                 ]);
1920         }
1921 });
1922
1923 var CBIHiddenValue = CBIValue.extend({
1924         __name__: 'CBI.HiddenValue',
1925
1926         renderWidget: function(section_id, option_index, cfgvalue) {
1927                 var widget = new ui.Hiddenfield((cfgvalue != null) ? cfgvalue : this.default, {
1928                         id: this.cbid(section_id)
1929                 });
1930
1931                 return widget.render();
1932         }
1933 });
1934
1935 var CBIFileUpload = CBIValue.extend({
1936         __name__: 'CBI.FileSelect',
1937
1938         __init__: function(/* ... */) {
1939                 this.super('__init__', arguments);
1940
1941                 this.show_hidden = false;
1942                 this.enable_upload = true;
1943                 this.enable_remove = true;
1944                 this.root_directory = '/etc/luci-uploads';
1945         },
1946
1947         renderWidget: function(section_id, option_index, cfgvalue) {
1948                 var browserEl = new ui.FileUpload((cfgvalue != null) ? cfgvalue : this.default, {
1949                         id: this.cbid(section_id),
1950                         name: this.cbid(section_id),
1951                         show_hidden: this.show_hidden,
1952                         enable_upload: this.enable_upload,
1953                         enable_remove: this.enable_remove,
1954                         root_directory: this.root_directory
1955                 });
1956
1957                 return browserEl.render();
1958         }
1959 });
1960
1961 var CBISectionValue = CBIValue.extend({
1962         __name__: 'CBI.ContainerValue',
1963         __init__: function(map, section, option, cbiClass /*, ... */) {
1964                 this.super('__init__', [map, section, option]);
1965
1966                 if (!CBIAbstractSection.isSubclass(cbiClass))
1967                         throw 'Sub section must be a descendent of CBIAbstractSection';
1968
1969                 this.subsection = cbiClass.instantiate(this.varargs(arguments, 4, this.map));
1970                 this.subsection.parentoption = this;
1971         },
1972
1973         load: function(section_id) {
1974                 return this.subsection.load();
1975         },
1976
1977         parse: function(section_id) {
1978                 return this.subsection.parse();
1979         },
1980
1981         renderWidget: function(section_id, option_index, cfgvalue) {
1982                 return this.subsection.render();
1983         },
1984
1985         checkDepends: function(section_id) {
1986                 this.subsection.checkDepends();
1987                 return CBIValue.prototype.checkDepends.apply(this, [ section_id ]);
1988         },
1989
1990         write: function() {},
1991         remove: function() {},
1992         cfgvalue: function() { return null },
1993         formvalue: function() { return null }
1994 });
1995
1996 return baseclass.extend({
1997         Map: CBIMap,
1998         JSONMap: CBIJSONMap,
1999         AbstractSection: CBIAbstractSection,
2000         AbstractValue: CBIAbstractValue,
2001
2002         TypedSection: CBITypedSection,
2003         TableSection: CBITableSection,
2004         GridSection: CBIGridSection,
2005         NamedSection: CBINamedSection,
2006
2007         Value: CBIValue,
2008         DynamicList: CBIDynamicList,
2009         ListValue: CBIListValue,
2010         Flag: CBIFlagValue,
2011         MultiValue: CBIMultiValue,
2012         TextValue: CBITextValue,
2013         DummyValue: CBIDummyValue,
2014         Button: CBIButtonValue,
2015         HiddenValue: CBIHiddenValue,
2016         FileUpload: CBIFileUpload,
2017         SectionValue: CBISectionValue
2018 });