luci-base: ui.js: dispatch "cbi-tab-active" event when a tab is selected
[project/luci.git] / modules / luci-base / htdocs / luci-static / resources / ui.js
1 'use strict';
2 'require rpc';
3 'require uci';
4 'require validation';
5 'require fs';
6
7 var modalDiv = null,
8 tooltipDiv = null,
9 tooltipTimeout = null;
10
11 var UIElement = L.Class.extend({
12 getValue: function() {
13 if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input'))
14 return this.node.value;
15
16 return null;
17 },
18
19 setValue: function(value) {
20 if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input'))
21 this.node.value = value;
22 },
23
24 isValid: function() {
25 return (this.validState !== false);
26 },
27
28 triggerValidation: function() {
29 if (typeof(this.vfunc) != 'function')
30 return false;
31
32 var wasValid = this.isValid();
33
34 this.vfunc();
35
36 return (wasValid != this.isValid());
37 },
38
39 registerEvents: function(targetNode, synevent, events) {
40 var dispatchFn = L.bind(function(ev) {
41 this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true }));
42 }, this);
43
44 for (var i = 0; i < events.length; i++)
45 targetNode.addEventListener(events[i], dispatchFn);
46 },
47
48 setUpdateEvents: function(targetNode /*, ... */) {
49 var datatype = this.options.datatype,
50 optional = this.options.hasOwnProperty('optional') ? this.options.optional : true,
51 validate = this.options.validate,
52 events = this.varargs(arguments, 1);
53
54 this.registerEvents(targetNode, 'widget-update', events);
55
56 if (!datatype && !validate)
57 return;
58
59 this.vfunc = L.ui.addValidator.apply(L.ui, [
60 targetNode, datatype || 'string',
61 optional, validate
62 ].concat(events));
63
64 this.node.addEventListener('validation-success', L.bind(function(ev) {
65 this.validState = true;
66 }, this));
67
68 this.node.addEventListener('validation-failure', L.bind(function(ev) {
69 this.validState = false;
70 }, this));
71 },
72
73 setChangeEvents: function(targetNode /*, ... */) {
74 var tag_changed = L.bind(function(ev) { this.setAttribute('data-changed', true) }, this.node);
75
76 for (var i = 1; i < arguments.length; i++)
77 targetNode.addEventListener(arguments[i], tag_changed);
78
79 this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
80 }
81 });
82
83 var UITextfield = UIElement.extend({
84 __init__: function(value, options) {
85 this.value = value;
86 this.options = Object.assign({
87 optional: true,
88 password: false
89 }, options);
90 },
91
92 render: function() {
93 var frameEl = E('div', { 'id': this.options.id });
94
95 if (this.options.password) {
96 frameEl.classList.add('nowrap');
97 frameEl.appendChild(E('input', {
98 'type': 'password',
99 'style': 'position:absolute; left:-100000px',
100 'aria-hidden': true,
101 'tabindex': -1,
102 'name': this.options.name ? 'password.%s'.format(this.options.name) : null
103 }));
104 }
105
106 frameEl.appendChild(E('input', {
107 'id': this.options.id ? 'widget.' + this.options.id : null,
108 'name': this.options.name,
109 'type': this.options.password ? 'password' : 'text',
110 'class': this.options.password ? 'cbi-input-password' : 'cbi-input-text',
111 'readonly': this.options.readonly ? '' : null,
112 'maxlength': this.options.maxlength,
113 'placeholder': this.options.placeholder,
114 'value': this.value,
115 }));
116
117 if (this.options.password)
118 frameEl.appendChild(E('button', {
119 'class': 'cbi-button cbi-button-neutral',
120 'title': _('Reveal/hide password'),
121 'aria-label': _('Reveal/hide password'),
122 'click': function(ev) {
123 var e = this.previousElementSibling;
124 e.type = (e.type === 'password') ? 'text' : 'password';
125 ev.preventDefault();
126 }
127 }, '∗'));
128
129 return this.bind(frameEl);
130 },
131
132 bind: function(frameEl) {
133 var inputEl = frameEl.childNodes[+!!this.options.password];
134
135 this.node = frameEl;
136
137 this.setUpdateEvents(inputEl, 'keyup', 'blur');
138 this.setChangeEvents(inputEl, 'change');
139
140 L.dom.bindClassInstance(frameEl, this);
141
142 return frameEl;
143 },
144
145 getValue: function() {
146 var inputEl = this.node.childNodes[+!!this.options.password];
147 return inputEl.value;
148 },
149
150 setValue: function(value) {
151 var inputEl = this.node.childNodes[+!!this.options.password];
152 inputEl.value = value;
153 }
154 });
155
156 var UITextarea = UIElement.extend({
157 __init__: function(value, options) {
158 this.value = value;
159 this.options = Object.assign({
160 optional: true,
161 wrap: false,
162 cols: null,
163 rows: null
164 }, options);
165 },
166
167 render: function() {
168 var frameEl = E('div', { 'id': this.options.id }),
169 value = (this.value != null) ? String(this.value) : '';
170
171 frameEl.appendChild(E('textarea', {
172 'id': this.options.id ? 'widget.' + this.options.id : null,
173 'name': this.options.name,
174 'class': 'cbi-input-textarea',
175 'readonly': this.options.readonly ? '' : null,
176 'placeholder': this.options.placeholder,
177 'style': !this.options.cols ? 'width:100%' : null,
178 'cols': this.options.cols,
179 'rows': this.options.rows,
180 'wrap': this.options.wrap ? '' : null
181 }, [ value ]));
182
183 if (this.options.monospace)
184 frameEl.firstElementChild.style.fontFamily = 'monospace';
185
186 return this.bind(frameEl);
187 },
188
189 bind: function(frameEl) {
190 var inputEl = frameEl.firstElementChild;
191
192 this.node = frameEl;
193
194 this.setUpdateEvents(inputEl, 'keyup', 'blur');
195 this.setChangeEvents(inputEl, 'change');
196
197 L.dom.bindClassInstance(frameEl, this);
198
199 return frameEl;
200 },
201
202 getValue: function() {
203 return this.node.firstElementChild.value;
204 },
205
206 setValue: function(value) {
207 this.node.firstElementChild.value = value;
208 }
209 });
210
211 var UICheckbox = UIElement.extend({
212 __init__: function(value, options) {
213 this.value = value;
214 this.options = Object.assign({
215 value_enabled: '1',
216 value_disabled: '0'
217 }, options);
218 },
219
220 render: function() {
221 var frameEl = E('div', {
222 'id': this.options.id,
223 'class': 'cbi-checkbox'
224 });
225
226 if (this.options.hiddenname)
227 frameEl.appendChild(E('input', {
228 'type': 'hidden',
229 'name': this.options.hiddenname,
230 'value': 1
231 }));
232
233 frameEl.appendChild(E('input', {
234 'id': this.options.id ? 'widget.' + this.options.id : null,
235 'name': this.options.name,
236 'type': 'checkbox',
237 'value': this.options.value_enabled,
238 'checked': (this.value == this.options.value_enabled) ? '' : null
239 }));
240
241 return this.bind(frameEl);
242 },
243
244 bind: function(frameEl) {
245 this.node = frameEl;
246
247 this.setUpdateEvents(frameEl.lastElementChild, 'click', 'blur');
248 this.setChangeEvents(frameEl.lastElementChild, 'change');
249
250 L.dom.bindClassInstance(frameEl, this);
251
252 return frameEl;
253 },
254
255 isChecked: function() {
256 return this.node.lastElementChild.checked;
257 },
258
259 getValue: function() {
260 return this.isChecked()
261 ? this.options.value_enabled
262 : this.options.value_disabled;
263 },
264
265 setValue: function(value) {
266 this.node.lastElementChild.checked = (value == this.options.value_enabled);
267 }
268 });
269
270 var UISelect = UIElement.extend({
271 __init__: function(value, choices, options) {
272 if (!L.isObject(choices))
273 choices = {};
274
275 if (!Array.isArray(value))
276 value = (value != null && value != '') ? [ value ] : [];
277
278 if (!options.multiple && value.length > 1)
279 value.length = 1;
280
281 this.values = value;
282 this.choices = choices;
283 this.options = Object.assign({
284 multiple: false,
285 widget: 'select',
286 orientation: 'horizontal'
287 }, options);
288
289 if (this.choices.hasOwnProperty(''))
290 this.options.optional = true;
291 },
292
293 render: function() {
294 var frameEl = E('div', { 'id': this.options.id }),
295 keys = Object.keys(this.choices);
296
297 if (this.options.sort === true)
298 keys.sort();
299 else if (Array.isArray(this.options.sort))
300 keys = this.options.sort;
301
302 if (this.options.widget == 'select') {
303 frameEl.appendChild(E('select', {
304 'id': this.options.id ? 'widget.' + this.options.id : null,
305 'name': this.options.name,
306 'size': this.options.size,
307 'class': 'cbi-input-select',
308 'multiple': this.options.multiple ? '' : null
309 }));
310
311 if (this.options.optional)
312 frameEl.lastChild.appendChild(E('option', {
313 'value': '',
314 'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null
315 }, [ this.choices[''] || this.options.placeholder || _('-- Please choose --') ]));
316
317 for (var i = 0; i < keys.length; i++) {
318 if (keys[i] == null || keys[i] == '')
319 continue;
320
321 frameEl.lastChild.appendChild(E('option', {
322 'value': keys[i],
323 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
324 }, [ this.choices[keys[i]] || keys[i] ]));
325 }
326 }
327 else {
328 var brEl = (this.options.orientation === 'horizontal') ? document.createTextNode(' ') : E('br');
329
330 for (var i = 0; i < keys.length; i++) {
331 frameEl.appendChild(E('label', {}, [
332 E('input', {
333 'id': this.options.id ? 'widget.' + this.options.id : null,
334 'name': this.options.id || this.options.name,
335 'type': this.options.multiple ? 'checkbox' : 'radio',
336 'class': this.options.multiple ? 'cbi-input-checkbox' : 'cbi-input-radio',
337 'value': keys[i],
338 'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null
339 }),
340 this.choices[keys[i]] || keys[i]
341 ]));
342
343 if (i + 1 == this.options.size)
344 frameEl.appendChild(brEl);
345 }
346 }
347
348 return this.bind(frameEl);
349 },
350
351 bind: function(frameEl) {
352 this.node = frameEl;
353
354 if (this.options.widget == 'select') {
355 this.setUpdateEvents(frameEl.firstChild, 'change', 'click', 'blur');
356 this.setChangeEvents(frameEl.firstChild, 'change');
357 }
358 else {
359 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
360 for (var i = 0; i < radioEls.length; i++) {
361 this.setUpdateEvents(radioEls[i], 'change', 'click', 'blur');
362 this.setChangeEvents(radioEls[i], 'change', 'click', 'blur');
363 }
364 }
365
366 L.dom.bindClassInstance(frameEl, this);
367
368 return frameEl;
369 },
370
371 getValue: function() {
372 if (this.options.widget == 'select')
373 return this.node.firstChild.value;
374
375 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
376 for (var i = 0; i < radioEls.length; i++)
377 if (radioEls[i].checked)
378 return radioEls[i].value;
379
380 return null;
381 },
382
383 setValue: function(value) {
384 if (this.options.widget == 'select') {
385 if (value == null)
386 value = '';
387
388 for (var i = 0; i < this.node.firstChild.options.length; i++)
389 this.node.firstChild.options[i].selected = (this.node.firstChild.options[i].value == value);
390
391 return;
392 }
393
394 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
395 for (var i = 0; i < radioEls.length; i++)
396 radioEls[i].checked = (radioEls[i].value == value);
397 }
398 });
399
400 var UIDropdown = UIElement.extend({
401 __init__: function(value, choices, options) {
402 if (typeof(choices) != 'object')
403 choices = {};
404
405 if (!Array.isArray(value))
406 this.values = (value != null && value != '') ? [ value ] : [];
407 else
408 this.values = value;
409
410 this.choices = choices;
411 this.options = Object.assign({
412 sort: true,
413 multiple: Array.isArray(value),
414 optional: true,
415 select_placeholder: _('-- Please choose --'),
416 custom_placeholder: _('-- custom --'),
417 display_items: 3,
418 dropdown_items: -1,
419 create: false,
420 create_query: '.create-item-input',
421 create_template: 'script[type="item-template"]'
422 }, options);
423 },
424
425 render: function() {
426 var sb = E('div', {
427 'id': this.options.id,
428 'class': 'cbi-dropdown',
429 'multiple': this.options.multiple ? '' : null,
430 'optional': this.options.optional ? '' : null,
431 }, E('ul'));
432
433 var keys = Object.keys(this.choices);
434
435 if (this.options.sort === true)
436 keys.sort();
437 else if (Array.isArray(this.options.sort))
438 keys = this.options.sort;
439
440 if (this.options.create)
441 for (var i = 0; i < this.values.length; i++)
442 if (!this.choices.hasOwnProperty(this.values[i]))
443 keys.push(this.values[i]);
444
445 for (var i = 0; i < keys.length; i++) {
446 var label = this.choices[keys[i]];
447
448 if (L.dom.elem(label))
449 label = label.cloneNode(true);
450
451 sb.lastElementChild.appendChild(E('li', {
452 'data-value': keys[i],
453 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
454 }, [ label || keys[i] ]));
455 }
456
457 if (this.options.create) {
458 var createEl = E('input', {
459 'type': 'text',
460 'class': 'create-item-input',
461 'readonly': this.options.readonly ? '' : null,
462 'maxlength': this.options.maxlength,
463 'placeholder': this.options.custom_placeholder || this.options.placeholder
464 });
465
466 if (this.options.datatype || this.options.validate)
467 L.ui.addValidator(createEl, this.options.datatype || 'string',
468 true, this.options.validate, 'blur', 'keyup');
469
470 sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
471 }
472
473 if (this.options.create_markup)
474 sb.appendChild(E('script', { type: 'item-template' },
475 this.options.create_markup));
476
477 return this.bind(sb);
478 },
479
480 bind: function(sb) {
481 var o = this.options;
482
483 o.multiple = sb.hasAttribute('multiple');
484 o.optional = sb.hasAttribute('optional');
485 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
486 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
487 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
488 o.create_query = sb.getAttribute('item-create') || o.create_query;
489 o.create_template = sb.getAttribute('item-template') || o.create_template;
490
491 var ul = sb.querySelector('ul'),
492 more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
493 open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, '▾')),
494 canary = sb.appendChild(E('div')),
495 create = sb.querySelector(this.options.create_query),
496 ndisplay = this.options.display_items,
497 n = 0;
498
499 if (this.options.multiple) {
500 var items = ul.querySelectorAll('li');
501
502 for (var i = 0; i < items.length; i++) {
503 this.transformItem(sb, items[i]);
504
505 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
506 items[i].setAttribute('display', n++);
507 }
508 }
509 else {
510 if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
511 var placeholder = E('li', { placeholder: '' },
512 this.options.select_placeholder || this.options.placeholder);
513
514 ul.firstChild
515 ? ul.insertBefore(placeholder, ul.firstChild)
516 : ul.appendChild(placeholder);
517 }
518
519 var items = ul.querySelectorAll('li'),
520 sel = sb.querySelectorAll('[selected]');
521
522 sel.forEach(function(s) {
523 s.removeAttribute('selected');
524 });
525
526 var s = sel[0] || items[0];
527 if (s) {
528 s.setAttribute('selected', '');
529 s.setAttribute('display', n++);
530 }
531
532 ndisplay--;
533 }
534
535 this.saveValues(sb, ul);
536
537 ul.setAttribute('tabindex', -1);
538 sb.setAttribute('tabindex', 0);
539
540 if (ndisplay < 0)
541 sb.setAttribute('more', '')
542 else
543 sb.removeAttribute('more');
544
545 if (ndisplay == this.options.display_items)
546 sb.setAttribute('empty', '')
547 else
548 sb.removeAttribute('empty');
549
550 L.dom.content(more, (ndisplay == this.options.display_items)
551 ? (this.options.select_placeholder || this.options.placeholder) : '···');
552
553
554 sb.addEventListener('click', this.handleClick.bind(this));
555 sb.addEventListener('keydown', this.handleKeydown.bind(this));
556 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
557 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
558
559 if ('ontouchstart' in window) {
560 sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
561 window.addEventListener('touchstart', this.closeAllDropdowns);
562 }
563 else {
564 sb.addEventListener('mouseover', this.handleMouseover.bind(this));
565 sb.addEventListener('focus', this.handleFocus.bind(this));
566
567 canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
568
569 window.addEventListener('mouseover', this.setFocus);
570 window.addEventListener('click', this.closeAllDropdowns);
571 }
572
573 if (create) {
574 create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
575 create.addEventListener('focus', this.handleCreateFocus.bind(this));
576 create.addEventListener('blur', this.handleCreateBlur.bind(this));
577
578 var li = findParent(create, 'li');
579
580 li.setAttribute('unselectable', '');
581 li.addEventListener('click', this.handleCreateClick.bind(this));
582 }
583
584 this.node = sb;
585
586 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
587 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
588
589 L.dom.bindClassInstance(sb, this);
590
591 return sb;
592 },
593
594 openDropdown: function(sb) {
595 var st = window.getComputedStyle(sb, null),
596 ul = sb.querySelector('ul'),
597 li = ul.querySelectorAll('li'),
598 fl = findParent(sb, '.cbi-value-field'),
599 sel = ul.querySelector('[selected]'),
600 rect = sb.getBoundingClientRect(),
601 items = Math.min(this.options.dropdown_items, li.length);
602
603 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
604 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
605 });
606
607 sb.setAttribute('open', '');
608
609 var pv = ul.cloneNode(true);
610 pv.classList.add('preview');
611
612 if (fl)
613 fl.classList.add('cbi-dropdown-open');
614
615 if ('ontouchstart' in window) {
616 var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
617 vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
618 scrollFrom = window.pageYOffset,
619 scrollTo = scrollFrom + rect.top - vpHeight * 0.5,
620 start = null;
621
622 ul.style.top = sb.offsetHeight + 'px';
623 ul.style.left = -rect.left + 'px';
624 ul.style.right = (rect.right - vpWidth) + 'px';
625 ul.style.maxHeight = (vpHeight * 0.5) + 'px';
626 ul.style.WebkitOverflowScrolling = 'touch';
627
628 var scrollStep = function(timestamp) {
629 if (!start) {
630 start = timestamp;
631 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
632 }
633
634 var duration = Math.max(timestamp - start, 1);
635 if (duration < 100) {
636 document.body.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
637 window.requestAnimationFrame(scrollStep);
638 }
639 else {
640 document.body.scrollTop = scrollTo;
641 }
642 };
643
644 window.requestAnimationFrame(scrollStep);
645 }
646 else {
647 ul.style.maxHeight = '1px';
648 ul.style.top = ul.style.bottom = '';
649
650 window.requestAnimationFrame(function() {
651 var itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
652 fullHeight = 0,
653 spaceAbove = rect.top,
654 spaceBelow = window.innerHeight - rect.height - rect.top;
655
656 for (var i = 0; i < (items == -1 ? li.length : items); i++)
657 fullHeight += li[i].getBoundingClientRect().height;
658
659 if (fullHeight <= spaceBelow) {
660 ul.style.top = rect.height + 'px';
661 ul.style.maxHeight = spaceBelow + 'px';
662 }
663 else if (fullHeight <= spaceAbove) {
664 ul.style.bottom = rect.height + 'px';
665 ul.style.maxHeight = spaceAbove + 'px';
666 }
667 else if (spaceBelow >= spaceAbove) {
668 ul.style.top = rect.height + 'px';
669 ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px';
670 }
671 else {
672 ul.style.bottom = rect.height + 'px';
673 ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px';
674 }
675
676 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
677 });
678 }
679
680 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
681 for (var i = 0; i < cboxes.length; i++) {
682 cboxes[i].checked = true;
683 cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
684 };
685
686 ul.classList.add('dropdown');
687
688 sb.insertBefore(pv, ul.nextElementSibling);
689
690 li.forEach(function(l) {
691 l.setAttribute('tabindex', 0);
692 });
693
694 sb.lastElementChild.setAttribute('tabindex', 0);
695
696 this.setFocus(sb, sel || li[0], true);
697 },
698
699 closeDropdown: function(sb, no_focus) {
700 if (!sb.hasAttribute('open'))
701 return;
702
703 var pv = sb.querySelector('ul.preview'),
704 ul = sb.querySelector('ul.dropdown'),
705 li = ul.querySelectorAll('li'),
706 fl = findParent(sb, '.cbi-value-field');
707
708 li.forEach(function(l) { l.removeAttribute('tabindex'); });
709 sb.lastElementChild.removeAttribute('tabindex');
710
711 sb.removeChild(pv);
712 sb.removeAttribute('open');
713 sb.style.width = sb.style.height = '';
714
715 ul.classList.remove('dropdown');
716 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
717
718 if (fl)
719 fl.classList.remove('cbi-dropdown-open');
720
721 if (!no_focus)
722 this.setFocus(sb, sb);
723
724 this.saveValues(sb, ul);
725 },
726
727 toggleItem: function(sb, li, force_state) {
728 if (li.hasAttribute('unselectable'))
729 return;
730
731 if (this.options.multiple) {
732 var cbox = li.querySelector('input[type="checkbox"]'),
733 items = li.parentNode.querySelectorAll('li'),
734 label = sb.querySelector('ul.preview'),
735 sel = li.parentNode.querySelectorAll('[selected]').length,
736 more = sb.querySelector('.more'),
737 ndisplay = this.options.display_items,
738 n = 0;
739
740 if (li.hasAttribute('selected')) {
741 if (force_state !== true) {
742 if (sel > 1 || this.options.optional) {
743 li.removeAttribute('selected');
744 cbox.checked = cbox.disabled = false;
745 sel--;
746 }
747 else {
748 cbox.disabled = true;
749 }
750 }
751 }
752 else {
753 if (force_state !== false) {
754 li.setAttribute('selected', '');
755 cbox.checked = true;
756 cbox.disabled = false;
757 sel++;
758 }
759 }
760
761 while (label && label.firstElementChild)
762 label.removeChild(label.firstElementChild);
763
764 for (var i = 0; i < items.length; i++) {
765 items[i].removeAttribute('display');
766 if (items[i].hasAttribute('selected')) {
767 if (ndisplay-- > 0) {
768 items[i].setAttribute('display', n++);
769 if (label)
770 label.appendChild(items[i].cloneNode(true));
771 }
772 var c = items[i].querySelector('input[type="checkbox"]');
773 if (c)
774 c.disabled = (sel == 1 && !this.options.optional);
775 }
776 }
777
778 if (ndisplay < 0)
779 sb.setAttribute('more', '');
780 else
781 sb.removeAttribute('more');
782
783 if (ndisplay === this.options.display_items)
784 sb.setAttribute('empty', '');
785 else
786 sb.removeAttribute('empty');
787
788 L.dom.content(more, (ndisplay === this.options.display_items)
789 ? (this.options.select_placeholder || this.options.placeholder) : '···');
790 }
791 else {
792 var sel = li.parentNode.querySelector('[selected]');
793 if (sel) {
794 sel.removeAttribute('display');
795 sel.removeAttribute('selected');
796 }
797
798 li.setAttribute('display', 0);
799 li.setAttribute('selected', '');
800
801 this.closeDropdown(sb, true);
802 }
803
804 this.saveValues(sb, li.parentNode);
805 },
806
807 transformItem: function(sb, li) {
808 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
809 label = E('label');
810
811 while (li.firstChild)
812 label.appendChild(li.firstChild);
813
814 li.appendChild(cbox);
815 li.appendChild(label);
816 },
817
818 saveValues: function(sb, ul) {
819 var sel = ul.querySelectorAll('li[selected]'),
820 div = sb.lastElementChild,
821 name = this.options.name,
822 strval = '',
823 values = [];
824
825 while (div.lastElementChild)
826 div.removeChild(div.lastElementChild);
827
828 sel.forEach(function (s) {
829 if (s.hasAttribute('placeholder'))
830 return;
831
832 var v = {
833 text: s.innerText,
834 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
835 element: s
836 };
837
838 div.appendChild(E('input', {
839 type: 'hidden',
840 name: name,
841 value: v.value
842 }));
843
844 values.push(v);
845
846 strval += strval.length ? ' ' + v.value : v.value;
847 });
848
849 var detail = {
850 instance: this,
851 element: sb
852 };
853
854 if (this.options.multiple)
855 detail.values = values;
856 else
857 detail.value = values.length ? values[0] : null;
858
859 sb.value = strval;
860
861 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
862 bubbles: true,
863 detail: detail
864 }));
865 },
866
867 setValues: function(sb, values) {
868 var ul = sb.querySelector('ul');
869
870 if (this.options.create) {
871 for (var value in values) {
872 this.createItems(sb, value);
873
874 if (!this.options.multiple)
875 break;
876 }
877 }
878
879 if (this.options.multiple) {
880 var lis = ul.querySelectorAll('li[data-value]');
881 for (var i = 0; i < lis.length; i++) {
882 var value = lis[i].getAttribute('data-value');
883 if (values === null || !(value in values))
884 this.toggleItem(sb, lis[i], false);
885 else
886 this.toggleItem(sb, lis[i], true);
887 }
888 }
889 else {
890 var ph = ul.querySelector('li[placeholder]');
891 if (ph)
892 this.toggleItem(sb, ph);
893
894 var lis = ul.querySelectorAll('li[data-value]');
895 for (var i = 0; i < lis.length; i++) {
896 var value = lis[i].getAttribute('data-value');
897 if (values !== null && (value in values))
898 this.toggleItem(sb, lis[i]);
899 }
900 }
901 },
902
903 setFocus: function(sb, elem, scroll) {
904 if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
905 return;
906
907 if (sb.target && findParent(sb.target, 'ul.dropdown'))
908 return;
909
910 document.querySelectorAll('.focus').forEach(function(e) {
911 if (!matchesElem(e, 'input')) {
912 e.classList.remove('focus');
913 e.blur();
914 }
915 });
916
917 if (elem) {
918 elem.focus();
919 elem.classList.add('focus');
920
921 if (scroll)
922 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
923 }
924 },
925
926 createChoiceElement: function(sb, value, label) {
927 var tpl = sb.querySelector(this.options.create_template),
928 markup = null;
929
930 if (tpl)
931 markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
932 else
933 markup = '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
934
935 var new_item = E(markup.replace(/{{value}}/g, '%h'.format(value))),
936 placeholder = new_item.querySelector('[data-label-placeholder]');
937
938 if (placeholder) {
939 var content = E('span', {}, label || this.choices[value] || [ value ]);
940
941 while (content.firstChild)
942 placeholder.parentNode.insertBefore(content.firstChild, placeholder);
943
944 placeholder.parentNode.removeChild(placeholder);
945 }
946
947 if (this.options.multiple)
948 this.transformItem(sb, new_item);
949
950 return new_item;
951 },
952
953 createItems: function(sb, value) {
954 var sbox = this,
955 val = (value || '').trim(),
956 ul = sb.querySelector('ul');
957
958 if (!sbox.options.multiple)
959 val = val.length ? [ val ] : [];
960 else
961 val = val.length ? val.split(/\s+/) : [];
962
963 val.forEach(function(item) {
964 var new_item = null;
965
966 ul.childNodes.forEach(function(li) {
967 if (li.getAttribute && li.getAttribute('data-value') === item)
968 new_item = li;
969 });
970
971 if (!new_item) {
972 new_item = sbox.createChoiceElement(sb, item);
973
974 if (!sbox.options.multiple) {
975 var old = ul.querySelector('li[created]');
976 if (old)
977 ul.removeChild(old);
978
979 new_item.setAttribute('created', '');
980 }
981
982 new_item = ul.insertBefore(new_item, ul.lastElementChild);
983 }
984
985 sbox.toggleItem(sb, new_item, true);
986 sbox.setFocus(sb, new_item, true);
987 });
988 },
989
990 clearChoices: function(reset_value) {
991 var ul = this.node.querySelector('ul'),
992 lis = ul ? ul.querySelectorAll('li[data-value]') : [],
993 len = lis.length - (this.options.create ? 1 : 0),
994 val = reset_value ? null : this.getValue();
995
996 for (var i = 0; i < len; i++) {
997 var lival = lis[i].getAttribute('data-value');
998 if (val == null ||
999 (!this.options.multiple && val != lival) ||
1000 (this.options.multiple && val.indexOf(lival) == -1))
1001 ul.removeChild(lis[i]);
1002 }
1003
1004 if (reset_value)
1005 this.setValues(this.node, {});
1006 },
1007
1008 addChoices: function(values, labels) {
1009 var sb = this.node,
1010 ul = sb.querySelector('ul'),
1011 lis = ul ? ul.querySelectorAll('li[data-value]') : [];
1012
1013 if (!Array.isArray(values))
1014 values = L.toArray(values);
1015
1016 if (!L.isObject(labels))
1017 labels = {};
1018
1019 for (var i = 0; i < values.length; i++) {
1020 var found = false;
1021
1022 for (var j = 0; j < lis.length; j++) {
1023 if (lis[j].getAttribute('data-value') === values[i]) {
1024 found = true;
1025 break;
1026 }
1027 }
1028
1029 if (found)
1030 continue;
1031
1032 ul.insertBefore(
1033 this.createChoiceElement(sb, values[i], labels[values[i]]),
1034 ul.lastElementChild);
1035 }
1036 },
1037
1038 closeAllDropdowns: function() {
1039 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1040 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1041 });
1042 },
1043
1044 handleClick: function(ev) {
1045 var sb = ev.currentTarget;
1046
1047 if (!sb.hasAttribute('open')) {
1048 if (!matchesElem(ev.target, 'input'))
1049 this.openDropdown(sb);
1050 }
1051 else {
1052 var li = findParent(ev.target, 'li');
1053 if (li && li.parentNode.classList.contains('dropdown'))
1054 this.toggleItem(sb, li);
1055 else if (li && li.parentNode.classList.contains('preview'))
1056 this.closeDropdown(sb);
1057 else if (matchesElem(ev.target, 'span.open, span.more'))
1058 this.closeDropdown(sb);
1059 }
1060
1061 ev.preventDefault();
1062 ev.stopPropagation();
1063 },
1064
1065 handleKeydown: function(ev) {
1066 var sb = ev.currentTarget;
1067
1068 if (matchesElem(ev.target, 'input'))
1069 return;
1070
1071 if (!sb.hasAttribute('open')) {
1072 switch (ev.keyCode) {
1073 case 37:
1074 case 38:
1075 case 39:
1076 case 40:
1077 this.openDropdown(sb);
1078 ev.preventDefault();
1079 }
1080 }
1081 else {
1082 var active = findParent(document.activeElement, 'li');
1083
1084 switch (ev.keyCode) {
1085 case 27:
1086 this.closeDropdown(sb);
1087 break;
1088
1089 case 13:
1090 if (active) {
1091 if (!active.hasAttribute('selected'))
1092 this.toggleItem(sb, active);
1093 this.closeDropdown(sb);
1094 ev.preventDefault();
1095 }
1096 break;
1097
1098 case 32:
1099 if (active) {
1100 this.toggleItem(sb, active);
1101 ev.preventDefault();
1102 }
1103 break;
1104
1105 case 38:
1106 if (active && active.previousElementSibling) {
1107 this.setFocus(sb, active.previousElementSibling);
1108 ev.preventDefault();
1109 }
1110 break;
1111
1112 case 40:
1113 if (active && active.nextElementSibling) {
1114 this.setFocus(sb, active.nextElementSibling);
1115 ev.preventDefault();
1116 }
1117 break;
1118 }
1119 }
1120 },
1121
1122 handleDropdownClose: function(ev) {
1123 var sb = ev.currentTarget;
1124
1125 this.closeDropdown(sb, true);
1126 },
1127
1128 handleDropdownSelect: function(ev) {
1129 var sb = ev.currentTarget,
1130 li = findParent(ev.target, 'li');
1131
1132 if (!li)
1133 return;
1134
1135 this.toggleItem(sb, li);
1136 this.closeDropdown(sb, true);
1137 },
1138
1139 handleMouseover: function(ev) {
1140 var sb = ev.currentTarget;
1141
1142 if (!sb.hasAttribute('open'))
1143 return;
1144
1145 var li = findParent(ev.target, 'li');
1146
1147 if (li && li.parentNode.classList.contains('dropdown'))
1148 this.setFocus(sb, li);
1149 },
1150
1151 handleFocus: function(ev) {
1152 var sb = ev.currentTarget;
1153
1154 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1155 if (s !== sb || sb.hasAttribute('open'))
1156 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1157 });
1158 },
1159
1160 handleCanaryFocus: function(ev) {
1161 this.closeDropdown(ev.currentTarget.parentNode);
1162 },
1163
1164 handleCreateKeydown: function(ev) {
1165 var input = ev.currentTarget,
1166 sb = findParent(input, '.cbi-dropdown');
1167
1168 switch (ev.keyCode) {
1169 case 13:
1170 ev.preventDefault();
1171
1172 if (input.classList.contains('cbi-input-invalid'))
1173 return;
1174
1175 this.createItems(sb, input.value);
1176 input.value = '';
1177 input.blur();
1178 break;
1179 }
1180 },
1181
1182 handleCreateFocus: function(ev) {
1183 var input = ev.currentTarget,
1184 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1185 sb = findParent(input, '.cbi-dropdown');
1186
1187 if (cbox)
1188 cbox.checked = true;
1189
1190 sb.setAttribute('locked-in', '');
1191 },
1192
1193 handleCreateBlur: function(ev) {
1194 var input = ev.currentTarget,
1195 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1196 sb = findParent(input, '.cbi-dropdown');
1197
1198 if (cbox)
1199 cbox.checked = false;
1200
1201 sb.removeAttribute('locked-in');
1202 },
1203
1204 handleCreateClick: function(ev) {
1205 ev.currentTarget.querySelector(this.options.create_query).focus();
1206 },
1207
1208 setValue: function(values) {
1209 if (this.options.multiple) {
1210 if (!Array.isArray(values))
1211 values = (values != null && values != '') ? [ values ] : [];
1212
1213 var v = {};
1214
1215 for (var i = 0; i < values.length; i++)
1216 v[values[i]] = true;
1217
1218 this.setValues(this.node, v);
1219 }
1220 else {
1221 var v = {};
1222
1223 if (values != null) {
1224 if (Array.isArray(values))
1225 v[values[0]] = true;
1226 else
1227 v[values] = true;
1228 }
1229
1230 this.setValues(this.node, v);
1231 }
1232 },
1233
1234 getValue: function() {
1235 var div = this.node.lastElementChild,
1236 h = div.querySelectorAll('input[type="hidden"]'),
1237 v = [];
1238
1239 for (var i = 0; i < h.length; i++)
1240 v.push(h[i].value);
1241
1242 return this.options.multiple ? v : v[0];
1243 }
1244 });
1245
1246 var UICombobox = UIDropdown.extend({
1247 __init__: function(value, choices, options) {
1248 this.super('__init__', [ value, choices, Object.assign({
1249 select_placeholder: _('-- Please choose --'),
1250 custom_placeholder: _('-- custom --'),
1251 dropdown_items: -1,
1252 sort: true
1253 }, options, {
1254 multiple: false,
1255 create: true,
1256 optional: true
1257 }) ]);
1258 }
1259 });
1260
1261 var UIComboButton = UIDropdown.extend({
1262 __init__: function(value, choices, options) {
1263 this.super('__init__', [ value, choices, Object.assign({
1264 sort: true
1265 }, options, {
1266 multiple: false,
1267 create: false,
1268 optional: false
1269 }) ]);
1270 },
1271
1272 render: function(/* ... */) {
1273 var node = UIDropdown.prototype.render.apply(this, arguments),
1274 val = this.getValue();
1275
1276 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
1277 node.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
1278
1279 return node;
1280 },
1281
1282 handleClick: function(ev) {
1283 var sb = ev.currentTarget,
1284 t = ev.target;
1285
1286 if (sb.hasAttribute('open') || L.dom.matches(t, '.cbi-dropdown > span.open'))
1287 return UIDropdown.prototype.handleClick.apply(this, arguments);
1288
1289 if (this.options.click)
1290 return this.options.click.call(sb, ev, this.getValue());
1291 },
1292
1293 toggleItem: function(sb /*, ... */) {
1294 var rv = UIDropdown.prototype.toggleItem.apply(this, arguments),
1295 val = this.getValue();
1296
1297 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
1298 sb.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
1299 else
1300 sb.setAttribute('class', 'cbi-dropdown');
1301
1302 return rv;
1303 }
1304 });
1305
1306 var UIDynamicList = UIElement.extend({
1307 __init__: function(values, choices, options) {
1308 if (!Array.isArray(values))
1309 values = (values != null && values != '') ? [ values ] : [];
1310
1311 if (typeof(choices) != 'object')
1312 choices = null;
1313
1314 this.values = values;
1315 this.choices = choices;
1316 this.options = Object.assign({}, options, {
1317 multiple: false,
1318 optional: true
1319 });
1320 },
1321
1322 render: function() {
1323 var dl = E('div', {
1324 'id': this.options.id,
1325 'class': 'cbi-dynlist'
1326 }, E('div', { 'class': 'add-item' }));
1327
1328 if (this.choices) {
1329 if (this.options.placeholder != null)
1330 this.options.select_placeholder = this.options.placeholder;
1331
1332 var cbox = new UICombobox(null, this.choices, this.options);
1333
1334 dl.lastElementChild.appendChild(cbox.render());
1335 }
1336 else {
1337 var inputEl = E('input', {
1338 'id': this.options.id ? 'widget.' + this.options.id : null,
1339 'type': 'text',
1340 'class': 'cbi-input-text',
1341 'placeholder': this.options.placeholder
1342 });
1343
1344 dl.lastElementChild.appendChild(inputEl);
1345 dl.lastElementChild.appendChild(E('div', { 'class': 'cbi-button cbi-button-add' }, '+'));
1346
1347 if (this.options.datatype || this.options.validate)
1348 L.ui.addValidator(inputEl, this.options.datatype || 'string',
1349 true, this.options.validate, 'blur', 'keyup');
1350 }
1351
1352 for (var i = 0; i < this.values.length; i++) {
1353 var label = this.choices ? this.choices[this.values[i]] : null;
1354
1355 if (L.dom.elem(label))
1356 label = label.cloneNode(true);
1357
1358 this.addItem(dl, this.values[i], label);
1359 }
1360
1361 return this.bind(dl);
1362 },
1363
1364 bind: function(dl) {
1365 dl.addEventListener('click', L.bind(this.handleClick, this));
1366 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
1367 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
1368
1369 this.node = dl;
1370
1371 this.setUpdateEvents(dl, 'cbi-dynlist-change');
1372 this.setChangeEvents(dl, 'cbi-dynlist-change');
1373
1374 L.dom.bindClassInstance(dl, this);
1375
1376 return dl;
1377 },
1378
1379 addItem: function(dl, value, text, flash) {
1380 var exists = false,
1381 new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
1382 E('span', {}, [ text || value ]),
1383 E('input', {
1384 'type': 'hidden',
1385 'name': this.options.name,
1386 'value': value })]);
1387
1388 dl.querySelectorAll('.item').forEach(function(item) {
1389 if (exists)
1390 return;
1391
1392 var hidden = item.querySelector('input[type="hidden"]');
1393
1394 if (hidden && hidden.parentNode !== item)
1395 hidden = null;
1396
1397 if (hidden && hidden.value === value)
1398 exists = true;
1399 });
1400
1401 if (!exists) {
1402 var ai = dl.querySelector('.add-item');
1403 ai.parentNode.insertBefore(new_item, ai);
1404 }
1405
1406 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
1407 bubbles: true,
1408 detail: {
1409 instance: this,
1410 element: dl,
1411 value: value,
1412 add: true
1413 }
1414 }));
1415 },
1416
1417 removeItem: function(dl, item) {
1418 var value = item.querySelector('input[type="hidden"]').value;
1419 var sb = dl.querySelector('.cbi-dropdown');
1420 if (sb)
1421 sb.querySelectorAll('ul > li').forEach(function(li) {
1422 if (li.getAttribute('data-value') === value) {
1423 if (li.hasAttribute('dynlistcustom'))
1424 li.parentNode.removeChild(li);
1425 else
1426 li.removeAttribute('unselectable');
1427 }
1428 });
1429
1430 item.parentNode.removeChild(item);
1431
1432 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
1433 bubbles: true,
1434 detail: {
1435 instance: this,
1436 element: dl,
1437 value: value,
1438 remove: true
1439 }
1440 }));
1441 },
1442
1443 handleClick: function(ev) {
1444 var dl = ev.currentTarget,
1445 item = findParent(ev.target, '.item');
1446
1447 if (item) {
1448 this.removeItem(dl, item);
1449 }
1450 else if (matchesElem(ev.target, '.cbi-button-add')) {
1451 var input = ev.target.previousElementSibling;
1452 if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
1453 this.addItem(dl, input.value, null, true);
1454 input.value = '';
1455 }
1456 }
1457 },
1458
1459 handleDropdownChange: function(ev) {
1460 var dl = ev.currentTarget,
1461 sbIn = ev.detail.instance,
1462 sbEl = ev.detail.element,
1463 sbVal = ev.detail.value;
1464
1465 if (sbVal === null)
1466 return;
1467
1468 sbIn.setValues(sbEl, null);
1469 sbVal.element.setAttribute('unselectable', '');
1470
1471 if (sbVal.element.hasAttribute('created')) {
1472 sbVal.element.removeAttribute('created');
1473 sbVal.element.setAttribute('dynlistcustom', '');
1474 }
1475
1476 var label = sbVal.text;
1477
1478 if (sbVal.element) {
1479 label = E([]);
1480
1481 for (var i = 0; i < sbVal.element.childNodes.length; i++)
1482 label.appendChild(sbVal.element.childNodes[i].cloneNode(true));
1483 }
1484
1485 this.addItem(dl, sbVal.value, label, true);
1486 },
1487
1488 handleKeydown: function(ev) {
1489 var dl = ev.currentTarget,
1490 item = findParent(ev.target, '.item');
1491
1492 if (item) {
1493 switch (ev.keyCode) {
1494 case 8: /* backspace */
1495 if (item.previousElementSibling)
1496 item.previousElementSibling.focus();
1497
1498 this.removeItem(dl, item);
1499 break;
1500
1501 case 46: /* delete */
1502 if (item.nextElementSibling) {
1503 if (item.nextElementSibling.classList.contains('item'))
1504 item.nextElementSibling.focus();
1505 else
1506 item.nextElementSibling.firstElementChild.focus();
1507 }
1508
1509 this.removeItem(dl, item);
1510 break;
1511 }
1512 }
1513 else if (matchesElem(ev.target, '.cbi-input-text')) {
1514 switch (ev.keyCode) {
1515 case 13: /* enter */
1516 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
1517 this.addItem(dl, ev.target.value, null, true);
1518 ev.target.value = '';
1519 ev.target.blur();
1520 ev.target.focus();
1521 }
1522
1523 ev.preventDefault();
1524 break;
1525 }
1526 }
1527 },
1528
1529 getValue: function() {
1530 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
1531 input = this.node.querySelector('.add-item > input[type="text"]'),
1532 v = [];
1533
1534 for (var i = 0; i < items.length; i++)
1535 v.push(items[i].value);
1536
1537 if (input && input.value != null && input.value.match(/\S/) &&
1538 input.classList.contains('cbi-input-invalid') == false &&
1539 v.filter(function(s) { return s == input.value }).length == 0)
1540 v.push(input.value);
1541
1542 return v;
1543 },
1544
1545 setValue: function(values) {
1546 if (!Array.isArray(values))
1547 values = (values != null && values != '') ? [ values ] : [];
1548
1549 var items = this.node.querySelectorAll('.item');
1550
1551 for (var i = 0; i < items.length; i++)
1552 if (items[i].parentNode === this.node)
1553 this.removeItem(this.node, items[i]);
1554
1555 for (var i = 0; i < values.length; i++)
1556 this.addItem(this.node, values[i],
1557 this.choices ? this.choices[values[i]] : null);
1558 },
1559
1560 addChoices: function(values, labels) {
1561 var dl = this.node.lastElementChild.firstElementChild;
1562 L.dom.callClassMethod(dl, 'addChoices', values, labels);
1563 },
1564
1565 clearChoices: function() {
1566 var dl = this.node.lastElementChild.firstElementChild;
1567 L.dom.callClassMethod(dl, 'clearChoices');
1568 }
1569 });
1570
1571 var UIHiddenfield = UIElement.extend({
1572 __init__: function(value, options) {
1573 this.value = value;
1574 this.options = Object.assign({
1575
1576 }, options);
1577 },
1578
1579 render: function() {
1580 var hiddenEl = E('input', {
1581 'id': this.options.id,
1582 'type': 'hidden',
1583 'value': this.value
1584 });
1585
1586 return this.bind(hiddenEl);
1587 },
1588
1589 bind: function(hiddenEl) {
1590 this.node = hiddenEl;
1591
1592 L.dom.bindClassInstance(hiddenEl, this);
1593
1594 return hiddenEl;
1595 },
1596
1597 getValue: function() {
1598 return this.node.value;
1599 },
1600
1601 setValue: function(value) {
1602 this.node.value = value;
1603 }
1604 });
1605
1606 var UIFileUpload = UIElement.extend({
1607 __init__: function(value, options) {
1608 this.value = value;
1609 this.options = Object.assign({
1610 show_hidden: false,
1611 enable_upload: true,
1612 enable_remove: true,
1613 root_directory: '/etc/luci-uploads'
1614 }, options);
1615 },
1616
1617 bind: function(browserEl) {
1618 this.node = browserEl;
1619
1620 this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
1621 this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
1622
1623 L.dom.bindClassInstance(browserEl, this);
1624
1625 return browserEl;
1626 },
1627
1628 render: function() {
1629 return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) {
1630 var label;
1631
1632 if (L.isObject(stat) && stat.type != 'directory')
1633 this.stat = stat;
1634
1635 if (this.stat != null)
1636 label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
1637 else if (this.value != null)
1638 label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
1639 else
1640 label = [ _('Select file…') ];
1641
1642 return this.bind(E('div', { 'id': this.options.id }, [
1643 E('button', {
1644 'class': 'btn',
1645 'click': L.ui.createHandlerFn(this, 'handleFileBrowser')
1646 }, label),
1647 E('div', {
1648 'class': 'cbi-filebrowser'
1649 }),
1650 E('input', {
1651 'type': 'hidden',
1652 'name': this.options.name,
1653 'value': this.value
1654 })
1655 ]));
1656 }, this));
1657 },
1658
1659 truncatePath: function(path) {
1660 if (path.length > 50)
1661 path = path.substring(0, 25) + '…' + path.substring(path.length - 25);
1662
1663 return path;
1664 },
1665
1666 iconForType: function(type) {
1667 switch (type) {
1668 case 'symlink':
1669 return E('img', {
1670 'src': L.resource('cbi/link.gif'),
1671 'title': _('Symbolic link'),
1672 'class': 'middle'
1673 });
1674
1675 case 'directory':
1676 return E('img', {
1677 'src': L.resource('cbi/folder.gif'),
1678 'title': _('Directory'),
1679 'class': 'middle'
1680 });
1681
1682 default:
1683 return E('img', {
1684 'src': L.resource('cbi/file.gif'),
1685 'title': _('File'),
1686 'class': 'middle'
1687 });
1688 }
1689 },
1690
1691 canonicalizePath: function(path) {
1692 return path.replace(/\/{2,}/, '/')
1693 .replace(/\/\.(\/|$)/g, '/')
1694 .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
1695 .replace(/\/$/, '');
1696 },
1697
1698 splitPath: function(path) {
1699 var croot = this.canonicalizePath(this.options.root_directory || '/'),
1700 cpath = this.canonicalizePath(path || '/');
1701
1702 if (cpath.length <= croot.length)
1703 return [ croot ];
1704
1705 if (cpath.charAt(croot.length) != '/')
1706 return [ croot ];
1707
1708 var parts = cpath.substring(croot.length + 1).split(/\//);
1709
1710 parts.unshift(croot);
1711
1712 return parts;
1713 },
1714
1715 handleUpload: function(path, list, ev) {
1716 var form = ev.target.parentNode,
1717 fileinput = form.querySelector('input[type="file"]'),
1718 nameinput = form.querySelector('input[type="text"]'),
1719 filename = (nameinput.value != null ? nameinput.value : '').trim();
1720
1721 ev.preventDefault();
1722
1723 if (filename == '' || filename.match(/\//) || fileinput.files[0] == null)
1724 return;
1725
1726 var existing = list.filter(function(e) { return e.name == filename })[0];
1727
1728 if (existing != null && existing.type == 'directory')
1729 return alert(_('A directory with the same name already exists.'));
1730 else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename)))
1731 return;
1732
1733 var data = new FormData();
1734
1735 data.append('sessionid', L.env.sessionid);
1736 data.append('filename', path + '/' + filename);
1737 data.append('filedata', fileinput.files[0]);
1738
1739 return L.Request.post(L.env.cgi_base + '/cgi-upload', data, {
1740 progress: L.bind(function(btn, ev) {
1741 btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100);
1742 }, this, ev.target)
1743 }).then(L.bind(function(path, ev, res) {
1744 var reply = res.json();
1745
1746 if (L.isObject(reply) && reply.failure)
1747 alert(_('Upload request failed: %s').format(reply.message));
1748
1749 return this.handleSelect(path, null, ev);
1750 }, this, path, ev));
1751 },
1752
1753 handleDelete: function(path, fileStat, ev) {
1754 var parent = path.replace(/\/[^\/]+$/, '') || '/',
1755 name = path.replace(/^.+\//, ''),
1756 msg;
1757
1758 ev.preventDefault();
1759
1760 if (fileStat.type == 'directory')
1761 msg = _('Do you really want to recursively delete the directory "%s" ?').format(name);
1762 else
1763 msg = _('Do you really want to delete "%s" ?').format(name);
1764
1765 if (confirm(msg)) {
1766 var button = this.node.firstElementChild,
1767 hidden = this.node.lastElementChild;
1768
1769 if (path == hidden.value) {
1770 L.dom.content(button, _('Select file…'));
1771 hidden.value = '';
1772 }
1773
1774 return fs.remove(path).then(L.bind(function(parent, ev) {
1775 return this.handleSelect(parent, null, ev);
1776 }, this, parent, ev)).catch(function(err) {
1777 alert(_('Delete request failed: %s').format(err.message));
1778 });
1779 }
1780 },
1781
1782 renderUpload: function(path, list) {
1783 if (!this.options.enable_upload)
1784 return E([]);
1785
1786 return E([
1787 E('a', {
1788 'href': '#',
1789 'class': 'btn cbi-button-positive',
1790 'click': function(ev) {
1791 var uploadForm = ev.target.nextElementSibling,
1792 fileInput = uploadForm.querySelector('input[type="file"]');
1793
1794 ev.target.style.display = 'none';
1795 uploadForm.style.display = '';
1796 fileInput.click();
1797 }
1798 }, _('Upload file…')),
1799 E('div', { 'class': 'upload', 'style': 'display:none' }, [
1800 E('input', {
1801 'type': 'file',
1802 'style': 'display:none',
1803 'change': function(ev) {
1804 var nameinput = ev.target.parentNode.querySelector('input[type="text"]'),
1805 uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save');
1806
1807 nameinput.value = ev.target.value.replace(/^.+[\/\\]/, '');
1808 uploadbtn.disabled = false;
1809 }
1810 }),
1811 E('button', {
1812 'class': 'btn',
1813 'click': function(ev) {
1814 ev.preventDefault();
1815 ev.target.previousElementSibling.click();
1816 }
1817 }, [ _('Browse…') ]),
1818 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
1819 E('button', {
1820 'class': 'btn cbi-button-save',
1821 'click': L.ui.createHandlerFn(this, 'handleUpload', path, list),
1822 'disabled': true
1823 }, [ _('Upload file') ])
1824 ])
1825 ]);
1826 },
1827
1828 renderListing: function(container, path, list) {
1829 var breadcrumb = E('p'),
1830 rows = E('ul');
1831
1832 list.sort(function(a, b) {
1833 var isDirA = (a.type == 'directory'),
1834 isDirB = (b.type == 'directory');
1835
1836 if (isDirA != isDirB)
1837 return isDirA < isDirB;
1838
1839 return a.name > b.name;
1840 });
1841
1842 for (var i = 0; i < list.length; i++) {
1843 if (!this.options.show_hidden && list[i].name.charAt(0) == '.')
1844 continue;
1845
1846 var entrypath = this.canonicalizePath(path + '/' + list[i].name),
1847 selected = (entrypath == this.node.lastElementChild.value),
1848 mtime = new Date(list[i].mtime * 1000);
1849
1850 rows.appendChild(E('li', [
1851 E('div', { 'class': 'name' }, [
1852 this.iconForType(list[i].type),
1853 ' ',
1854 E('a', {
1855 'href': '#',
1856 'style': selected ? 'font-weight:bold' : null,
1857 'click': L.ui.createHandlerFn(this, 'handleSelect',
1858 entrypath, list[i].type != 'directory' ? list[i] : null)
1859 }, '%h'.format(list[i].name))
1860 ]),
1861 E('div', { 'class': 'mtime hide-xs' }, [
1862 ' %04d-%02d-%02d %02d:%02d:%02d '.format(
1863 mtime.getFullYear(),
1864 mtime.getMonth() + 1,
1865 mtime.getDate(),
1866 mtime.getHours(),
1867 mtime.getMinutes(),
1868 mtime.getSeconds())
1869 ]),
1870 E('div', [
1871 selected ? E('button', {
1872 'class': 'btn',
1873 'click': L.ui.createHandlerFn(this, 'handleReset')
1874 }, [ _('Deselect') ]) : '',
1875 this.options.enable_remove ? E('button', {
1876 'class': 'btn cbi-button-negative',
1877 'click': L.ui.createHandlerFn(this, 'handleDelete', entrypath, list[i])
1878 }, [ _('Delete') ]) : ''
1879 ])
1880 ]));
1881 }
1882
1883 if (!rows.firstElementChild)
1884 rows.appendChild(E('em', _('No entries in this directory')));
1885
1886 var dirs = this.splitPath(path),
1887 cur = '';
1888
1889 for (var i = 0; i < dirs.length; i++) {
1890 cur = cur ? cur + '/' + dirs[i] : dirs[i];
1891 L.dom.append(breadcrumb, [
1892 i ? ' » ' : '',
1893 E('a', {
1894 'href': '#',
1895 'click': L.ui.createHandlerFn(this, 'handleSelect', cur || '/', null)
1896 }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')),
1897 ]);
1898 }
1899
1900 L.dom.content(container, [
1901 breadcrumb,
1902 rows,
1903 E('div', { 'class': 'right' }, [
1904 this.renderUpload(path, list),
1905 E('a', {
1906 'href': '#',
1907 'class': 'btn',
1908 'click': L.ui.createHandlerFn(this, 'handleCancel')
1909 }, _('Cancel'))
1910 ]),
1911 ]);
1912 },
1913
1914 handleCancel: function(ev) {
1915 var button = this.node.firstElementChild,
1916 browser = button.nextElementSibling;
1917
1918 browser.classList.remove('open');
1919 button.style.display = '';
1920
1921 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
1922
1923 ev.preventDefault();
1924 },
1925
1926 handleReset: function(ev) {
1927 var button = this.node.firstElementChild,
1928 hidden = this.node.lastElementChild;
1929
1930 hidden.value = '';
1931 L.dom.content(button, _('Select file…'));
1932
1933 this.handleCancel(ev);
1934 },
1935
1936 handleSelect: function(path, fileStat, ev) {
1937 var browser = L.dom.parent(ev.target, '.cbi-filebrowser'),
1938 ul = browser.querySelector('ul');
1939
1940 if (fileStat == null) {
1941 L.dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
1942 L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
1943 }
1944 else {
1945 var button = this.node.firstElementChild,
1946 hidden = this.node.lastElementChild;
1947
1948 path = this.canonicalizePath(path);
1949
1950 L.dom.content(button, [
1951 this.iconForType(fileStat.type),
1952 ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
1953 ]);
1954
1955 browser.classList.remove('open');
1956 button.style.display = '';
1957 hidden.value = path;
1958
1959 this.stat = Object.assign({ path: path }, fileStat);
1960 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
1961 }
1962 },
1963
1964 handleFileBrowser: function(ev) {
1965 var button = ev.target,
1966 browser = button.nextElementSibling,
1967 path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : (this.options.initial_directory || this.options.root_directory);
1968
1969 if (path.indexOf(this.options.root_directory) != 0)
1970 path = this.options.root_directory;
1971
1972 ev.preventDefault();
1973
1974 return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) {
1975 document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
1976 L.dom.findClassInstance(browserEl).handleCancel(ev);
1977 });
1978
1979 button.style.display = 'none';
1980 browser.classList.add('open');
1981
1982 return this.renderListing(browser, path, list);
1983 }, this, button, browser, path));
1984 },
1985
1986 getValue: function() {
1987 return this.node.lastElementChild.value;
1988 },
1989
1990 setValue: function(value) {
1991 this.node.lastElementChild.value = value;
1992 }
1993 });
1994
1995
1996 return L.Class.extend({
1997 __init__: function() {
1998 modalDiv = document.body.appendChild(
1999 L.dom.create('div', { id: 'modal_overlay' },
2000 L.dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
2001
2002 tooltipDiv = document.body.appendChild(
2003 L.dom.create('div', { class: 'cbi-tooltip' }));
2004
2005 /* setup old aliases */
2006 L.showModal = this.showModal;
2007 L.hideModal = this.hideModal;
2008 L.showTooltip = this.showTooltip;
2009 L.hideTooltip = this.hideTooltip;
2010 L.itemlist = this.itemlist;
2011
2012 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
2013 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
2014 document.addEventListener('focus', this.showTooltip.bind(this), true);
2015 document.addEventListener('blur', this.hideTooltip.bind(this), true);
2016
2017 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
2018 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
2019 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
2020 },
2021
2022 /* Modal dialog */
2023 showModal: function(title, children /* , ... */) {
2024 var dlg = modalDiv.firstElementChild;
2025
2026 dlg.setAttribute('class', 'modal');
2027
2028 for (var i = 2; i < arguments.length; i++)
2029 dlg.classList.add(arguments[i]);
2030
2031 L.dom.content(dlg, L.dom.create('h4', {}, title));
2032 L.dom.append(dlg, children);
2033
2034 document.body.classList.add('modal-overlay-active');
2035
2036 return dlg;
2037 },
2038
2039 hideModal: function() {
2040 document.body.classList.remove('modal-overlay-active');
2041 },
2042
2043 /* Tooltip */
2044 showTooltip: function(ev) {
2045 var target = findParent(ev.target, '[data-tooltip]');
2046
2047 if (!target)
2048 return;
2049
2050 if (tooltipTimeout !== null) {
2051 window.clearTimeout(tooltipTimeout);
2052 tooltipTimeout = null;
2053 }
2054
2055 var rect = target.getBoundingClientRect(),
2056 x = rect.left + window.pageXOffset,
2057 y = rect.top + rect.height + window.pageYOffset;
2058
2059 tooltipDiv.className = 'cbi-tooltip';
2060 tooltipDiv.innerHTML = '▲ ';
2061 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
2062
2063 if (target.hasAttribute('data-tooltip-style'))
2064 tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
2065
2066 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
2067 y -= (tooltipDiv.offsetHeight + target.offsetHeight);
2068 tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
2069 }
2070
2071 tooltipDiv.style.top = y + 'px';
2072 tooltipDiv.style.left = x + 'px';
2073 tooltipDiv.style.opacity = 1;
2074
2075 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
2076 bubbles: true,
2077 detail: { target: target }
2078 }));
2079 },
2080
2081 hideTooltip: function(ev) {
2082 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
2083 tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
2084 return;
2085
2086 if (tooltipTimeout !== null) {
2087 window.clearTimeout(tooltipTimeout);
2088 tooltipTimeout = null;
2089 }
2090
2091 tooltipDiv.style.opacity = 0;
2092 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
2093
2094 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
2095 },
2096
2097 addNotification: function(title, children /*, ... */) {
2098 var mc = document.querySelector('#maincontent') || document.body;
2099 var msg = E('div', {
2100 'class': 'alert-message fade-in',
2101 'style': 'display:flex',
2102 'transitionend': function(ev) {
2103 var node = ev.currentTarget;
2104 if (node.parentNode && node.classList.contains('fade-out'))
2105 node.parentNode.removeChild(node);
2106 }
2107 }, [
2108 E('div', { 'style': 'flex:10' }),
2109 E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
2110 E('button', {
2111 'class': 'btn',
2112 'style': 'margin-left:auto; margin-top:auto',
2113 'click': function(ev) {
2114 L.dom.parent(ev.target, '.alert-message').classList.add('fade-out');
2115 },
2116
2117 }, [ _('Dismiss') ])
2118 ])
2119 ]);
2120
2121 if (title != null)
2122 L.dom.append(msg.firstElementChild, E('h4', {}, title));
2123
2124 L.dom.append(msg.firstElementChild, children);
2125
2126 for (var i = 2; i < arguments.length; i++)
2127 msg.classList.add(arguments[i]);
2128
2129 mc.insertBefore(msg, mc.firstElementChild);
2130
2131 return msg;
2132 },
2133
2134 /* Widget helper */
2135 itemlist: function(node, items, separators) {
2136 var children = [];
2137
2138 if (!Array.isArray(separators))
2139 separators = [ separators || E('br') ];
2140
2141 for (var i = 0; i < items.length; i += 2) {
2142 if (items[i+1] !== null && items[i+1] !== undefined) {
2143 var sep = separators[(i/2) % separators.length],
2144 cld = [];
2145
2146 children.push(E('span', { class: 'nowrap' }, [
2147 items[i] ? E('strong', items[i] + ': ') : '',
2148 items[i+1]
2149 ]));
2150
2151 if ((i+2) < items.length)
2152 children.push(L.dom.elem(sep) ? sep.cloneNode(true) : sep);
2153 }
2154 }
2155
2156 L.dom.content(node, children);
2157
2158 return node;
2159 },
2160
2161 /* Tabs */
2162 tabs: L.Class.singleton({
2163 init: function() {
2164 var groups = [], prevGroup = null, currGroup = null;
2165
2166 document.querySelectorAll('[data-tab]').forEach(function(tab) {
2167 var parent = tab.parentNode;
2168
2169 if (L.dom.matches(tab, 'li') && L.dom.matches(parent, 'ul.cbi-tabmenu'))
2170 return;
2171
2172 if (!parent.hasAttribute('data-tab-group'))
2173 parent.setAttribute('data-tab-group', groups.length);
2174
2175 currGroup = +parent.getAttribute('data-tab-group');
2176
2177 if (currGroup !== prevGroup) {
2178 prevGroup = currGroup;
2179
2180 if (!groups[currGroup])
2181 groups[currGroup] = [];
2182 }
2183
2184 groups[currGroup].push(tab);
2185 });
2186
2187 for (var i = 0; i < groups.length; i++)
2188 this.initTabGroup(groups[i]);
2189
2190 document.addEventListener('dependency-update', this.updateTabs.bind(this));
2191
2192 this.updateTabs();
2193 },
2194
2195 initTabGroup: function(panes) {
2196 if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
2197 return;
2198
2199 var menu = E('ul', { 'class': 'cbi-tabmenu' }),
2200 group = panes[0].parentNode,
2201 groupId = +group.getAttribute('data-tab-group'),
2202 selected = null;
2203
2204 if (group.getAttribute('data-initialized') === 'true')
2205 return;
2206
2207 for (var i = 0, pane; pane = panes[i]; i++) {
2208 var name = pane.getAttribute('data-tab'),
2209 title = pane.getAttribute('data-tab-title'),
2210 active = pane.getAttribute('data-tab-active') === 'true';
2211
2212 menu.appendChild(E('li', {
2213 'style': this.isEmptyPane(pane) ? 'display:none' : null,
2214 'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
2215 'data-tab': name
2216 }, E('a', {
2217 'href': '#',
2218 'click': this.switchTab.bind(this)
2219 }, title)));
2220
2221 if (active)
2222 selected = i;
2223 }
2224
2225 group.parentNode.insertBefore(menu, group);
2226 group.setAttribute('data-initialized', true);
2227
2228 if (selected === null) {
2229 selected = this.getActiveTabId(panes[0]);
2230
2231 if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) {
2232 for (var i = 0; i < panes.length; i++) {
2233 if (!this.isEmptyPane(panes[i])) {
2234 selected = i;
2235 break;
2236 }
2237 }
2238 }
2239
2240 menu.childNodes[selected].classList.add('cbi-tab');
2241 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
2242 panes[selected].setAttribute('data-tab-active', 'true');
2243
2244 this.setActiveTabId(panes[selected], selected);
2245 }
2246
2247 panes[selected].dispatchEvent(new CustomEvent('cbi-tab-active', {
2248 detail: { tab: panes[selected].getAttribute('data-tab') }
2249 }));
2250
2251 this.updateTabs(group);
2252 },
2253
2254 isEmptyPane: function(pane) {
2255 return L.dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
2256 },
2257
2258 getPathForPane: function(pane) {
2259 var path = [], node = null;
2260
2261 for (node = pane ? pane.parentNode : null;
2262 node != null && node.hasAttribute != null;
2263 node = node.parentNode)
2264 {
2265 if (node.hasAttribute('data-tab'))
2266 path.unshift(node.getAttribute('data-tab'));
2267 else if (node.hasAttribute('data-section-id'))
2268 path.unshift(node.getAttribute('data-section-id'));
2269 }
2270
2271 return path.join('/');
2272 },
2273
2274 getActiveTabState: function() {
2275 var page = document.body.getAttribute('data-page');
2276
2277 try {
2278 var val = JSON.parse(window.sessionStorage.getItem('tab'));
2279 if (val.page === page && L.isObject(val.paths))
2280 return val;
2281 }
2282 catch(e) {}
2283
2284 window.sessionStorage.removeItem('tab');
2285 return { page: page, paths: {} };
2286 },
2287
2288 getActiveTabId: function(pane) {
2289 var path = this.getPathForPane(pane);
2290 return +this.getActiveTabState().paths[path] || 0;
2291 },
2292
2293 setActiveTabId: function(pane, tabIndex) {
2294 var path = this.getPathForPane(pane);
2295
2296 try {
2297 var state = this.getActiveTabState();
2298 state.paths[path] = tabIndex;
2299
2300 window.sessionStorage.setItem('tab', JSON.stringify(state));
2301 }
2302 catch (e) { return false; }
2303
2304 return true;
2305 },
2306
2307 updateTabs: function(ev, root) {
2308 (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
2309 var menu = pane.parentNode.previousElementSibling,
2310 tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
2311 n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
2312
2313 if (!menu || !tab)
2314 return;
2315
2316 if (this.isEmptyPane(pane)) {
2317 tab.style.display = 'none';
2318 tab.classList.remove('flash');
2319 }
2320 else if (tab.style.display === 'none') {
2321 tab.style.display = '';
2322 requestAnimationFrame(function() { tab.classList.add('flash') });
2323 }
2324
2325 if (n_errors) {
2326 tab.setAttribute('data-errors', n_errors);
2327 tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
2328 tab.setAttribute('data-tooltip-style', 'error');
2329 }
2330 else {
2331 tab.removeAttribute('data-errors');
2332 tab.removeAttribute('data-tooltip');
2333 }
2334 }, this));
2335 },
2336
2337 switchTab: function(ev) {
2338 var tab = ev.target.parentNode,
2339 name = tab.getAttribute('data-tab'),
2340 menu = tab.parentNode,
2341 group = menu.nextElementSibling,
2342 groupId = +group.getAttribute('data-tab-group'),
2343 index = 0;
2344
2345 ev.preventDefault();
2346
2347 if (!tab.classList.contains('cbi-tab-disabled'))
2348 return;
2349
2350 menu.querySelectorAll('[data-tab]').forEach(function(tab) {
2351 tab.classList.remove('cbi-tab');
2352 tab.classList.remove('cbi-tab-disabled');
2353 tab.classList.add(
2354 tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
2355 });
2356
2357 group.childNodes.forEach(function(pane) {
2358 if (L.dom.matches(pane, '[data-tab]')) {
2359 if (pane.getAttribute('data-tab') === name) {
2360 pane.setAttribute('data-tab-active', 'true');
2361 pane.dispatchEvent(new CustomEvent('cbi-tab-active', { detail: { tab: name } }));
2362 L.ui.tabs.setActiveTabId(pane, index);
2363 }
2364 else {
2365 pane.setAttribute('data-tab-active', 'false');
2366 }
2367
2368 index++;
2369 }
2370 });
2371 }
2372 }),
2373
2374 /* File uploading */
2375 uploadFile: function(path, progressStatusNode) {
2376 return new Promise(function(resolveFn, rejectFn) {
2377 L.ui.showModal(_('Uploading file…'), [
2378 E('p', _('Please select the file to upload.')),
2379 E('div', { 'style': 'display:flex' }, [
2380 E('div', { 'class': 'left', 'style': 'flex:1' }, [
2381 E('input', {
2382 type: 'file',
2383 style: 'display:none',
2384 change: function(ev) {
2385 var modal = L.dom.parent(ev.target, '.modal'),
2386 body = modal.querySelector('p'),
2387 upload = modal.querySelector('.cbi-button-action.important'),
2388 file = ev.currentTarget.files[0];
2389
2390 if (file == null)
2391 return;
2392
2393 L.dom.content(body, [
2394 E('ul', {}, [
2395 E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
2396 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
2397 ])
2398 ]);
2399
2400 upload.disabled = false;
2401 upload.focus();
2402 }
2403 }),
2404 E('button', {
2405 'class': 'btn',
2406 'click': function(ev) {
2407 ev.target.previousElementSibling.click();
2408 }
2409 }, [ _('Browse…') ])
2410 ]),
2411 E('div', { 'class': 'right', 'style': 'flex:1' }, [
2412 E('button', {
2413 'class': 'btn',
2414 'click': function() {
2415 L.ui.hideModal();
2416 rejectFn(new Error('Upload has been cancelled'));
2417 }
2418 }, [ _('Cancel') ]),
2419 ' ',
2420 E('button', {
2421 'class': 'btn cbi-button-action important',
2422 'disabled': true,
2423 'click': function(ev) {
2424 var input = L.dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
2425
2426 if (!input.files[0])
2427 return;
2428
2429 var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
2430
2431 L.ui.showModal(_('Uploading file…'), [ progress ]);
2432
2433 var data = new FormData();
2434
2435 data.append('sessionid', rpc.getSessionID());
2436 data.append('filename', path);
2437 data.append('filedata', input.files[0]);
2438
2439 var filename = input.files[0].name;
2440
2441 L.Request.post(L.env.cgi_base + '/cgi-upload', data, {
2442 timeout: 0,
2443 progress: function(pev) {
2444 var percent = (pev.loaded / pev.total) * 100;
2445
2446 if (progressStatusNode)
2447 progressStatusNode.data = '%.2f%%'.format(percent);
2448
2449 progress.setAttribute('title', '%.2f%%'.format(percent));
2450 progress.firstElementChild.style.width = '%.2f%%'.format(percent);
2451 }
2452 }).then(function(res) {
2453 var reply = res.json();
2454
2455 L.ui.hideModal();
2456
2457 if (L.isObject(reply) && reply.failure) {
2458 L.ui.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
2459 rejectFn(new Error(reply.failure));
2460 }
2461 else {
2462 reply.name = filename;
2463 resolveFn(reply);
2464 }
2465 }, function(err) {
2466 L.ui.hideModal();
2467 rejectFn(err);
2468 });
2469 }
2470 }, [ _('Upload') ])
2471 ])
2472 ])
2473 ]);
2474 });
2475 },
2476
2477 /* Reconnect handling */
2478 pingDevice: function(proto, ipaddr) {
2479 var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
2480
2481 return new Promise(function(resolveFn, rejectFn) {
2482 var img = new Image();
2483
2484 img.onload = resolveFn;
2485 img.onerror = rejectFn;
2486
2487 window.setTimeout(rejectFn, 1000);
2488
2489 img.src = target;
2490 });
2491 },
2492
2493 awaitReconnect: function(/* ... */) {
2494 var ipaddrs = arguments.length ? arguments : [ window.location.host ];
2495
2496 window.setTimeout(L.bind(function() {
2497 L.Poll.add(L.bind(function() {
2498 var tasks = [], reachable = false;
2499
2500 for (var i = 0; i < 2; i++)
2501 for (var j = 0; j < ipaddrs.length; j++)
2502 tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
2503 .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
2504
2505 return Promise.all(tasks).then(function() {
2506 if (reachable) {
2507 L.Poll.stop();
2508 window.location = reachable;
2509 }
2510 });
2511 }, this));
2512 }, this), 5000);
2513 },
2514
2515 /* UCI Changes */
2516 changes: L.Class.singleton({
2517 init: function() {
2518 if (!L.env.sessionid)
2519 return;
2520
2521 return uci.changes().then(L.bind(this.renderChangeIndicator, this));
2522 },
2523
2524 setIndicator: function(n) {
2525 var i = document.querySelector('.uci_change_indicator');
2526 if (i == null) {
2527 var poll = document.getElementById('xhr_poll_status');
2528 i = poll.parentNode.insertBefore(E('a', {
2529 'href': '#',
2530 'class': 'uci_change_indicator label notice',
2531 'click': L.bind(this.displayChanges, this)
2532 }), poll);
2533 }
2534
2535 if (n > 0) {
2536 L.dom.content(i, [ _('Unsaved Changes'), ': ', n ]);
2537 i.classList.add('flash');
2538 i.style.display = '';
2539 }
2540 else {
2541 i.classList.remove('flash');
2542 i.style.display = 'none';
2543 }
2544 },
2545
2546 renderChangeIndicator: function(changes) {
2547 var n_changes = 0;
2548
2549 for (var config in changes)
2550 if (changes.hasOwnProperty(config))
2551 n_changes += changes[config].length;
2552
2553 this.changes = changes;
2554 this.setIndicator(n_changes);
2555 },
2556
2557 changeTemplates: {
2558 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
2559 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
2560 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
2561 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
2562 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
2563 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
2564 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
2565 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
2566 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
2567 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
2568 },
2569
2570 displayChanges: function() {
2571 var list = E('div', { 'class': 'uci-change-list' }),
2572 dlg = L.ui.showModal(_('Configuration') + ' / ' + _('Changes'), [
2573 E('div', { 'class': 'cbi-section' }, [
2574 E('strong', _('Legend:')),
2575 E('div', { 'class': 'uci-change-legend' }, [
2576 E('div', { 'class': 'uci-change-legend-label' }, [
2577 E('ins', '&#160;'), ' ', _('Section added') ]),
2578 E('div', { 'class': 'uci-change-legend-label' }, [
2579 E('del', '&#160;'), ' ', _('Section removed') ]),
2580 E('div', { 'class': 'uci-change-legend-label' }, [
2581 E('var', {}, E('ins', '&#160;')), ' ', _('Option changed') ]),
2582 E('div', { 'class': 'uci-change-legend-label' }, [
2583 E('var', {}, E('del', '&#160;')), ' ', _('Option removed') ])]),
2584 E('br'), list,
2585 E('div', { 'class': 'right' }, [
2586 E('button', {
2587 'class': 'btn',
2588 'click': L.ui.hideModal
2589 }, [ _('Dismiss') ]), ' ',
2590 E('button', {
2591 'class': 'cbi-button cbi-button-positive important',
2592 'click': L.bind(this.apply, this, true)
2593 }, [ _('Save & Apply') ]), ' ',
2594 E('button', {
2595 'class': 'cbi-button cbi-button-reset',
2596 'click': L.bind(this.revert, this)
2597 }, [ _('Revert') ])])])
2598 ]);
2599
2600 for (var config in this.changes) {
2601 if (!this.changes.hasOwnProperty(config))
2602 continue;
2603
2604 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
2605
2606 for (var i = 0, added = null; i < this.changes[config].length; i++) {
2607 var chg = this.changes[config][i],
2608 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
2609
2610 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
2611 switch (+m1) {
2612 case 0:
2613 return config;
2614
2615 case 2:
2616 if (added != null && chg[1] == added[0])
2617 return '@' + added[1] + '[-1]';
2618 else
2619 return chg[1];
2620
2621 case 4:
2622 return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
2623
2624 default:
2625 return chg[m1-1];
2626 }
2627 })));
2628
2629 if (chg[0] == 'add')
2630 added = [ chg[1], chg[2] ];
2631 }
2632 }
2633
2634 list.appendChild(E('br'));
2635 dlg.classList.add('uci-dialog');
2636 },
2637
2638 displayStatus: function(type, content) {
2639 if (type) {
2640 var message = L.ui.showModal('', '');
2641
2642 message.classList.add('alert-message');
2643 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
2644
2645 if (content)
2646 L.dom.content(message, content);
2647
2648 if (!this.was_polling) {
2649 this.was_polling = L.Request.poll.active();
2650 L.Request.poll.stop();
2651 }
2652 }
2653 else {
2654 L.ui.hideModal();
2655
2656 if (this.was_polling)
2657 L.Request.poll.start();
2658 }
2659 },
2660
2661 rollback: function(checked) {
2662 if (checked) {
2663 this.displayStatus('warning spinning',
2664 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
2665 .format(L.env.apply_rollback)));
2666
2667 var call = function(r, data, duration) {
2668 if (r.status === 204) {
2669 L.ui.changes.displayStatus('warning', [
2670 E('h4', _('Configuration changes have been rolled back!')),
2671 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)),
2672 E('div', { 'class': 'right' }, [
2673 E('button', {
2674 'class': 'btn',
2675 'click': L.bind(L.ui.changes.displayStatus, L.ui.changes, false)
2676 }, [ _('Dismiss') ]), ' ',
2677 E('button', {
2678 'class': 'btn cbi-button-action important',
2679 'click': L.bind(L.ui.changes.revert, L.ui.changes)
2680 }, [ _('Revert changes') ]), ' ',
2681 E('button', {
2682 'class': 'btn cbi-button-negative important',
2683 'click': L.bind(L.ui.changes.apply, L.ui.changes, false)
2684 }, [ _('Apply unchecked') ])
2685 ])
2686 ]);
2687
2688 return;
2689 }
2690
2691 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
2692 window.setTimeout(function() {
2693 L.Request.request(L.url('admin/uci/confirm'), {
2694 method: 'post',
2695 timeout: L.env.apply_timeout * 1000,
2696 query: { sid: L.env.sessionid, token: L.env.token }
2697 }).then(call);
2698 }, delay);
2699 };
2700
2701 call({ status: 0 });
2702 }
2703 else {
2704 this.displayStatus('warning', [
2705 E('h4', _('Device unreachable!')),
2706 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.'))
2707 ]);
2708 }
2709 },
2710
2711 confirm: function(checked, deadline, override_token) {
2712 var tt;
2713 var ts = Date.now();
2714
2715 this.displayStatus('notice');
2716
2717 if (override_token)
2718 this.confirm_auth = { token: override_token };
2719
2720 var call = function(r, data, duration) {
2721 if (Date.now() >= deadline) {
2722 window.clearTimeout(tt);
2723 L.ui.changes.rollback(checked);
2724 return;
2725 }
2726 else if (r && (r.status === 200 || r.status === 204)) {
2727 document.dispatchEvent(new CustomEvent('uci-applied'));
2728
2729 L.ui.changes.setIndicator(0);
2730 L.ui.changes.displayStatus('notice',
2731 E('p', _('Configuration changes applied.')));
2732
2733 window.clearTimeout(tt);
2734 window.setTimeout(function() {
2735 //L.ui.changes.displayStatus(false);
2736 window.location = window.location.href.split('#')[0];
2737 }, L.env.apply_display * 1000);
2738
2739 return;
2740 }
2741
2742 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
2743 window.setTimeout(function() {
2744 L.Request.request(L.url('admin/uci/confirm'), {
2745 method: 'post',
2746 timeout: L.env.apply_timeout * 1000,
2747 query: L.ui.changes.confirm_auth
2748 }).then(call, call);
2749 }, delay);
2750 };
2751
2752 var tick = function() {
2753 var now = Date.now();
2754
2755 L.ui.changes.displayStatus('notice spinning',
2756 E('p', _('Applying configuration changes… %ds')
2757 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
2758
2759 if (now >= deadline)
2760 return;
2761
2762 tt = window.setTimeout(tick, 1000 - (now - ts));
2763 ts = now;
2764 };
2765
2766 tick();
2767
2768 /* wait a few seconds for the settings to become effective */
2769 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
2770 },
2771
2772 apply: function(checked) {
2773 this.displayStatus('notice spinning',
2774 E('p', _('Starting configuration apply…')));
2775
2776 L.Request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
2777 method: 'post',
2778 query: { sid: L.env.sessionid, token: L.env.token }
2779 }).then(function(r) {
2780 if (r.status === (checked ? 200 : 204)) {
2781 var tok = null; try { tok = r.json(); } catch(e) {}
2782 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
2783 L.ui.changes.confirm_auth = tok;
2784
2785 L.ui.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
2786 }
2787 else if (checked && r.status === 204) {
2788 L.ui.changes.displayStatus('notice',
2789 E('p', _('There are no changes to apply')));
2790
2791 window.setTimeout(function() {
2792 L.ui.changes.displayStatus(false);
2793 }, L.env.apply_display * 1000);
2794 }
2795 else {
2796 L.ui.changes.displayStatus('warning',
2797 E('p', _('Apply request failed with status <code>%h</code>')
2798 .format(r.responseText || r.statusText || r.status)));
2799
2800 window.setTimeout(function() {
2801 L.ui.changes.displayStatus(false);
2802 }, L.env.apply_display * 1000);
2803 }
2804 });
2805 },
2806
2807 revert: function() {
2808 this.displayStatus('notice spinning',
2809 E('p', _('Reverting configuration…')));
2810
2811 L.Request.request(L.url('admin/uci/revert'), {
2812 method: 'post',
2813 query: { sid: L.env.sessionid, token: L.env.token }
2814 }).then(function(r) {
2815 if (r.status === 200) {
2816 document.dispatchEvent(new CustomEvent('uci-reverted'));
2817
2818 L.ui.changes.setIndicator(0);
2819 L.ui.changes.displayStatus('notice',
2820 E('p', _('Changes have been reverted.')));
2821
2822 window.setTimeout(function() {
2823 //L.ui.changes.displayStatus(false);
2824 window.location = window.location.href.split('#')[0];
2825 }, L.env.apply_display * 1000);
2826 }
2827 else {
2828 L.ui.changes.displayStatus('warning',
2829 E('p', _('Revert request failed with status <code>%h</code>')
2830 .format(r.statusText || r.status)));
2831
2832 window.setTimeout(function() {
2833 L.ui.changes.displayStatus(false);
2834 }, L.env.apply_display * 1000);
2835 }
2836 });
2837 }
2838 }),
2839
2840 addValidator: function(field, type, optional, vfunc /*, ... */) {
2841 if (type == null)
2842 return;
2843
2844 var events = this.varargs(arguments, 3);
2845 if (events.length == 0)
2846 events.push('blur', 'keyup');
2847
2848 try {
2849 var cbiValidator = L.validation.create(field, type, optional, vfunc),
2850 validatorFn = cbiValidator.validate.bind(cbiValidator);
2851
2852 for (var i = 0; i < events.length; i++)
2853 field.addEventListener(events[i], validatorFn);
2854
2855 validatorFn();
2856
2857 return validatorFn;
2858 }
2859 catch (e) { }
2860 },
2861
2862 createHandlerFn: function(ctx, fn /*, ... */) {
2863 if (typeof(fn) == 'string')
2864 fn = ctx[fn];
2865
2866 if (typeof(fn) != 'function')
2867 return null;
2868
2869 var arg_offset = arguments.length - 2;
2870
2871 return Function.prototype.bind.apply(function() {
2872 var t = arguments[arg_offset].target;
2873
2874 t.classList.add('spinning');
2875 t.disabled = true;
2876
2877 if (t.blur)
2878 t.blur();
2879
2880 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
2881 t.classList.remove('spinning');
2882 t.disabled = false;
2883 });
2884 }, this.varargs(arguments, 2, ctx));
2885 },
2886
2887 AbstractElement: UIElement,
2888
2889 /* Widgets */
2890 Textfield: UITextfield,
2891 Textarea: UITextarea,
2892 Checkbox: UICheckbox,
2893 Select: UISelect,
2894 Dropdown: UIDropdown,
2895 DynamicList: UIDynamicList,
2896 Combobox: UICombobox,
2897 ComboButton: UIComboButton,
2898 Hiddenfield: UIHiddenfield,
2899 FileUpload: UIFileUpload
2900 });