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