luci-base: replace uci change pages with client side modal dialog
[oweals/luci.git] / modules / luci-base / htdocs / luci-static / resources / ui.js
1 'use strict';
2 'require uci';
3
4 var modalDiv = null,
5     tooltipDiv = null,
6     tooltipTimeout = null;
7
8 var UIElement = L.Class.extend({
9         getValue: function() {
10                 if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input'))
11                         return this.node.value;
12
13                 return null;
14         },
15
16         setValue: function(value) {
17                 if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input'))
18                         this.node.value = value;
19         },
20
21         isValid: function() {
22                 return true;
23         },
24
25         registerEvents: function(targetNode, synevent, events) {
26                 var dispatchFn = L.bind(function(ev) {
27                         this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true }));
28                 }, this);
29
30                 for (var i = 0; i < events.length; i++)
31                         targetNode.addEventListener(events[i], dispatchFn);
32         },
33
34         setUpdateEvents: function(targetNode /*, ... */) {
35                 this.registerEvents(targetNode, 'widget-update', this.varargs(arguments, 1));
36         },
37
38         setChangeEvents: function(targetNode /*, ... */) {
39                 this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
40         }
41 });
42
43 var UIDropdown = UIElement.extend({
44         __init__: function(value, choices, options) {
45                 if (typeof(choices) != 'object')
46                         choices = {};
47
48                 if (!Array.isArray(value))
49                         this.values = (value != null) ? [ value ] : [];
50                 else
51                         this.values = value;
52
53                 this.choices = choices;
54                 this.options = Object.assign({
55                         sort:               true,
56                         multi:              Array.isArray(value),
57                         optional:           true,
58                         select_placeholder: _('-- Please choose --'),
59                         custom_placeholder: _('-- custom --'),
60                         display_items:      3,
61                         dropdown_items:     5,
62                         create:             false,
63                         create_query:       '.create-item-input',
64                         create_template:    'script[type="item-template"]'
65                 }, options);
66         },
67
68         render: function() {
69                 var sb = E('div', {
70                         'id': this.options.id,
71                         'class': 'cbi-dropdown',
72                         'multiple': this.options.multi ? '' : null,
73                         'optional': this.options.optional ? '' : null,
74                 }, E('ul'));
75
76                 var keys = Object.keys(this.choices);
77
78                 if (this.options.sort === true)
79                         keys.sort();
80                 else if (Array.isArray(this.options.sort))
81                         keys = this.options.sort;
82
83                 if (this.options.create)
84                         for (var i = 0; i < this.values.length; i++)
85                                 if (!this.choices.hasOwnProperty(this.values[i]))
86                                         keys.push(this.values[i]);
87
88                 for (var i = 0; i < keys.length; i++)
89                         sb.lastElementChild.appendChild(E('li', {
90                                 'data-value': keys[i],
91                                 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
92                         }, this.choices[keys[i]] || keys[i]));
93
94                 if (this.options.create) {
95                         var createEl = E('input', {
96                                 'type': 'text',
97                                 'class': 'create-item-input',
98                                 'placeholder': this.options.custom_placeholder || this.options.placeholder
99                         });
100
101                         if (this.options.datatype)
102                                 L.ui.addValidator(createEl, this.options.datatype, true, 'blur', 'keyup');
103
104                         sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
105                 }
106
107                 return this.bind(sb);
108         },
109
110         bind: function(sb) {
111                 var o = this.options;
112
113                 o.multi = sb.hasAttribute('multiple');
114                 o.optional = sb.hasAttribute('optional');
115                 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
116                 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
117                 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
118                 o.create_query = sb.getAttribute('item-create') || o.create_query;
119                 o.create_template = sb.getAttribute('item-template') || o.create_template;
120
121                 var ul = sb.querySelector('ul'),
122                     more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
123                     open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, '▾')),
124                     canary = sb.appendChild(E('div')),
125                     create = sb.querySelector(this.options.create_query),
126                     ndisplay = this.options.display_items,
127                     n = 0;
128
129                 if (this.options.multi) {
130                         var items = ul.querySelectorAll('li');
131
132                         for (var i = 0; i < items.length; i++) {
133                                 this.transformItem(sb, items[i]);
134
135                                 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
136                                         items[i].setAttribute('display', n++);
137                         }
138                 }
139                 else {
140                         if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
141                                 var placeholder = E('li', { placeholder: '' },
142                                         this.options.select_placeholder || this.options.placeholder);
143
144                                 ul.firstChild
145                                         ? ul.insertBefore(placeholder, ul.firstChild)
146                                         : ul.appendChild(placeholder);
147                         }
148
149                         var items = ul.querySelectorAll('li'),
150                             sel = sb.querySelectorAll('[selected]');
151
152                         sel.forEach(function(s) {
153                                 s.removeAttribute('selected');
154                         });
155
156                         var s = sel[0] || items[0];
157                         if (s) {
158                                 s.setAttribute('selected', '');
159                                 s.setAttribute('display', n++);
160                         }
161
162                         ndisplay--;
163                 }
164
165                 this.saveValues(sb, ul);
166
167                 ul.setAttribute('tabindex', -1);
168                 sb.setAttribute('tabindex', 0);
169
170                 if (ndisplay < 0)
171                         sb.setAttribute('more', '')
172                 else
173                         sb.removeAttribute('more');
174
175                 if (ndisplay == this.options.display_items)
176                         sb.setAttribute('empty', '')
177                 else
178                         sb.removeAttribute('empty');
179
180                 more.innerHTML = (ndisplay == this.options.display_items)
181                         ? (this.options.select_placeholder || this.options.placeholder) : '···';
182
183
184                 sb.addEventListener('click', this.handleClick.bind(this));
185                 sb.addEventListener('keydown', this.handleKeydown.bind(this));
186                 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
187                 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
188
189                 if ('ontouchstart' in window) {
190                         sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
191                         window.addEventListener('touchstart', this.closeAllDropdowns);
192                 }
193                 else {
194                         sb.addEventListener('mouseover', this.handleMouseover.bind(this));
195                         sb.addEventListener('focus', this.handleFocus.bind(this));
196
197                         canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
198
199                         window.addEventListener('mouseover', this.setFocus);
200                         window.addEventListener('click', this.closeAllDropdowns);
201                 }
202
203                 if (create) {
204                         create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
205                         create.addEventListener('focus', this.handleCreateFocus.bind(this));
206                         create.addEventListener('blur', this.handleCreateBlur.bind(this));
207
208                         var li = findParent(create, 'li');
209
210                         li.setAttribute('unselectable', '');
211                         li.addEventListener('click', this.handleCreateClick.bind(this));
212                 }
213
214                 this.node = sb;
215
216                 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
217                 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
218
219                 L.dom.bindClassInstance(sb, this);
220
221                 return sb;
222         },
223
224         openDropdown: function(sb) {
225                 var st = window.getComputedStyle(sb, null),
226                     ul = sb.querySelector('ul'),
227                     li = ul.querySelectorAll('li'),
228                     fl = findParent(sb, '.cbi-value-field'),
229                     sel = ul.querySelector('[selected]'),
230                     rect = sb.getBoundingClientRect(),
231                     items = Math.min(this.options.dropdown_items, li.length);
232
233                 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
234                         s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
235                 });
236
237                 sb.setAttribute('open', '');
238
239                 var pv = ul.cloneNode(true);
240                     pv.classList.add('preview');
241
242                 if (fl)
243                         fl.classList.add('cbi-dropdown-open');
244
245                 if ('ontouchstart' in window) {
246                         var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
247                             vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
248                             scrollFrom = window.pageYOffset,
249                             scrollTo = scrollFrom + rect.top - vpHeight * 0.5,
250                             start = null;
251
252                         ul.style.top = sb.offsetHeight + 'px';
253                         ul.style.left = -rect.left + 'px';
254                         ul.style.right = (rect.right - vpWidth) + 'px';
255                         ul.style.maxHeight = (vpHeight * 0.5) + 'px';
256                         ul.style.WebkitOverflowScrolling = 'touch';
257
258                         var scrollStep = function(timestamp) {
259                                 if (!start) {
260                                         start = timestamp;
261                                         ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
262                                 }
263
264                                 var duration = Math.max(timestamp - start, 1);
265                                 if (duration < 100) {
266                                         document.body.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
267                                         window.requestAnimationFrame(scrollStep);
268                                 }
269                                 else {
270                                         document.body.scrollTop = scrollTo;
271                                 }
272                         };
273
274                         window.requestAnimationFrame(scrollStep);
275                 }
276                 else {
277                         ul.style.maxHeight = '1px';
278                         ul.style.top = ul.style.bottom = '';
279
280                         window.requestAnimationFrame(function() {
281                                 var height = items * li[Math.max(0, li.length - 2)].offsetHeight;
282
283                                 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
284                                 ul.style[((rect.top + rect.height + height) > window.innerHeight) ? 'bottom' : 'top'] = rect.height + 'px';
285                                 ul.style.maxHeight = height + 'px';
286                         });
287                 }
288
289                 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
290                 for (var i = 0; i < cboxes.length; i++) {
291                         cboxes[i].checked = true;
292                         cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
293                 };
294
295                 ul.classList.add('dropdown');
296
297                 sb.insertBefore(pv, ul.nextElementSibling);
298
299                 li.forEach(function(l) {
300                         l.setAttribute('tabindex', 0);
301                 });
302
303                 sb.lastElementChild.setAttribute('tabindex', 0);
304
305                 this.setFocus(sb, sel || li[0], true);
306         },
307
308         closeDropdown: function(sb, no_focus) {
309                 if (!sb.hasAttribute('open'))
310                         return;
311
312                 var pv = sb.querySelector('ul.preview'),
313                     ul = sb.querySelector('ul.dropdown'),
314                     li = ul.querySelectorAll('li'),
315                     fl = findParent(sb, '.cbi-value-field');
316
317                 li.forEach(function(l) { l.removeAttribute('tabindex'); });
318                 sb.lastElementChild.removeAttribute('tabindex');
319
320                 sb.removeChild(pv);
321                 sb.removeAttribute('open');
322                 sb.style.width = sb.style.height = '';
323
324                 ul.classList.remove('dropdown');
325                 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
326
327                 if (fl)
328                         fl.classList.remove('cbi-dropdown-open');
329
330                 if (!no_focus)
331                         this.setFocus(sb, sb);
332
333                 this.saveValues(sb, ul);
334         },
335
336         toggleItem: function(sb, li, force_state) {
337                 if (li.hasAttribute('unselectable'))
338                         return;
339
340                 if (this.options.multi) {
341                         var cbox = li.querySelector('input[type="checkbox"]'),
342                             items = li.parentNode.querySelectorAll('li'),
343                             label = sb.querySelector('ul.preview'),
344                             sel = li.parentNode.querySelectorAll('[selected]').length,
345                             more = sb.querySelector('.more'),
346                             ndisplay = this.options.display_items,
347                             n = 0;
348
349                         if (li.hasAttribute('selected')) {
350                                 if (force_state !== true) {
351                                         if (sel > 1 || this.options.optional) {
352                                                 li.removeAttribute('selected');
353                                                 cbox.checked = cbox.disabled = false;
354                                                 sel--;
355                                         }
356                                         else {
357                                                 cbox.disabled = true;
358                                         }
359                                 }
360                         }
361                         else {
362                                 if (force_state !== false) {
363                                         li.setAttribute('selected', '');
364                                         cbox.checked = true;
365                                         cbox.disabled = false;
366                                         sel++;
367                                 }
368                         }
369
370                         while (label && label.firstElementChild)
371                                 label.removeChild(label.firstElementChild);
372
373                         for (var i = 0; i < items.length; i++) {
374                                 items[i].removeAttribute('display');
375                                 if (items[i].hasAttribute('selected')) {
376                                         if (ndisplay-- > 0) {
377                                                 items[i].setAttribute('display', n++);
378                                                 if (label)
379                                                         label.appendChild(items[i].cloneNode(true));
380                                         }
381                                         var c = items[i].querySelector('input[type="checkbox"]');
382                                         if (c)
383                                                 c.disabled = (sel == 1 && !this.options.optional);
384                                 }
385                         }
386
387                         if (ndisplay < 0)
388                                 sb.setAttribute('more', '');
389                         else
390                                 sb.removeAttribute('more');
391
392                         if (ndisplay === this.options.display_items)
393                                 sb.setAttribute('empty', '');
394                         else
395                                 sb.removeAttribute('empty');
396
397                         more.innerHTML = (ndisplay === this.options.display_items)
398                                 ? (this.options.select_placeholder || this.options.placeholder) : '···';
399                 }
400                 else {
401                         var sel = li.parentNode.querySelector('[selected]');
402                         if (sel) {
403                                 sel.removeAttribute('display');
404                                 sel.removeAttribute('selected');
405                         }
406
407                         li.setAttribute('display', 0);
408                         li.setAttribute('selected', '');
409
410                         this.closeDropdown(sb, true);
411                 }
412
413                 this.saveValues(sb, li.parentNode);
414         },
415
416         transformItem: function(sb, li) {
417                 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
418                     label = E('label');
419
420                 while (li.firstChild)
421                         label.appendChild(li.firstChild);
422
423                 li.appendChild(cbox);
424                 li.appendChild(label);
425         },
426
427         saveValues: function(sb, ul) {
428                 var sel = ul.querySelectorAll('li[selected]'),
429                     div = sb.lastElementChild,
430                     name = this.options.name,
431                     strval = '',
432                     values = [];
433
434                 while (div.lastElementChild)
435                         div.removeChild(div.lastElementChild);
436
437                 sel.forEach(function (s) {
438                         if (s.hasAttribute('placeholder'))
439                                 return;
440
441                         var v = {
442                                 text: s.innerText,
443                                 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
444                                 element: s
445                         };
446
447                         div.appendChild(E('input', {
448                                 type: 'hidden',
449                                 name: name,
450                                 value: v.value
451                         }));
452
453                         values.push(v);
454
455                         strval += strval.length ? ' ' + v.value : v.value;
456                 });
457
458                 var detail = {
459                         instance: this,
460                         element: sb
461                 };
462
463                 if (this.options.multi)
464                         detail.values = values;
465                 else
466                         detail.value = values.length ? values[0] : null;
467
468                 sb.value = strval;
469
470                 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
471                         bubbles: true,
472                         detail: detail
473                 }));
474         },
475
476         setValues: function(sb, values) {
477                 var ul = sb.querySelector('ul');
478
479                 if (this.options.create) {
480                         for (var value in values) {
481                                 this.createItems(sb, value);
482
483                                 if (!this.options.multi)
484                                         break;
485                         }
486                 }
487
488                 if (this.options.multi) {
489                         var lis = ul.querySelectorAll('li[data-value]');
490                         for (var i = 0; i < lis.length; i++) {
491                                 var value = lis[i].getAttribute('data-value');
492                                 if (values === null || !(value in values))
493                                         this.toggleItem(sb, lis[i], false);
494                                 else
495                                         this.toggleItem(sb, lis[i], true);
496                         }
497                 }
498                 else {
499                         var ph = ul.querySelector('li[placeholder]');
500                         if (ph)
501                                 this.toggleItem(sb, ph);
502
503                         var lis = ul.querySelectorAll('li[data-value]');
504                         for (var i = 0; i < lis.length; i++) {
505                                 var value = lis[i].getAttribute('data-value');
506                                 if (values !== null && (value in values))
507                                         this.toggleItem(sb, lis[i]);
508                         }
509                 }
510         },
511
512         setFocus: function(sb, elem, scroll) {
513                 if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
514                         return;
515
516                 if (sb.target && findParent(sb.target, 'ul.dropdown'))
517                         return;
518
519                 document.querySelectorAll('.focus').forEach(function(e) {
520                         if (!matchesElem(e, 'input')) {
521                                 e.classList.remove('focus');
522                                 e.blur();
523                         }
524                 });
525
526                 if (elem) {
527                         elem.focus();
528                         elem.classList.add('focus');
529
530                         if (scroll)
531                                 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
532                 }
533         },
534
535         createItems: function(sb, value) {
536                 var sbox = this,
537                     val = (value || '').trim(),
538                     ul = sb.querySelector('ul');
539
540                 if (!sbox.options.multi)
541                         val = val.length ? [ val ] : [];
542                 else
543                         val = val.length ? val.split(/\s+/) : [];
544
545                 val.forEach(function(item) {
546                         var new_item = null;
547
548                         ul.childNodes.forEach(function(li) {
549                                 if (li.getAttribute && li.getAttribute('data-value') === item)
550                                         new_item = li;
551                         });
552
553                         if (!new_item) {
554                                 var markup,
555                                     tpl = sb.querySelector(sbox.options.create_template);
556
557                                 if (tpl)
558                                         markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
559                                 else
560                                         markup = '<li data-value="{{value}}">{{value}}</li>';
561
562                                 new_item = E(markup.replace(/{{value}}/g, item));
563
564                                 if (sbox.options.multi) {
565                                         sbox.transformItem(sb, new_item);
566                                 }
567                                 else {
568                                         var old = ul.querySelector('li[created]');
569                                         if (old)
570                                                 ul.removeChild(old);
571
572                                         new_item.setAttribute('created', '');
573                                 }
574
575                                 new_item = ul.insertBefore(new_item, ul.lastElementChild);
576                         }
577
578                         sbox.toggleItem(sb, new_item, true);
579                         sbox.setFocus(sb, new_item, true);
580                 });
581         },
582
583         closeAllDropdowns: function() {
584                 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
585                         s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
586                 });
587         },
588
589         handleClick: function(ev) {
590                 var sb = ev.currentTarget;
591
592                 if (!sb.hasAttribute('open')) {
593                         if (!matchesElem(ev.target, 'input'))
594                                 this.openDropdown(sb);
595                 }
596                 else {
597                         var li = findParent(ev.target, 'li');
598                         if (li && li.parentNode.classList.contains('dropdown'))
599                                 this.toggleItem(sb, li);
600                         else if (li && li.parentNode.classList.contains('preview'))
601                                 this.closeDropdown(sb);
602                 }
603
604                 ev.preventDefault();
605                 ev.stopPropagation();
606         },
607
608         handleKeydown: function(ev) {
609                 var sb = ev.currentTarget;
610
611                 if (matchesElem(ev.target, 'input'))
612                         return;
613
614                 if (!sb.hasAttribute('open')) {
615                         switch (ev.keyCode) {
616                         case 37:
617                         case 38:
618                         case 39:
619                         case 40:
620                                 this.openDropdown(sb);
621                                 ev.preventDefault();
622                         }
623                 }
624                 else {
625                         var active = findParent(document.activeElement, 'li');
626
627                         switch (ev.keyCode) {
628                         case 27:
629                                 this.closeDropdown(sb);
630                                 break;
631
632                         case 13:
633                                 if (active) {
634                                         if (!active.hasAttribute('selected'))
635                                                 this.toggleItem(sb, active);
636                                         this.closeDropdown(sb);
637                                         ev.preventDefault();
638                                 }
639                                 break;
640
641                         case 32:
642                                 if (active) {
643                                         this.toggleItem(sb, active);
644                                         ev.preventDefault();
645                                 }
646                                 break;
647
648                         case 38:
649                                 if (active && active.previousElementSibling) {
650                                         this.setFocus(sb, active.previousElementSibling);
651                                         ev.preventDefault();
652                                 }
653                                 break;
654
655                         case 40:
656                                 if (active && active.nextElementSibling) {
657                                         this.setFocus(sb, active.nextElementSibling);
658                                         ev.preventDefault();
659                                 }
660                                 break;
661                         }
662                 }
663         },
664
665         handleDropdownClose: function(ev) {
666                 var sb = ev.currentTarget;
667
668                 this.closeDropdown(sb, true);
669         },
670
671         handleDropdownSelect: function(ev) {
672                 var sb = ev.currentTarget,
673                     li = findParent(ev.target, 'li');
674
675                 if (!li)
676                         return;
677
678                 this.toggleItem(sb, li);
679                 this.closeDropdown(sb, true);
680         },
681
682         handleMouseover: function(ev) {
683                 var sb = ev.currentTarget;
684
685                 if (!sb.hasAttribute('open'))
686                         return;
687
688                 var li = findParent(ev.target, 'li');
689
690                 if (li && li.parentNode.classList.contains('dropdown'))
691                         this.setFocus(sb, li);
692         },
693
694         handleFocus: function(ev) {
695                 var sb = ev.currentTarget;
696
697                 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
698                         if (s !== sb || sb.hasAttribute('open'))
699                                 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
700                 });
701         },
702
703         handleCanaryFocus: function(ev) {
704                 this.closeDropdown(ev.currentTarget.parentNode);
705         },
706
707         handleCreateKeydown: function(ev) {
708                 var input = ev.currentTarget,
709                     sb = findParent(input, '.cbi-dropdown');
710
711                 switch (ev.keyCode) {
712                 case 13:
713                         ev.preventDefault();
714
715                         if (input.classList.contains('cbi-input-invalid'))
716                                 return;
717
718                         this.createItems(sb, input.value);
719                         input.value = '';
720                         input.blur();
721                         break;
722                 }
723         },
724
725         handleCreateFocus: function(ev) {
726                 var input = ev.currentTarget,
727                     cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
728                     sb = findParent(input, '.cbi-dropdown');
729
730                 if (cbox)
731                         cbox.checked = true;
732
733                 sb.setAttribute('locked-in', '');
734         },
735
736         handleCreateBlur: function(ev) {
737                 var input = ev.currentTarget,
738                     cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
739                     sb = findParent(input, '.cbi-dropdown');
740
741                 if (cbox)
742                         cbox.checked = false;
743
744                 sb.removeAttribute('locked-in');
745         },
746
747         handleCreateClick: function(ev) {
748                 ev.currentTarget.querySelector(this.options.create_query).focus();
749         },
750
751         setValue: function(values) {
752                 if (this.options.multi) {
753                         if (!Array.isArray(values))
754                                 values = (values != null) ? [ values ] : [];
755
756                         var v = {};
757
758                         for (var i = 0; i < values.length; i++)
759                                 v[values[i]] = true;
760
761                         this.setValues(this.node, v);
762                 }
763                 else {
764                         var v = {};
765
766                         if (values != null) {
767                                 if (Array.isArray(values))
768                                         v[values[0]] = true;
769                                 else
770                                         v[values] = true;
771                         }
772
773                         this.setValues(this.node, v);
774                 }
775         },
776
777         getValue: function() {
778                 var div = this.node.lastElementChild,
779                     h = div.querySelectorAll('input[type="hidden"]'),
780                         v = [];
781
782                 for (var i = 0; i < h.length; i++)
783                         v.push(h[i].value);
784
785                 return this.options.multi ? v : v[0];
786         }
787 });
788
789 var UICombobox = UIDropdown.extend({
790         __init__: function(value, choices, options) {
791                 this.super('__init__', [ value, choices, Object.assign({
792                         select_placeholder: _('-- Please choose --'),
793                         custom_placeholder: _('-- custom --'),
794                         dropdown_items: 5
795                 }, options, {
796                         sort: true,
797                         multi: false,
798                         create: true,
799                         optional: true
800                 }) ]);
801         }
802 });
803
804 var UIDynamicList = UIElement.extend({
805         __init__: function(values, choices, options) {
806                 if (!Array.isArray(values))
807                         values = (values != null) ? [ values ] : [];
808
809                 if (typeof(choices) != 'object')
810                         choices = null;
811
812                 this.values = values;
813                 this.choices = choices;
814                 this.options = Object.assign({}, options, {
815                         multi: false,
816                         optional: true
817                 });
818         },
819
820         render: function() {
821                 var dl = E('div', {
822                         'id': this.options.id,
823                         'class': 'cbi-dynlist'
824                 }, E('div', { 'class': 'add-item' }));
825
826                 if (this.choices) {
827                         var cbox = new UICombobox(null, this.choices, this.options);
828                         dl.lastElementChild.appendChild(cbox.render());
829                 }
830                 else {
831                         var inputEl = E('input', {
832                                 'type': 'text',
833                                 'class': 'cbi-input-text',
834                                 'placeholder': this.options.placeholder
835                         });
836
837                         dl.lastElementChild.appendChild(inputEl);
838                         dl.lastElementChild.appendChild(E('div', { 'class': 'cbi-button cbi-button-add' }, '+'));
839
840                         L.ui.addValidator(inputEl, this.options.datatype, true, 'blue', 'keyup');
841                 }
842
843                 for (var i = 0; i < this.values.length; i++)
844                         this.addItem(dl, this.values[i],
845                                 this.choices ? this.choices[this.values[i]] : null);
846
847                 return this.bind(dl);
848         },
849
850         bind: function(dl) {
851                 dl.addEventListener('click', L.bind(this.handleClick, this));
852                 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
853                 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
854
855                 this.node = dl;
856
857                 this.setUpdateEvents(dl, 'cbi-dynlist-change');
858                 this.setChangeEvents(dl, 'cbi-dynlist-change');
859
860                 L.dom.bindClassInstance(dl, this);
861
862                 return dl;
863         },
864
865         addItem: function(dl, value, text, flash) {
866                 var exists = false,
867                     new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
868                                 E('span', {}, text || value),
869                                 E('input', {
870                                         'type': 'hidden',
871                                         'name': this.options.name,
872                                         'value': value })]);
873
874                 dl.querySelectorAll('.item, .add-item').forEach(function(item) {
875                         if (exists)
876                                 return;
877
878                         var hidden = item.querySelector('input[type="hidden"]');
879
880                         if (hidden && hidden.parentNode !== item)
881                                 hidden = null;
882
883                         if (hidden && hidden.value === value)
884                                 exists = true;
885                         else if (!hidden || hidden.value >= value)
886                                 exists = !!item.parentNode.insertBefore(new_item, item);
887                 });
888
889                 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
890                         bubbles: true,
891                         detail: {
892                                 instance: this,
893                                 element: dl,
894                                 value: value,
895                                 add: true
896                         }
897                 }));
898         },
899
900         removeItem: function(dl, item) {
901                 var value = item.querySelector('input[type="hidden"]').value;
902                 var sb = dl.querySelector('.cbi-dropdown');
903                 if (sb)
904                         sb.querySelectorAll('ul > li').forEach(function(li) {
905                                 if (li.getAttribute('data-value') === value) {
906                                         if (li.hasAttribute('dynlistcustom'))
907                                                 li.parentNode.removeChild(li);
908                                         else
909                                                 li.removeAttribute('unselectable');
910                                 }
911                         });
912
913                 item.parentNode.removeChild(item);
914
915                 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
916                         bubbles: true,
917                         detail: {
918                                 instance: this,
919                                 element: dl,
920                                 value: value,
921                                 remove: true
922                         }
923                 }));
924         },
925
926         handleClick: function(ev) {
927                 var dl = ev.currentTarget,
928                     item = findParent(ev.target, '.item');
929
930                 if (item) {
931                         this.removeItem(dl, item);
932                 }
933                 else if (matchesElem(ev.target, '.cbi-button-add')) {
934                         var input = ev.target.previousElementSibling;
935                         if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
936                                 this.addItem(dl, input.value, null, true);
937                                 input.value = '';
938                         }
939                 }
940         },
941
942         handleDropdownChange: function(ev) {
943                 var dl = ev.currentTarget,
944                     sbIn = ev.detail.instance,
945                     sbEl = ev.detail.element,
946                     sbVal = ev.detail.value;
947
948                 if (sbVal === null)
949                         return;
950
951                 sbIn.setValues(sbEl, null);
952                 sbVal.element.setAttribute('unselectable', '');
953
954                 if (sbVal.element.hasAttribute('created')) {
955                         sbVal.element.removeAttribute('created');
956                         sbVal.element.setAttribute('dynlistcustom', '');
957                 }
958
959                 this.addItem(dl, sbVal.value, sbVal.text, true);
960         },
961
962         handleKeydown: function(ev) {
963                 var dl = ev.currentTarget,
964                     item = findParent(ev.target, '.item');
965
966                 if (item) {
967                         switch (ev.keyCode) {
968                         case 8: /* backspace */
969                                 if (item.previousElementSibling)
970                                         item.previousElementSibling.focus();
971
972                                 this.removeItem(dl, item);
973                                 break;
974
975                         case 46: /* delete */
976                                 if (item.nextElementSibling) {
977                                         if (item.nextElementSibling.classList.contains('item'))
978                                                 item.nextElementSibling.focus();
979                                         else
980                                                 item.nextElementSibling.firstElementChild.focus();
981                                 }
982
983                                 this.removeItem(dl, item);
984                                 break;
985                         }
986                 }
987                 else if (matchesElem(ev.target, '.cbi-input-text')) {
988                         switch (ev.keyCode) {
989                         case 13: /* enter */
990                                 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
991                                         this.addItem(dl, ev.target.value, null, true);
992                                         ev.target.value = '';
993                                         ev.target.blur();
994                                         ev.target.focus();
995                                 }
996
997                                 ev.preventDefault();
998                                 break;
999                         }
1000                 }
1001         },
1002
1003         getValue: function() {
1004                 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
1005                     v = [];
1006
1007                 for (var i = 0; i < items.length; i++)
1008                         v.push(items[i].value);
1009
1010                 return v;
1011         },
1012
1013         setValue: function(values) {
1014                 if (!Array.isArray(values))
1015                         values = (values != null) ? [ values ] : [];
1016
1017                 var items = this.node.querySelectorAll('.item');
1018
1019                 for (var i = 0; i < items.length; i++)
1020                         if (items[i].parentNode === this.node)
1021                                 this.removeItem(this.node, items[i]);
1022
1023                 for (var i = 0; i < values.length; i++)
1024                         this.addItem(this.node, values[i],
1025                                 this.choices ? this.choices[values[i]] : null);
1026         }
1027 });
1028
1029
1030 return L.Class.extend({
1031         __init__: function() {
1032                 modalDiv = document.body.appendChild(
1033                         L.dom.create('div', { id: 'modal_overlay' },
1034                                 L.dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
1035
1036                 tooltipDiv = document.body.appendChild(
1037                         L.dom.create('div', { class: 'cbi-tooltip' }));
1038
1039                 /* setup old aliases */
1040                 L.showModal = this.showModal;
1041                 L.hideModal = this.hideModal;
1042                 L.showTooltip = this.showTooltip;
1043                 L.hideTooltip = this.hideTooltip;
1044                 L.itemlist = this.itemlist;
1045
1046                 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
1047                 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
1048                 document.addEventListener('focus', this.showTooltip.bind(this), true);
1049                 document.addEventListener('blur', this.hideTooltip.bind(this), true);
1050
1051                 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
1052                 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
1053                 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
1054         },
1055
1056         /* Modal dialog */
1057         showModal: function(title, children) {
1058                 var dlg = modalDiv.firstElementChild;
1059
1060                 dlg.setAttribute('class', 'modal');
1061
1062                 L.dom.content(dlg, L.dom.create('h4', {}, title));
1063                 L.dom.append(dlg, children);
1064
1065                 document.body.classList.add('modal-overlay-active');
1066
1067                 return dlg;
1068         },
1069
1070         hideModal: function() {
1071                 document.body.classList.remove('modal-overlay-active');
1072         },
1073
1074         /* Tooltip */
1075         showTooltip: function(ev) {
1076                 var target = findParent(ev.target, '[data-tooltip]');
1077
1078                 if (!target)
1079                         return;
1080
1081                 if (tooltipTimeout !== null) {
1082                         window.clearTimeout(tooltipTimeout);
1083                         tooltipTimeout = null;
1084                 }
1085
1086                 var rect = target.getBoundingClientRect(),
1087                     x = rect.left              + window.pageXOffset,
1088                     y = rect.top + rect.height + window.pageYOffset;
1089
1090                 tooltipDiv.className = 'cbi-tooltip';
1091                 tooltipDiv.innerHTML = '▲ ';
1092                 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
1093
1094                 if (target.hasAttribute('data-tooltip-style'))
1095                         tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
1096
1097                 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
1098                         y -= (tooltipDiv.offsetHeight + target.offsetHeight);
1099                         tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
1100                 }
1101
1102                 tooltipDiv.style.top = y + 'px';
1103                 tooltipDiv.style.left = x + 'px';
1104                 tooltipDiv.style.opacity = 1;
1105
1106                 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
1107                         bubbles: true,
1108                         detail: { target: target }
1109                 }));
1110         },
1111
1112         hideTooltip: function(ev) {
1113                 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
1114                     tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
1115                         return;
1116
1117                 if (tooltipTimeout !== null) {
1118                         window.clearTimeout(tooltipTimeout);
1119                         tooltipTimeout = null;
1120                 }
1121
1122                 tooltipDiv.style.opacity = 0;
1123                 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
1124
1125                 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
1126         },
1127
1128         /* Widget helper */
1129         itemlist: function(node, items, separators) {
1130                 var children = [];
1131
1132                 if (!Array.isArray(separators))
1133                         separators = [ separators || E('br') ];
1134
1135                 for (var i = 0; i < items.length; i += 2) {
1136                         if (items[i+1] !== null && items[i+1] !== undefined) {
1137                                 var sep = separators[(i/2) % separators.length],
1138                                     cld = [];
1139
1140                                 children.push(E('span', { class: 'nowrap' }, [
1141                                         items[i] ? E('strong', items[i] + ': ') : '',
1142                                         items[i+1]
1143                                 ]));
1144
1145                                 if ((i+2) < items.length)
1146                                         children.push(L.dom.elem(sep) ? sep.cloneNode(true) : sep);
1147                         }
1148                 }
1149
1150                 L.dom.content(node, children);
1151
1152                 return node;
1153         },
1154
1155         /* Tabs */
1156         tabs: L.Class.singleton({
1157                 init: function() {
1158                         var groups = [], prevGroup = null, currGroup = null;
1159
1160                         document.querySelectorAll('[data-tab]').forEach(function(tab) {
1161                                 var parent = tab.parentNode;
1162
1163                                 if (!parent.hasAttribute('data-tab-group'))
1164                                         parent.setAttribute('data-tab-group', groups.length);
1165
1166                                 currGroup = +parent.getAttribute('data-tab-group');
1167
1168                                 if (currGroup !== prevGroup) {
1169                                         prevGroup = currGroup;
1170
1171                                         if (!groups[currGroup])
1172                                                 groups[currGroup] = [];
1173                                 }
1174
1175                                 groups[currGroup].push(tab);
1176                         });
1177
1178                         for (var i = 0; i < groups.length; i++)
1179                                 this.initTabGroup(groups[i]);
1180
1181                         document.addEventListener('dependency-update', this.updateTabs.bind(this));
1182
1183                         this.updateTabs();
1184
1185                         if (!groups.length)
1186                                 this.setActiveTabId(-1, -1);
1187                 },
1188
1189                 initTabGroup: function(panes) {
1190                         if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
1191                                 return;
1192
1193                         var menu = E('ul', { 'class': 'cbi-tabmenu' }),
1194                             group = panes[0].parentNode,
1195                             groupId = +group.getAttribute('data-tab-group'),
1196                             selected = null;
1197
1198                         for (var i = 0, pane; pane = panes[i]; i++) {
1199                                 var name = pane.getAttribute('data-tab'),
1200                                     title = pane.getAttribute('data-tab-title'),
1201                                     active = pane.getAttribute('data-tab-active') === 'true';
1202
1203                                 menu.appendChild(E('li', {
1204                                         'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
1205                                         'data-tab': name
1206                                 }, E('a', {
1207                                         'href': '#',
1208                                         'click': this.switchTab.bind(this)
1209                                 }, title)));
1210
1211                                 if (active)
1212                                         selected = i;
1213                         }
1214
1215                         group.parentNode.insertBefore(menu, group);
1216
1217                         if (selected === null) {
1218                                 selected = this.getActiveTabId(groupId);
1219
1220                                 if (selected < 0 || selected >= panes.length)
1221                                         selected = 0;
1222
1223                                 menu.childNodes[selected].classList.add('cbi-tab');
1224                                 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
1225                                 panes[selected].setAttribute('data-tab-active', 'true');
1226
1227                                 this.setActiveTabId(groupId, selected);
1228                         }
1229                 },
1230
1231                 getActiveTabState: function() {
1232                         var page = document.body.getAttribute('data-page');
1233
1234                         try {
1235                                 var val = JSON.parse(window.sessionStorage.getItem('tab'));
1236                                 if (val.page === page && Array.isArray(val.groups))
1237                                         return val;
1238                         }
1239                         catch(e) {}
1240
1241                         window.sessionStorage.removeItem('tab');
1242                         return { page: page, groups: [] };
1243                 },
1244
1245                 getActiveTabId: function(groupId) {
1246                         return +this.getActiveTabState().groups[groupId] || 0;
1247                 },
1248
1249                 setActiveTabId: function(groupId, tabIndex) {
1250                         try {
1251                                 var state = this.getActiveTabState();
1252                                     state.groups[groupId] = tabIndex;
1253
1254                             window.sessionStorage.setItem('tab', JSON.stringify(state));
1255                         }
1256                         catch (e) { return false; }
1257
1258                         return true;
1259                 },
1260
1261                 updateTabs: function(ev) {
1262                         document.querySelectorAll('[data-tab-title]').forEach(function(pane) {
1263                                 var menu = pane.parentNode.previousElementSibling,
1264                                     tab = menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))),
1265                                     n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
1266
1267                                 if (!pane.firstElementChild) {
1268                                         tab.style.display = 'none';
1269                                         tab.classList.remove('flash');
1270                                 }
1271                                 else if (tab.style.display === 'none') {
1272                                         tab.style.display = '';
1273                                         requestAnimationFrame(function() { tab.classList.add('flash') });
1274                                 }
1275
1276                                 if (n_errors) {
1277                                         tab.setAttribute('data-errors', n_errors);
1278                                         tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
1279                                         tab.setAttribute('data-tooltip-style', 'error');
1280                                 }
1281                                 else {
1282                                         tab.removeAttribute('data-errors');
1283                                         tab.removeAttribute('data-tooltip');
1284                                 }
1285                         });
1286                 },
1287
1288                 switchTab: function(ev) {
1289                         var tab = ev.target.parentNode,
1290                             name = tab.getAttribute('data-tab'),
1291                             menu = tab.parentNode,
1292                             group = menu.nextElementSibling,
1293                             groupId = +group.getAttribute('data-tab-group'),
1294                             index = 0;
1295
1296                         ev.preventDefault();
1297
1298                         if (!tab.classList.contains('cbi-tab-disabled'))
1299                                 return;
1300
1301                         menu.querySelectorAll('[data-tab]').forEach(function(tab) {
1302                                 tab.classList.remove('cbi-tab');
1303                                 tab.classList.remove('cbi-tab-disabled');
1304                                 tab.classList.add(
1305                                         tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
1306                         });
1307
1308                         group.childNodes.forEach(function(pane) {
1309                                 if (L.dom.matches(pane, '[data-tab]')) {
1310                                         if (pane.getAttribute('data-tab') === name) {
1311                                                 pane.setAttribute('data-tab-active', 'true');
1312                                                 L.ui.tabs.setActiveTabId(groupId, index);
1313                                         }
1314                                         else {
1315                                                 pane.setAttribute('data-tab-active', 'false');
1316                                         }
1317
1318                                         index++;
1319                                 }
1320                         });
1321                 }
1322         }),
1323
1324         /* UCI Changes */
1325         changes: L.Class.singleton({
1326                 init: function() {
1327                         if (!L.env.sessionid)
1328                                 return;
1329
1330                         return uci.changes().then(L.bind(this.renderChangeIndicator, this));
1331                 },
1332
1333                 setIndicator: function(n) {
1334                         var i = document.querySelector('.uci_change_indicator');
1335                         if (i == null) {
1336                                 var poll = document.getElementById('xhr_poll_status');
1337                                 i = poll.parentNode.insertBefore(E('a', {
1338                                         'href': '#',
1339                                         'class': 'uci_change_indicator label notice',
1340                                         'click': L.bind(this.displayChanges, this)
1341                                 }), poll);
1342                         }
1343
1344                         if (n > 0) {
1345                                 L.dom.content(i, [ _('Unsaved Changes'), ': ', n ]);
1346                                 i.classList.add('flash');
1347                                 i.style.display = '';
1348                         }
1349                         else {
1350                                 i.classList.remove('flash');
1351                                 i.style.display = 'none';
1352                         }
1353                 },
1354
1355                 renderChangeIndicator: function(changes) {
1356                         var n_changes = 0;
1357
1358                         for (var config in changes)
1359                                 if (changes.hasOwnProperty(config))
1360                                         n_changes += changes[config].length;
1361
1362                         this.changes = changes;
1363                         this.setIndicator(n_changes);
1364                 },
1365
1366                 changeTemplates: {
1367                         'add-3':      '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
1368                         'set-3':      '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
1369                         'set-4':      '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
1370                         'remove-2':   '<del>uci del %0.<strong>%2</strong></del>',
1371                         'remove-3':   '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
1372                         'order-3':    '<var>uci reorder %0.%2=<strong>%3</strong></var>',
1373                         'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
1374                         'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
1375                         'rename-3':   '<var>uci rename %0.%2=<strong>%3</strong></var>',
1376                         'rename-4':   '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
1377                 },
1378
1379                 displayChanges: function() {
1380                         var list = E('div', { 'class': 'uci-change-list' }),
1381                             dlg = L.ui.showModal(_('Configuration') + ' / ' + _('Changes'), [
1382                                 E('div', { 'class': 'cbi-section' }, [
1383                                         E('strong', _('Legend:')),
1384                                         E('div', { 'class': 'uci-change-legend' }, [
1385                                                 E('div', { 'class': 'uci-change-legend-label' }, [
1386                                                         E('ins', '&#160;'), ' ', _('Section added') ]),
1387                                                 E('div', { 'class': 'uci-change-legend-label' }, [
1388                                                         E('del', '&#160;'), ' ', _('Section removed') ]),
1389                                                 E('div', { 'class': 'uci-change-legend-label' }, [
1390                                                         E('var', {}, E('ins', '&#160;')), ' ', _('Option changed') ]),
1391                                                 E('div', { 'class': 'uci-change-legend-label' }, [
1392                                                         E('var', {}, E('del', '&#160;')), ' ', _('Option removed') ])]),
1393                                         E('br'), list,
1394                                         E('div', { 'class': 'right' }, [
1395                                                 E('input', {
1396                                                         'type': 'button',
1397                                                         'class': 'btn',
1398                                                         'click': L.ui.hideModal,
1399                                                         'value': _('Dismiss')
1400                                                 }), ' ',
1401                                                 E('input', {
1402                                                         'type': 'button',
1403                                                         'class': 'cbi-button cbi-button-positive important',
1404                                                         'click': L.bind(this.apply, this, true),
1405                                                         'value': _('Save & Apply')
1406                                                 }), ' ',
1407                                                 E('input', {
1408                                                         'type': 'button',
1409                                                         'class': 'cbi-button cbi-button-reset',
1410                                                         'click': L.bind(this.revert, this),
1411                                                         'value': _('Revert')
1412                                                 })])])
1413                         ]);
1414
1415                         for (var config in this.changes) {
1416                                 if (!this.changes.hasOwnProperty(config))
1417                                         continue;
1418
1419                                 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
1420
1421                                 for (var i = 0, added = null; i < this.changes[config].length; i++) {
1422                                         var chg = this.changes[config][i],
1423                                             tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
1424
1425                                         list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
1426                                                 switch (+m1) {
1427                                                 case 0:
1428                                                         return config;
1429
1430                                                 case 2:
1431                                                         if (added != null && chg[1] == added[0])
1432                                                                 return '@' + added[1] + '[-1]';
1433                                                         else
1434                                                                 return chg[1];
1435
1436                                                 case 4:
1437                                                         return "'" + chg[3].replace(/'/g, "'\"'\"'") + "'";
1438
1439                                                 default:
1440                                                         return chg[m1-1];
1441                                                 }
1442                                         })));
1443
1444                                         if (chg[0] == 'add')
1445                                                 added = [ chg[1], chg[2] ];
1446                                 }
1447                         }
1448
1449                         list.appendChild(E('br'));
1450                         dlg.classList.add('uci-dialog');
1451                 },
1452
1453                 displayStatus: function(type, content) {
1454                         if (type) {
1455                                 var message = L.ui.showModal('', '');
1456
1457                                 message.classList.add('alert-message');
1458                                 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
1459
1460                                 if (content)
1461                                         L.dom.content(message, content);
1462
1463                                 if (!this.was_polling) {
1464                                         this.was_polling = L.Request.poll.active();
1465                                         L.Request.poll.stop();
1466                                 }
1467                         }
1468                         else {
1469                                 L.ui.hideModal();
1470
1471                                 if (this.was_polling)
1472                                         L.Request.poll.start();
1473                         }
1474                 },
1475
1476                 rollback: function(checked) {
1477                         if (checked) {
1478                                 this.displayStatus('warning spinning',
1479                                         E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
1480                                                 .format(L.env.apply_rollback)));
1481
1482                                 var call = function(r, data, duration) {
1483                                         if (r.status === 204) {
1484                                                 L.ui.changes.displayStatus('warning', [
1485                                                         E('h4', _('Configuration has been rolled back!')),
1486                                                         E('p', _('The device could not be reached within %d seconds after applying the pending changes, which caused the configuration to be rolled back for safety reasons. If you believe that the configuration changes are correct nonetheless, perform an unchecked configuration apply. Alternatively, you can dismiss this warning and edit changes before attempting to apply again, or revert all pending changes to keep the currently working configuration state.').format(L.env.apply_rollback)),
1487                                                         E('div', { 'class': 'right' }, [
1488                                                                 E('input', {
1489                                                                         'type': 'button',
1490                                                                         'class': 'btn',
1491                                                                         'click': L.bind(L.ui.changes.displayStatus, L.ui.changes, false),
1492                                                                         'value': _('Dismiss')
1493                                                                 }), ' ',
1494                                                                 E('input', {
1495                                                                         'type': 'button',
1496                                                                         'class': 'btn cbi-button-action important',
1497                                                                         'click': L.bind(L.ui.changes.revert, L.ui.changes),
1498                                                                         'value': _('Revert changes')
1499                                                                 }), ' ',
1500                                                                 E('input', {
1501                                                                         'type': 'button',
1502                                                                         'class': 'btn cbi-button-negative important',
1503                                                                         'click': L.bind(L.ui.changes.apply, L.ui.changes, false),
1504                                                                         'value': _('Apply unchecked')
1505                                                                 })
1506                                                         ])
1507                                                 ]);
1508
1509                                                 return;
1510                                         }
1511
1512                                         var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
1513                                         window.setTimeout(function() {
1514                                                 L.Request.request(L.url('admin/uci/confirm'), {
1515                                                         method: 'post',
1516                                                         timeout: L.env.apply_timeout * 1000,
1517                                                         query: { sid: L.env.sessionid, token: L.env.token }
1518                                                 }).then(call);
1519                                         }, delay);
1520                                 };
1521
1522                                 call({ status: 0 });
1523                         }
1524                         else {
1525                                 this.displayStatus('warning', [
1526                                         E('h4', _('Device unreachable!')),
1527                                         E('p', _('Could not regain access to the device after applying the configuration changes. You might need to reconnect if you modified network related settings such as the IP address or wireless security credentials.'))
1528                                 ]);
1529                         }
1530                 },
1531
1532                 confirm: function(checked, deadline, override_token) {
1533                         var tt;
1534                         var ts = Date.now();
1535
1536                         this.displayStatus('notice');
1537
1538                         if (override_token)
1539                                 this.confirm_auth = { token: override_token };
1540
1541                         var call = function(r, data, duration) {
1542                                 if (Date.now() >= deadline) {
1543                                         window.clearTimeout(tt);
1544                                         L.ui.changes.rollback(checked);
1545                                         return;
1546                                 }
1547                                 else if (r && (r.status === 200 || r.status === 204)) {
1548                                         document.dispatchEvent(new CustomEvent('uci-applied'));
1549
1550                                         L.ui.changes.setIndicator(0);
1551                                         L.ui.changes.displayStatus('notice',
1552                                                 E('p', _('Configuration has been applied.')));
1553
1554                                         window.clearTimeout(tt);
1555                                         window.setTimeout(function() {
1556                                                 //L.ui.changes.displayStatus(false);
1557                                                 window.location = window.location.href.split('#')[0];
1558                                         }, L.env.apply_display * 1000);
1559
1560                                         return;
1561                                 }
1562
1563                                 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
1564                                 window.setTimeout(function() {
1565                                         L.Request.request(L.url('admin/uci/confirm'), {
1566                                                 method: 'post',
1567                                                 timeout: L.env.apply_timeout * 1000,
1568                                                 query: L.ui.changes.confirm_auth
1569                                         }).then(call);
1570                                 }, delay);
1571                         };
1572
1573                         var tick = function() {
1574                                 var now = Date.now();
1575
1576                                 L.ui.changes.displayStatus('notice spinning',
1577                                         E('p', _('Waiting for configuration to get applied… %ds')
1578                                                 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
1579
1580                                 if (now >= deadline)
1581                                         return;
1582
1583                                 tt = window.setTimeout(tick, 1000 - (now - ts));
1584                                 ts = now;
1585                         };
1586
1587                         tick();
1588
1589                         /* wait a few seconds for the settings to become effective */
1590                         window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
1591                 },
1592
1593                 apply: function(checked) {
1594                         this.displayStatus('notice spinning',
1595                                 E('p', _('Starting configuration apply…')));
1596
1597                         L.Request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
1598                                 method: 'post',
1599                                 query: { sid: L.env.sessionid, token: L.env.token }
1600                         }).then(function(r) {
1601                                 if (r.status === (checked ? 200 : 204)) {
1602                                         var tok = null; try { tok = r.json(); } catch(e) {}
1603                                         if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
1604                                                 L.ui.changes.confirm_auth = tok;
1605
1606                                         L.ui.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
1607                                 }
1608                                 else if (checked && r.status === 204) {
1609                                         L.ui.changes.displayStatus('notice',
1610                                                 E('p', _('There are no changes to apply')));
1611
1612                                         window.setTimeout(function() {
1613                                                 L.ui.changes.displayStatus(false);
1614                                         }, L.env.apply_display * 1000);
1615                                 }
1616                                 else {
1617                                         L.ui.changes.displayStatus('warning',
1618                                                 E('p', _('Apply request failed with status <code>%h</code>%>')
1619                                                         .format(r.responseText || r.statusText || r.status)));
1620
1621                                         window.setTimeout(function() {
1622                                                 L.ui.changes.displayStatus(false);
1623                                         }, L.env.apply_display * 1000);
1624                                 }
1625                         });
1626                 },
1627
1628                 revert: function() {
1629                         this.displayStatus('notice spinning',
1630                                 E('p', _('Reverting configuration…')));
1631
1632                         L.Request.request(L.url('admin/uci/revert'), {
1633                                 method: 'post',
1634                                 query: { sid: L.env.sessionid, token: L.env.token }
1635                         }).then(function(r) {
1636                                 if (r.status === 200) {
1637                                         document.dispatchEvent(new CustomEvent('uci-reverted'));
1638
1639                                         L.ui.changes.setIndicator(0);
1640                                         L.ui.changes.displayStatus('notice',
1641                                                 E('p', _('Changes have been reverted.')));
1642
1643                                         window.setTimeout(function() {
1644                                                 //L.ui.changes.displayStatus(false);
1645                                                 window.location = window.location.href.split('#')[0];
1646                                         }, L.env.apply_display * 1000);
1647                                 }
1648                                 else {
1649                                         L.ui.changes.displayStatus('warning',
1650                                                 E('p', _('Revert request failed with status <code>%h</code>')
1651                                                         .format(r.statusText || r.status)));
1652
1653                                         window.setTimeout(function() {
1654                                                 L.ui.changes.displayStatus(false);
1655                                         }, L.env.apply_display * 1000);
1656                                 }
1657                         });
1658                 }
1659         }),
1660
1661         addValidator: function(field, type, optional /*, ... */) {
1662                 if (type == null)
1663                         return;
1664
1665                 var events = this.varargs(arguments, 3);
1666                 if (events.length == 0)
1667                         events.push('blur', 'keyup');
1668
1669                 try {
1670                         var cbiValidator = new CBIValidator(field, type, optional),
1671                             validatorFn = cbiValidator.validate.bind(cbiValidator);
1672
1673                         for (var i = 0; i < events.length; i++)
1674                                 field.addEventListener(events[i], validatorFn);
1675
1676                         validatorFn();
1677                 }
1678                 catch (e) { }
1679         },
1680
1681         /* Widgets */
1682         Dropdown: UIDropdown,
1683         DynamicList: UIDynamicList,
1684         Combobox: UICombobox
1685 });