Merge pull request #3545 from michyprima/add-app-ser2net-js
[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 this.updateTabs(group);
2248 },
2249
2250 isEmptyPane: function(pane) {
2251 return L.dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
2252 },
2253
2254 getPathForPane: function(pane) {
2255 var path = [], node = null;
2256
2257 for (node = pane ? pane.parentNode : null;
2258 node != null && node.hasAttribute != null;
2259 node = node.parentNode)
2260 {
2261 if (node.hasAttribute('data-tab'))
2262 path.unshift(node.getAttribute('data-tab'));
2263 else if (node.hasAttribute('data-section-id'))
2264 path.unshift(node.getAttribute('data-section-id'));
2265 }
2266
2267 return path.join('/');
2268 },
2269
2270 getActiveTabState: function() {
2271 var page = document.body.getAttribute('data-page');
2272
2273 try {
2274 var val = JSON.parse(window.sessionStorage.getItem('tab'));
2275 if (val.page === page && L.isObject(val.paths))
2276 return val;
2277 }
2278 catch(e) {}
2279
2280 window.sessionStorage.removeItem('tab');
2281 return { page: page, paths: {} };
2282 },
2283
2284 getActiveTabId: function(pane) {
2285 var path = this.getPathForPane(pane);
2286 return +this.getActiveTabState().paths[path] || 0;
2287 },
2288
2289 setActiveTabId: function(pane, tabIndex) {
2290 var path = this.getPathForPane(pane);
2291
2292 try {
2293 var state = this.getActiveTabState();
2294 state.paths[path] = tabIndex;
2295
2296 window.sessionStorage.setItem('tab', JSON.stringify(state));
2297 }
2298 catch (e) { return false; }
2299
2300 return true;
2301 },
2302
2303 updateTabs: function(ev, root) {
2304 (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
2305 var menu = pane.parentNode.previousElementSibling,
2306 tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
2307 n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
2308
2309 if (!menu || !tab)
2310 return;
2311
2312 if (this.isEmptyPane(pane)) {
2313 tab.style.display = 'none';
2314 tab.classList.remove('flash');
2315 }
2316 else if (tab.style.display === 'none') {
2317 tab.style.display = '';
2318 requestAnimationFrame(function() { tab.classList.add('flash') });
2319 }
2320
2321 if (n_errors) {
2322 tab.setAttribute('data-errors', n_errors);
2323 tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
2324 tab.setAttribute('data-tooltip-style', 'error');
2325 }
2326 else {
2327 tab.removeAttribute('data-errors');
2328 tab.removeAttribute('data-tooltip');
2329 }
2330 }, this));
2331 },
2332
2333 switchTab: function(ev) {
2334 var tab = ev.target.parentNode,
2335 name = tab.getAttribute('data-tab'),
2336 menu = tab.parentNode,
2337 group = menu.nextElementSibling,
2338 groupId = +group.getAttribute('data-tab-group'),
2339 index = 0;
2340
2341 ev.preventDefault();
2342
2343 if (!tab.classList.contains('cbi-tab-disabled'))
2344 return;
2345
2346 menu.querySelectorAll('[data-tab]').forEach(function(tab) {
2347 tab.classList.remove('cbi-tab');
2348 tab.classList.remove('cbi-tab-disabled');
2349 tab.classList.add(
2350 tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
2351 });
2352
2353 group.childNodes.forEach(function(pane) {
2354 if (L.dom.matches(pane, '[data-tab]')) {
2355 if (pane.getAttribute('data-tab') === name) {
2356 pane.setAttribute('data-tab-active', 'true');
2357 L.ui.tabs.setActiveTabId(pane, index);
2358 }
2359 else {
2360 pane.setAttribute('data-tab-active', 'false');
2361 }
2362
2363 index++;
2364 }
2365 });
2366 }
2367 }),
2368
2369 /* File uploading */
2370 uploadFile: function(path, progressStatusNode) {
2371 return new Promise(function(resolveFn, rejectFn) {
2372 L.ui.showModal(_('Uploading file…'), [
2373 E('p', _('Please select the file to upload.')),
2374 E('div', { 'style': 'display:flex' }, [
2375 E('div', { 'class': 'left', 'style': 'flex:1' }, [
2376 E('input', {
2377 type: 'file',
2378 style: 'display:none',
2379 change: function(ev) {
2380 var modal = L.dom.parent(ev.target, '.modal'),
2381 body = modal.querySelector('p'),
2382 upload = modal.querySelector('.cbi-button-action.important'),
2383 file = ev.currentTarget.files[0];
2384
2385 if (file == null)
2386 return;
2387
2388 L.dom.content(body, [
2389 E('ul', {}, [
2390 E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
2391 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
2392 ])
2393 ]);
2394
2395 upload.disabled = false;
2396 upload.focus();
2397 }
2398 }),
2399 E('button', {
2400 'class': 'btn',
2401 'click': function(ev) {
2402 ev.target.previousElementSibling.click();
2403 }
2404 }, [ _('Browse…') ])
2405 ]),
2406 E('div', { 'class': 'right', 'style': 'flex:1' }, [
2407 E('button', {
2408 'class': 'btn',
2409 'click': function() {
2410 L.ui.hideModal();
2411 rejectFn(new Error('Upload has been cancelled'));
2412 }
2413 }, [ _('Cancel') ]),
2414 ' ',
2415 E('button', {
2416 'class': 'btn cbi-button-action important',
2417 'disabled': true,
2418 'click': function(ev) {
2419 var input = L.dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
2420
2421 if (!input.files[0])
2422 return;
2423
2424 var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
2425
2426 L.ui.showModal(_('Uploading file…'), [ progress ]);
2427
2428 var data = new FormData();
2429
2430 data.append('sessionid', rpc.getSessionID());
2431 data.append('filename', path);
2432 data.append('filedata', input.files[0]);
2433
2434 var filename = input.files[0].name;
2435
2436 L.Request.post(L.env.cgi_base + '/cgi-upload', data, {
2437 timeout: 0,
2438 progress: function(pev) {
2439 var percent = (pev.loaded / pev.total) * 100;
2440
2441 if (progressStatusNode)
2442 progressStatusNode.data = '%.2f%%'.format(percent);
2443
2444 progress.setAttribute('title', '%.2f%%'.format(percent));
2445 progress.firstElementChild.style.width = '%.2f%%'.format(percent);
2446 }
2447 }).then(function(res) {
2448 var reply = res.json();
2449
2450 L.ui.hideModal();
2451
2452 if (L.isObject(reply) && reply.failure) {
2453 L.ui.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
2454 rejectFn(new Error(reply.failure));
2455 }
2456 else {
2457 reply.name = filename;
2458 resolveFn(reply);
2459 }
2460 }, function(err) {
2461 L.ui.hideModal();
2462 rejectFn(err);
2463 });
2464 }
2465 }, [ _('Upload') ])
2466 ])
2467 ])
2468 ]);
2469 });
2470 },
2471
2472 /* Reconnect handling */
2473 pingDevice: function(proto, ipaddr) {
2474 var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
2475
2476 return new Promise(function(resolveFn, rejectFn) {
2477 var img = new Image();
2478
2479 img.onload = resolveFn;
2480 img.onerror = rejectFn;
2481
2482 window.setTimeout(rejectFn, 1000);
2483
2484 img.src = target;
2485 });
2486 },
2487
2488 awaitReconnect: function(/* ... */) {
2489 var ipaddrs = arguments.length ? arguments : [ window.location.host ];
2490
2491 window.setTimeout(L.bind(function() {
2492 L.Poll.add(L.bind(function() {
2493 var tasks = [], reachable = false;
2494
2495 for (var i = 0; i < 2; i++)
2496 for (var j = 0; j < ipaddrs.length; j++)
2497 tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
2498 .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
2499
2500 return Promise.all(tasks).then(function() {
2501 if (reachable) {
2502 L.Poll.stop();
2503 window.location = reachable;
2504 }
2505 });
2506 }, this));
2507 }, this), 5000);
2508 },
2509
2510 /* UCI Changes */
2511 changes: L.Class.singleton({
2512 init: function() {
2513 if (!L.env.sessionid)
2514 return;
2515
2516 return uci.changes().then(L.bind(this.renderChangeIndicator, this));
2517 },
2518
2519 setIndicator: function(n) {
2520 var i = document.querySelector('.uci_change_indicator');
2521 if (i == null) {
2522 var poll = document.getElementById('xhr_poll_status');
2523 i = poll.parentNode.insertBefore(E('a', {
2524 'href': '#',
2525 'class': 'uci_change_indicator label notice',
2526 'click': L.bind(this.displayChanges, this)
2527 }), poll);
2528 }
2529
2530 if (n > 0) {
2531 L.dom.content(i, [ _('Unsaved Changes'), ': ', n ]);
2532 i.classList.add('flash');
2533 i.style.display = '';
2534 }
2535 else {
2536 i.classList.remove('flash');
2537 i.style.display = 'none';
2538 }
2539 },
2540
2541 renderChangeIndicator: function(changes) {
2542 var n_changes = 0;
2543
2544 for (var config in changes)
2545 if (changes.hasOwnProperty(config))
2546 n_changes += changes[config].length;
2547
2548 this.changes = changes;
2549 this.setIndicator(n_changes);
2550 },
2551
2552 changeTemplates: {
2553 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
2554 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
2555 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
2556 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
2557 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
2558 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
2559 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
2560 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
2561 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
2562 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
2563 },
2564
2565 displayChanges: function() {
2566 var list = E('div', { 'class': 'uci-change-list' }),
2567 dlg = L.ui.showModal(_('Configuration') + ' / ' + _('Changes'), [
2568 E('div', { 'class': 'cbi-section' }, [
2569 E('strong', _('Legend:')),
2570 E('div', { 'class': 'uci-change-legend' }, [
2571 E('div', { 'class': 'uci-change-legend-label' }, [
2572 E('ins', '&#160;'), ' ', _('Section added') ]),
2573 E('div', { 'class': 'uci-change-legend-label' }, [
2574 E('del', '&#160;'), ' ', _('Section removed') ]),
2575 E('div', { 'class': 'uci-change-legend-label' }, [
2576 E('var', {}, E('ins', '&#160;')), ' ', _('Option changed') ]),
2577 E('div', { 'class': 'uci-change-legend-label' }, [
2578 E('var', {}, E('del', '&#160;')), ' ', _('Option removed') ])]),
2579 E('br'), list,
2580 E('div', { 'class': 'right' }, [
2581 E('button', {
2582 'class': 'btn',
2583 'click': L.ui.hideModal
2584 }, [ _('Dismiss') ]), ' ',
2585 E('button', {
2586 'class': 'cbi-button cbi-button-positive important',
2587 'click': L.bind(this.apply, this, true)
2588 }, [ _('Save & Apply') ]), ' ',
2589 E('button', {
2590 'class': 'cbi-button cbi-button-reset',
2591 'click': L.bind(this.revert, this)
2592 }, [ _('Revert') ])])])
2593 ]);
2594
2595 for (var config in this.changes) {
2596 if (!this.changes.hasOwnProperty(config))
2597 continue;
2598
2599 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
2600
2601 for (var i = 0, added = null; i < this.changes[config].length; i++) {
2602 var chg = this.changes[config][i],
2603 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
2604
2605 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
2606 switch (+m1) {
2607 case 0:
2608 return config;
2609
2610 case 2:
2611 if (added != null && chg[1] == added[0])
2612 return '@' + added[1] + '[-1]';
2613 else
2614 return chg[1];
2615
2616 case 4:
2617 return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
2618
2619 default:
2620 return chg[m1-1];
2621 }
2622 })));
2623
2624 if (chg[0] == 'add')
2625 added = [ chg[1], chg[2] ];
2626 }
2627 }
2628
2629 list.appendChild(E('br'));
2630 dlg.classList.add('uci-dialog');
2631 },
2632
2633 displayStatus: function(type, content) {
2634 if (type) {
2635 var message = L.ui.showModal('', '');
2636
2637 message.classList.add('alert-message');
2638 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
2639
2640 if (content)
2641 L.dom.content(message, content);
2642
2643 if (!this.was_polling) {
2644 this.was_polling = L.Request.poll.active();
2645 L.Request.poll.stop();
2646 }
2647 }
2648 else {
2649 L.ui.hideModal();
2650
2651 if (this.was_polling)
2652 L.Request.poll.start();
2653 }
2654 },
2655
2656 rollback: function(checked) {
2657 if (checked) {
2658 this.displayStatus('warning spinning',
2659 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
2660 .format(L.env.apply_rollback)));
2661
2662 var call = function(r, data, duration) {
2663 if (r.status === 204) {
2664 L.ui.changes.displayStatus('warning', [
2665 E('h4', _('Configuration changes have been rolled back!')),
2666 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)),
2667 E('div', { 'class': 'right' }, [
2668 E('button', {
2669 'class': 'btn',
2670 'click': L.bind(L.ui.changes.displayStatus, L.ui.changes, false)
2671 }, [ _('Dismiss') ]), ' ',
2672 E('button', {
2673 'class': 'btn cbi-button-action important',
2674 'click': L.bind(L.ui.changes.revert, L.ui.changes)
2675 }, [ _('Revert changes') ]), ' ',
2676 E('button', {
2677 'class': 'btn cbi-button-negative important',
2678 'click': L.bind(L.ui.changes.apply, L.ui.changes, false)
2679 }, [ _('Apply unchecked') ])
2680 ])
2681 ]);
2682
2683 return;
2684 }
2685
2686 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
2687 window.setTimeout(function() {
2688 L.Request.request(L.url('admin/uci/confirm'), {
2689 method: 'post',
2690 timeout: L.env.apply_timeout * 1000,
2691 query: { sid: L.env.sessionid, token: L.env.token }
2692 }).then(call);
2693 }, delay);
2694 };
2695
2696 call({ status: 0 });
2697 }
2698 else {
2699 this.displayStatus('warning', [
2700 E('h4', _('Device unreachable!')),
2701 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.'))
2702 ]);
2703 }
2704 },
2705
2706 confirm: function(checked, deadline, override_token) {
2707 var tt;
2708 var ts = Date.now();
2709
2710 this.displayStatus('notice');
2711
2712 if (override_token)
2713 this.confirm_auth = { token: override_token };
2714
2715 var call = function(r, data, duration) {
2716 if (Date.now() >= deadline) {
2717 window.clearTimeout(tt);
2718 L.ui.changes.rollback(checked);
2719 return;
2720 }
2721 else if (r && (r.status === 200 || r.status === 204)) {
2722 document.dispatchEvent(new CustomEvent('uci-applied'));
2723
2724 L.ui.changes.setIndicator(0);
2725 L.ui.changes.displayStatus('notice',
2726 E('p', _('Configuration changes applied.')));
2727
2728 window.clearTimeout(tt);
2729 window.setTimeout(function() {
2730 //L.ui.changes.displayStatus(false);
2731 window.location = window.location.href.split('#')[0];
2732 }, L.env.apply_display * 1000);
2733
2734 return;
2735 }
2736
2737 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
2738 window.setTimeout(function() {
2739 L.Request.request(L.url('admin/uci/confirm'), {
2740 method: 'post',
2741 timeout: L.env.apply_timeout * 1000,
2742 query: L.ui.changes.confirm_auth
2743 }).then(call, call);
2744 }, delay);
2745 };
2746
2747 var tick = function() {
2748 var now = Date.now();
2749
2750 L.ui.changes.displayStatus('notice spinning',
2751 E('p', _('Applying configuration changes… %ds')
2752 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
2753
2754 if (now >= deadline)
2755 return;
2756
2757 tt = window.setTimeout(tick, 1000 - (now - ts));
2758 ts = now;
2759 };
2760
2761 tick();
2762
2763 /* wait a few seconds for the settings to become effective */
2764 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
2765 },
2766
2767 apply: function(checked) {
2768 this.displayStatus('notice spinning',
2769 E('p', _('Starting configuration apply…')));
2770
2771 L.Request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
2772 method: 'post',
2773 query: { sid: L.env.sessionid, token: L.env.token }
2774 }).then(function(r) {
2775 if (r.status === (checked ? 200 : 204)) {
2776 var tok = null; try { tok = r.json(); } catch(e) {}
2777 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
2778 L.ui.changes.confirm_auth = tok;
2779
2780 L.ui.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
2781 }
2782 else if (checked && r.status === 204) {
2783 L.ui.changes.displayStatus('notice',
2784 E('p', _('There are no changes to apply')));
2785
2786 window.setTimeout(function() {
2787 L.ui.changes.displayStatus(false);
2788 }, L.env.apply_display * 1000);
2789 }
2790 else {
2791 L.ui.changes.displayStatus('warning',
2792 E('p', _('Apply request failed with status <code>%h</code>')
2793 .format(r.responseText || r.statusText || r.status)));
2794
2795 window.setTimeout(function() {
2796 L.ui.changes.displayStatus(false);
2797 }, L.env.apply_display * 1000);
2798 }
2799 });
2800 },
2801
2802 revert: function() {
2803 this.displayStatus('notice spinning',
2804 E('p', _('Reverting configuration…')));
2805
2806 L.Request.request(L.url('admin/uci/revert'), {
2807 method: 'post',
2808 query: { sid: L.env.sessionid, token: L.env.token }
2809 }).then(function(r) {
2810 if (r.status === 200) {
2811 document.dispatchEvent(new CustomEvent('uci-reverted'));
2812
2813 L.ui.changes.setIndicator(0);
2814 L.ui.changes.displayStatus('notice',
2815 E('p', _('Changes have been reverted.')));
2816
2817 window.setTimeout(function() {
2818 //L.ui.changes.displayStatus(false);
2819 window.location = window.location.href.split('#')[0];
2820 }, L.env.apply_display * 1000);
2821 }
2822 else {
2823 L.ui.changes.displayStatus('warning',
2824 E('p', _('Revert request failed with status <code>%h</code>')
2825 .format(r.statusText || r.status)));
2826
2827 window.setTimeout(function() {
2828 L.ui.changes.displayStatus(false);
2829 }, L.env.apply_display * 1000);
2830 }
2831 });
2832 }
2833 }),
2834
2835 addValidator: function(field, type, optional, vfunc /*, ... */) {
2836 if (type == null)
2837 return;
2838
2839 var events = this.varargs(arguments, 3);
2840 if (events.length == 0)
2841 events.push('blur', 'keyup');
2842
2843 try {
2844 var cbiValidator = L.validation.create(field, type, optional, vfunc),
2845 validatorFn = cbiValidator.validate.bind(cbiValidator);
2846
2847 for (var i = 0; i < events.length; i++)
2848 field.addEventListener(events[i], validatorFn);
2849
2850 validatorFn();
2851
2852 return validatorFn;
2853 }
2854 catch (e) { }
2855 },
2856
2857 createHandlerFn: function(ctx, fn /*, ... */) {
2858 if (typeof(fn) == 'string')
2859 fn = ctx[fn];
2860
2861 if (typeof(fn) != 'function')
2862 return null;
2863
2864 var arg_offset = arguments.length - 2;
2865
2866 return Function.prototype.bind.apply(function() {
2867 var t = arguments[arg_offset].target;
2868
2869 t.classList.add('spinning');
2870 t.disabled = true;
2871
2872 if (t.blur)
2873 t.blur();
2874
2875 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
2876 t.classList.remove('spinning');
2877 t.disabled = false;
2878 });
2879 }, this.varargs(arguments, 2, ctx));
2880 },
2881
2882 AbstractElement: UIElement,
2883
2884 /* Widgets */
2885 Textfield: UITextfield,
2886 Textarea: UITextarea,
2887 Checkbox: UICheckbox,
2888 Select: UISelect,
2889 Dropdown: UIDropdown,
2890 DynamicList: UIDynamicList,
2891 Combobox: UICombobox,
2892 ComboButton: UIComboButton,
2893 Hiddenfield: UIHiddenfield,
2894 FileUpload: UIFileUpload
2895 });