Merge pull request #3530 from ysc3839/transmission
[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
1924 handleReset: function(ev) {
1925 var button = this.node.firstElementChild,
1926 hidden = this.node.lastElementChild;
1927
1928 hidden.value = '';
1929 L.dom.content(button, _('Select file…'));
1930
1931 this.handleCancel(ev);
1932 },
1933
1934 handleSelect: function(path, fileStat, ev) {
1935 var browser = L.dom.parent(ev.target, '.cbi-filebrowser'),
1936 ul = browser.querySelector('ul');
1937
1938 if (fileStat == null) {
1939 L.dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
1940 L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
1941 }
1942 else {
1943 var button = this.node.firstElementChild,
1944 hidden = this.node.lastElementChild;
1945
1946 path = this.canonicalizePath(path);
1947
1948 L.dom.content(button, [
1949 this.iconForType(fileStat.type),
1950 ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
1951 ]);
1952
1953 browser.classList.remove('open');
1954 button.style.display = '';
1955 hidden.value = path;
1956
1957 this.stat = Object.assign({ path: path }, fileStat);
1958 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
1959 }
1960 },
1961
1962 handleFileBrowser: function(ev) {
1963 var button = ev.target,
1964 browser = button.nextElementSibling,
1965 path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : this.options.root_directory;
1966
1967 if (this.options.root_directory.indexOf(path) != 0)
1968 path = this.options.root_directory;
1969
1970 ev.preventDefault();
1971
1972 return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) {
1973 document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
1974 L.dom.findClassInstance(browserEl).handleCancel(ev);
1975 });
1976
1977 button.style.display = 'none';
1978 browser.classList.add('open');
1979
1980 return this.renderListing(browser, path, list);
1981 }, this, button, browser, path));
1982 },
1983
1984 getValue: function() {
1985 return this.node.lastElementChild.value;
1986 },
1987
1988 setValue: function(value) {
1989 this.node.lastElementChild.value = value;
1990 }
1991 });
1992
1993
1994 return L.Class.extend({
1995 __init__: function() {
1996 modalDiv = document.body.appendChild(
1997 L.dom.create('div', { id: 'modal_overlay' },
1998 L.dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
1999
2000 tooltipDiv = document.body.appendChild(
2001 L.dom.create('div', { class: 'cbi-tooltip' }));
2002
2003 /* setup old aliases */
2004 L.showModal = this.showModal;
2005 L.hideModal = this.hideModal;
2006 L.showTooltip = this.showTooltip;
2007 L.hideTooltip = this.hideTooltip;
2008 L.itemlist = this.itemlist;
2009
2010 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
2011 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
2012 document.addEventListener('focus', this.showTooltip.bind(this), true);
2013 document.addEventListener('blur', this.hideTooltip.bind(this), true);
2014
2015 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
2016 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
2017 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
2018 },
2019
2020 /* Modal dialog */
2021 showModal: function(title, children /* , ... */) {
2022 var dlg = modalDiv.firstElementChild;
2023
2024 dlg.setAttribute('class', 'modal');
2025
2026 for (var i = 2; i < arguments.length; i++)
2027 dlg.classList.add(arguments[i]);
2028
2029 L.dom.content(dlg, L.dom.create('h4', {}, title));
2030 L.dom.append(dlg, children);
2031
2032 document.body.classList.add('modal-overlay-active');
2033
2034 return dlg;
2035 },
2036
2037 hideModal: function() {
2038 document.body.classList.remove('modal-overlay-active');
2039 },
2040
2041 /* Tooltip */
2042 showTooltip: function(ev) {
2043 var target = findParent(ev.target, '[data-tooltip]');
2044
2045 if (!target)
2046 return;
2047
2048 if (tooltipTimeout !== null) {
2049 window.clearTimeout(tooltipTimeout);
2050 tooltipTimeout = null;
2051 }
2052
2053 var rect = target.getBoundingClientRect(),
2054 x = rect.left + window.pageXOffset,
2055 y = rect.top + rect.height + window.pageYOffset;
2056
2057 tooltipDiv.className = 'cbi-tooltip';
2058 tooltipDiv.innerHTML = '▲ ';
2059 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
2060
2061 if (target.hasAttribute('data-tooltip-style'))
2062 tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
2063
2064 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
2065 y -= (tooltipDiv.offsetHeight + target.offsetHeight);
2066 tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
2067 }
2068
2069 tooltipDiv.style.top = y + 'px';
2070 tooltipDiv.style.left = x + 'px';
2071 tooltipDiv.style.opacity = 1;
2072
2073 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
2074 bubbles: true,
2075 detail: { target: target }
2076 }));
2077 },
2078
2079 hideTooltip: function(ev) {
2080 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
2081 tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
2082 return;
2083
2084 if (tooltipTimeout !== null) {
2085 window.clearTimeout(tooltipTimeout);
2086 tooltipTimeout = null;
2087 }
2088
2089 tooltipDiv.style.opacity = 0;
2090 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
2091
2092 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
2093 },
2094
2095 addNotification: function(title, children /*, ... */) {
2096 var mc = document.querySelector('#maincontent') || document.body;
2097 var msg = E('div', {
2098 'class': 'alert-message fade-in',
2099 'style': 'display:flex',
2100 'transitionend': function(ev) {
2101 var node = ev.currentTarget;
2102 if (node.parentNode && node.classList.contains('fade-out'))
2103 node.parentNode.removeChild(node);
2104 }
2105 }, [
2106 E('div', { 'style': 'flex:10' }),
2107 E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
2108 E('button', {
2109 'class': 'btn',
2110 'style': 'margin-left:auto; margin-top:auto',
2111 'click': function(ev) {
2112 L.dom.parent(ev.target, '.alert-message').classList.add('fade-out');
2113 },
2114
2115 }, [ _('Dismiss') ])
2116 ])
2117 ]);
2118
2119 if (title != null)
2120 L.dom.append(msg.firstElementChild, E('h4', {}, title));
2121
2122 L.dom.append(msg.firstElementChild, children);
2123
2124 for (var i = 2; i < arguments.length; i++)
2125 msg.classList.add(arguments[i]);
2126
2127 mc.insertBefore(msg, mc.firstElementChild);
2128
2129 return msg;
2130 },
2131
2132 /* Widget helper */
2133 itemlist: function(node, items, separators) {
2134 var children = [];
2135
2136 if (!Array.isArray(separators))
2137 separators = [ separators || E('br') ];
2138
2139 for (var i = 0; i < items.length; i += 2) {
2140 if (items[i+1] !== null && items[i+1] !== undefined) {
2141 var sep = separators[(i/2) % separators.length],
2142 cld = [];
2143
2144 children.push(E('span', { class: 'nowrap' }, [
2145 items[i] ? E('strong', items[i] + ': ') : '',
2146 items[i+1]
2147 ]));
2148
2149 if ((i+2) < items.length)
2150 children.push(L.dom.elem(sep) ? sep.cloneNode(true) : sep);
2151 }
2152 }
2153
2154 L.dom.content(node, children);
2155
2156 return node;
2157 },
2158
2159 /* Tabs */
2160 tabs: L.Class.singleton({
2161 init: function() {
2162 var groups = [], prevGroup = null, currGroup = null;
2163
2164 document.querySelectorAll('[data-tab]').forEach(function(tab) {
2165 var parent = tab.parentNode;
2166
2167 if (L.dom.matches(tab, 'li') && L.dom.matches(parent, 'ul.cbi-tabmenu'))
2168 return;
2169
2170 if (!parent.hasAttribute('data-tab-group'))
2171 parent.setAttribute('data-tab-group', groups.length);
2172
2173 currGroup = +parent.getAttribute('data-tab-group');
2174
2175 if (currGroup !== prevGroup) {
2176 prevGroup = currGroup;
2177
2178 if (!groups[currGroup])
2179 groups[currGroup] = [];
2180 }
2181
2182 groups[currGroup].push(tab);
2183 });
2184
2185 for (var i = 0; i < groups.length; i++)
2186 this.initTabGroup(groups[i]);
2187
2188 document.addEventListener('dependency-update', this.updateTabs.bind(this));
2189
2190 this.updateTabs();
2191 },
2192
2193 initTabGroup: function(panes) {
2194 if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
2195 return;
2196
2197 var menu = E('ul', { 'class': 'cbi-tabmenu' }),
2198 group = panes[0].parentNode,
2199 groupId = +group.getAttribute('data-tab-group'),
2200 selected = null;
2201
2202 if (group.getAttribute('data-initialized') === 'true')
2203 return;
2204
2205 for (var i = 0, pane; pane = panes[i]; i++) {
2206 var name = pane.getAttribute('data-tab'),
2207 title = pane.getAttribute('data-tab-title'),
2208 active = pane.getAttribute('data-tab-active') === 'true';
2209
2210 menu.appendChild(E('li', {
2211 'style': this.isEmptyPane(pane) ? 'display:none' : null,
2212 'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
2213 'data-tab': name
2214 }, E('a', {
2215 'href': '#',
2216 'click': this.switchTab.bind(this)
2217 }, title)));
2218
2219 if (active)
2220 selected = i;
2221 }
2222
2223 group.parentNode.insertBefore(menu, group);
2224 group.setAttribute('data-initialized', true);
2225
2226 if (selected === null) {
2227 selected = this.getActiveTabId(panes[0]);
2228
2229 if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) {
2230 for (var i = 0; i < panes.length; i++) {
2231 if (!this.isEmptyPane(panes[i])) {
2232 selected = i;
2233 break;
2234 }
2235 }
2236 }
2237
2238 menu.childNodes[selected].classList.add('cbi-tab');
2239 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
2240 panes[selected].setAttribute('data-tab-active', 'true');
2241
2242 this.setActiveTabId(panes[selected], selected);
2243 }
2244
2245 this.updateTabs(group);
2246 },
2247
2248 isEmptyPane: function(pane) {
2249 return L.dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
2250 },
2251
2252 getPathForPane: function(pane) {
2253 var path = [], node = null;
2254
2255 for (node = pane ? pane.parentNode : null;
2256 node != null && node.hasAttribute != null;
2257 node = node.parentNode)
2258 {
2259 if (node.hasAttribute('data-tab'))
2260 path.unshift(node.getAttribute('data-tab'));
2261 else if (node.hasAttribute('data-section-id'))
2262 path.unshift(node.getAttribute('data-section-id'));
2263 }
2264
2265 return path.join('/');
2266 },
2267
2268 getActiveTabState: function() {
2269 var page = document.body.getAttribute('data-page');
2270
2271 try {
2272 var val = JSON.parse(window.sessionStorage.getItem('tab'));
2273 if (val.page === page && L.isObject(val.paths))
2274 return val;
2275 }
2276 catch(e) {}
2277
2278 window.sessionStorage.removeItem('tab');
2279 return { page: page, paths: {} };
2280 },
2281
2282 getActiveTabId: function(pane) {
2283 var path = this.getPathForPane(pane);
2284 return +this.getActiveTabState().paths[path] || 0;
2285 },
2286
2287 setActiveTabId: function(pane, tabIndex) {
2288 var path = this.getPathForPane(pane);
2289
2290 try {
2291 var state = this.getActiveTabState();
2292 state.paths[path] = tabIndex;
2293
2294 window.sessionStorage.setItem('tab', JSON.stringify(state));
2295 }
2296 catch (e) { return false; }
2297
2298 return true;
2299 },
2300
2301 updateTabs: function(ev, root) {
2302 (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
2303 var menu = pane.parentNode.previousElementSibling,
2304 tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
2305 n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
2306
2307 if (!menu || !tab)
2308 return;
2309
2310 if (this.isEmptyPane(pane)) {
2311 tab.style.display = 'none';
2312 tab.classList.remove('flash');
2313 }
2314 else if (tab.style.display === 'none') {
2315 tab.style.display = '';
2316 requestAnimationFrame(function() { tab.classList.add('flash') });
2317 }
2318
2319 if (n_errors) {
2320 tab.setAttribute('data-errors', n_errors);
2321 tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
2322 tab.setAttribute('data-tooltip-style', 'error');
2323 }
2324 else {
2325 tab.removeAttribute('data-errors');
2326 tab.removeAttribute('data-tooltip');
2327 }
2328 }, this));
2329 },
2330
2331 switchTab: function(ev) {
2332 var tab = ev.target.parentNode,
2333 name = tab.getAttribute('data-tab'),
2334 menu = tab.parentNode,
2335 group = menu.nextElementSibling,
2336 groupId = +group.getAttribute('data-tab-group'),
2337 index = 0;
2338
2339 ev.preventDefault();
2340
2341 if (!tab.classList.contains('cbi-tab-disabled'))
2342 return;
2343
2344 menu.querySelectorAll('[data-tab]').forEach(function(tab) {
2345 tab.classList.remove('cbi-tab');
2346 tab.classList.remove('cbi-tab-disabled');
2347 tab.classList.add(
2348 tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
2349 });
2350
2351 group.childNodes.forEach(function(pane) {
2352 if (L.dom.matches(pane, '[data-tab]')) {
2353 if (pane.getAttribute('data-tab') === name) {
2354 pane.setAttribute('data-tab-active', 'true');
2355 L.ui.tabs.setActiveTabId(pane, index);
2356 }
2357 else {
2358 pane.setAttribute('data-tab-active', 'false');
2359 }
2360
2361 index++;
2362 }
2363 });
2364 }
2365 }),
2366
2367 /* File uploading */
2368 uploadFile: function(path, progressStatusNode) {
2369 return new Promise(function(resolveFn, rejectFn) {
2370 L.ui.showModal(_('Uploading file…'), [
2371 E('p', _('Please select the file to upload.')),
2372 E('div', { 'style': 'display:flex' }, [
2373 E('div', { 'class': 'left', 'style': 'flex:1' }, [
2374 E('input', {
2375 type: 'file',
2376 style: 'display:none',
2377 change: function(ev) {
2378 var modal = L.dom.parent(ev.target, '.modal'),
2379 body = modal.querySelector('p'),
2380 upload = modal.querySelector('.cbi-button-action.important'),
2381 file = ev.currentTarget.files[0];
2382
2383 if (file == null)
2384 return;
2385
2386 L.dom.content(body, [
2387 E('ul', {}, [
2388 E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
2389 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
2390 ])
2391 ]);
2392
2393 upload.disabled = false;
2394 upload.focus();
2395 }
2396 }),
2397 E('button', {
2398 'class': 'btn',
2399 'click': function(ev) {
2400 ev.target.previousElementSibling.click();
2401 }
2402 }, [ _('Browse…') ])
2403 ]),
2404 E('div', { 'class': 'right', 'style': 'flex:1' }, [
2405 E('button', {
2406 'class': 'btn',
2407 'click': function() {
2408 L.ui.hideModal();
2409 rejectFn(new Error('Upload has been cancelled'));
2410 }
2411 }, [ _('Cancel') ]),
2412 ' ',
2413 E('button', {
2414 'class': 'btn cbi-button-action important',
2415 'disabled': true,
2416 'click': function(ev) {
2417 var input = L.dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
2418
2419 if (!input.files[0])
2420 return;
2421
2422 var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
2423
2424 L.ui.showModal(_('Uploading file…'), [ progress ]);
2425
2426 var data = new FormData();
2427
2428 data.append('sessionid', rpc.getSessionID());
2429 data.append('filename', path);
2430 data.append('filedata', input.files[0]);
2431
2432 var filename = input.files[0].name;
2433
2434 L.Request.post(L.env.cgi_base + '/cgi-upload', data, {
2435 timeout: 0,
2436 progress: function(pev) {
2437 var percent = (pev.loaded / pev.total) * 100;
2438
2439 if (progressStatusNode)
2440 progressStatusNode.data = '%.2f%%'.format(percent);
2441
2442 progress.setAttribute('title', '%.2f%%'.format(percent));
2443 progress.firstElementChild.style.width = '%.2f%%'.format(percent);
2444 }
2445 }).then(function(res) {
2446 var reply = res.json();
2447
2448 L.ui.hideModal();
2449
2450 if (L.isObject(reply) && reply.failure) {
2451 L.ui.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
2452 rejectFn(new Error(reply.failure));
2453 }
2454 else {
2455 reply.name = filename;
2456 resolveFn(reply);
2457 }
2458 }, function(err) {
2459 L.ui.hideModal();
2460 rejectFn(err);
2461 });
2462 }
2463 }, [ _('Upload') ])
2464 ])
2465 ])
2466 ]);
2467 });
2468 },
2469
2470 /* Reconnect handling */
2471 pingDevice: function(proto, ipaddr) {
2472 var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
2473
2474 return new Promise(function(resolveFn, rejectFn) {
2475 var img = new Image();
2476
2477 img.onload = resolveFn;
2478 img.onerror = rejectFn;
2479
2480 window.setTimeout(rejectFn, 1000);
2481
2482 img.src = target;
2483 });
2484 },
2485
2486 awaitReconnect: function(/* ... */) {
2487 var ipaddrs = arguments.length ? arguments : [ window.location.host ];
2488
2489 window.setTimeout(L.bind(function() {
2490 L.Poll.add(L.bind(function() {
2491 var tasks = [], reachable = false;
2492
2493 for (var i = 0; i < 2; i++)
2494 for (var j = 0; j < ipaddrs.length; j++)
2495 tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
2496 .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
2497
2498 return Promise.all(tasks).then(function() {
2499 if (reachable) {
2500 L.Poll.stop();
2501 window.location = reachable;
2502 }
2503 });
2504 }, this));
2505 }, this), 5000);
2506 },
2507
2508 /* UCI Changes */
2509 changes: L.Class.singleton({
2510 init: function() {
2511 if (!L.env.sessionid)
2512 return;
2513
2514 return uci.changes().then(L.bind(this.renderChangeIndicator, this));
2515 },
2516
2517 setIndicator: function(n) {
2518 var i = document.querySelector('.uci_change_indicator');
2519 if (i == null) {
2520 var poll = document.getElementById('xhr_poll_status');
2521 i = poll.parentNode.insertBefore(E('a', {
2522 'href': '#',
2523 'class': 'uci_change_indicator label notice',
2524 'click': L.bind(this.displayChanges, this)
2525 }), poll);
2526 }
2527
2528 if (n > 0) {
2529 L.dom.content(i, [ _('Unsaved Changes'), ': ', n ]);
2530 i.classList.add('flash');
2531 i.style.display = '';
2532 }
2533 else {
2534 i.classList.remove('flash');
2535 i.style.display = 'none';
2536 }
2537 },
2538
2539 renderChangeIndicator: function(changes) {
2540 var n_changes = 0;
2541
2542 for (var config in changes)
2543 if (changes.hasOwnProperty(config))
2544 n_changes += changes[config].length;
2545
2546 this.changes = changes;
2547 this.setIndicator(n_changes);
2548 },
2549
2550 changeTemplates: {
2551 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
2552 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
2553 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
2554 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
2555 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
2556 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
2557 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
2558 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
2559 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
2560 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
2561 },
2562
2563 displayChanges: function() {
2564 var list = E('div', { 'class': 'uci-change-list' }),
2565 dlg = L.ui.showModal(_('Configuration') + ' / ' + _('Changes'), [
2566 E('div', { 'class': 'cbi-section' }, [
2567 E('strong', _('Legend:')),
2568 E('div', { 'class': 'uci-change-legend' }, [
2569 E('div', { 'class': 'uci-change-legend-label' }, [
2570 E('ins', '&#160;'), ' ', _('Section added') ]),
2571 E('div', { 'class': 'uci-change-legend-label' }, [
2572 E('del', '&#160;'), ' ', _('Section removed') ]),
2573 E('div', { 'class': 'uci-change-legend-label' }, [
2574 E('var', {}, E('ins', '&#160;')), ' ', _('Option changed') ]),
2575 E('div', { 'class': 'uci-change-legend-label' }, [
2576 E('var', {}, E('del', '&#160;')), ' ', _('Option removed') ])]),
2577 E('br'), list,
2578 E('div', { 'class': 'right' }, [
2579 E('button', {
2580 'class': 'btn',
2581 'click': L.ui.hideModal
2582 }, [ _('Dismiss') ]), ' ',
2583 E('button', {
2584 'class': 'cbi-button cbi-button-positive important',
2585 'click': L.bind(this.apply, this, true)
2586 }, [ _('Save & Apply') ]), ' ',
2587 E('button', {
2588 'class': 'cbi-button cbi-button-reset',
2589 'click': L.bind(this.revert, this)
2590 }, [ _('Revert') ])])])
2591 ]);
2592
2593 for (var config in this.changes) {
2594 if (!this.changes.hasOwnProperty(config))
2595 continue;
2596
2597 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
2598
2599 for (var i = 0, added = null; i < this.changes[config].length; i++) {
2600 var chg = this.changes[config][i],
2601 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
2602
2603 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
2604 switch (+m1) {
2605 case 0:
2606 return config;
2607
2608 case 2:
2609 if (added != null && chg[1] == added[0])
2610 return '@' + added[1] + '[-1]';
2611 else
2612 return chg[1];
2613
2614 case 4:
2615 return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
2616
2617 default:
2618 return chg[m1-1];
2619 }
2620 })));
2621
2622 if (chg[0] == 'add')
2623 added = [ chg[1], chg[2] ];
2624 }
2625 }
2626
2627 list.appendChild(E('br'));
2628 dlg.classList.add('uci-dialog');
2629 },
2630
2631 displayStatus: function(type, content) {
2632 if (type) {
2633 var message = L.ui.showModal('', '');
2634
2635 message.classList.add('alert-message');
2636 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
2637
2638 if (content)
2639 L.dom.content(message, content);
2640
2641 if (!this.was_polling) {
2642 this.was_polling = L.Request.poll.active();
2643 L.Request.poll.stop();
2644 }
2645 }
2646 else {
2647 L.ui.hideModal();
2648
2649 if (this.was_polling)
2650 L.Request.poll.start();
2651 }
2652 },
2653
2654 rollback: function(checked) {
2655 if (checked) {
2656 this.displayStatus('warning spinning',
2657 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
2658 .format(L.env.apply_rollback)));
2659
2660 var call = function(r, data, duration) {
2661 if (r.status === 204) {
2662 L.ui.changes.displayStatus('warning', [
2663 E('h4', _('Configuration changes have been rolled back!')),
2664 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)),
2665 E('div', { 'class': 'right' }, [
2666 E('button', {
2667 'class': 'btn',
2668 'click': L.bind(L.ui.changes.displayStatus, L.ui.changes, false)
2669 }, [ _('Dismiss') ]), ' ',
2670 E('button', {
2671 'class': 'btn cbi-button-action important',
2672 'click': L.bind(L.ui.changes.revert, L.ui.changes)
2673 }, [ _('Revert changes') ]), ' ',
2674 E('button', {
2675 'class': 'btn cbi-button-negative important',
2676 'click': L.bind(L.ui.changes.apply, L.ui.changes, false)
2677 }, [ _('Apply unchecked') ])
2678 ])
2679 ]);
2680
2681 return;
2682 }
2683
2684 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
2685 window.setTimeout(function() {
2686 L.Request.request(L.url('admin/uci/confirm'), {
2687 method: 'post',
2688 timeout: L.env.apply_timeout * 1000,
2689 query: { sid: L.env.sessionid, token: L.env.token }
2690 }).then(call);
2691 }, delay);
2692 };
2693
2694 call({ status: 0 });
2695 }
2696 else {
2697 this.displayStatus('warning', [
2698 E('h4', _('Device unreachable!')),
2699 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.'))
2700 ]);
2701 }
2702 },
2703
2704 confirm: function(checked, deadline, override_token) {
2705 var tt;
2706 var ts = Date.now();
2707
2708 this.displayStatus('notice');
2709
2710 if (override_token)
2711 this.confirm_auth = { token: override_token };
2712
2713 var call = function(r, data, duration) {
2714 if (Date.now() >= deadline) {
2715 window.clearTimeout(tt);
2716 L.ui.changes.rollback(checked);
2717 return;
2718 }
2719 else if (r && (r.status === 200 || r.status === 204)) {
2720 document.dispatchEvent(new CustomEvent('uci-applied'));
2721
2722 L.ui.changes.setIndicator(0);
2723 L.ui.changes.displayStatus('notice',
2724 E('p', _('Configuration changes applied.')));
2725
2726 window.clearTimeout(tt);
2727 window.setTimeout(function() {
2728 //L.ui.changes.displayStatus(false);
2729 window.location = window.location.href.split('#')[0];
2730 }, L.env.apply_display * 1000);
2731
2732 return;
2733 }
2734
2735 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
2736 window.setTimeout(function() {
2737 L.Request.request(L.url('admin/uci/confirm'), {
2738 method: 'post',
2739 timeout: L.env.apply_timeout * 1000,
2740 query: L.ui.changes.confirm_auth
2741 }).then(call, call);
2742 }, delay);
2743 };
2744
2745 var tick = function() {
2746 var now = Date.now();
2747
2748 L.ui.changes.displayStatus('notice spinning',
2749 E('p', _('Applying configuration changes… %ds')
2750 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
2751
2752 if (now >= deadline)
2753 return;
2754
2755 tt = window.setTimeout(tick, 1000 - (now - ts));
2756 ts = now;
2757 };
2758
2759 tick();
2760
2761 /* wait a few seconds for the settings to become effective */
2762 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
2763 },
2764
2765 apply: function(checked) {
2766 this.displayStatus('notice spinning',
2767 E('p', _('Starting configuration apply…')));
2768
2769 L.Request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
2770 method: 'post',
2771 query: { sid: L.env.sessionid, token: L.env.token }
2772 }).then(function(r) {
2773 if (r.status === (checked ? 200 : 204)) {
2774 var tok = null; try { tok = r.json(); } catch(e) {}
2775 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
2776 L.ui.changes.confirm_auth = tok;
2777
2778 L.ui.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
2779 }
2780 else if (checked && r.status === 204) {
2781 L.ui.changes.displayStatus('notice',
2782 E('p', _('There are no changes to apply')));
2783
2784 window.setTimeout(function() {
2785 L.ui.changes.displayStatus(false);
2786 }, L.env.apply_display * 1000);
2787 }
2788 else {
2789 L.ui.changes.displayStatus('warning',
2790 E('p', _('Apply request failed with status <code>%h</code>')
2791 .format(r.responseText || r.statusText || r.status)));
2792
2793 window.setTimeout(function() {
2794 L.ui.changes.displayStatus(false);
2795 }, L.env.apply_display * 1000);
2796 }
2797 });
2798 },
2799
2800 revert: function() {
2801 this.displayStatus('notice spinning',
2802 E('p', _('Reverting configuration…')));
2803
2804 L.Request.request(L.url('admin/uci/revert'), {
2805 method: 'post',
2806 query: { sid: L.env.sessionid, token: L.env.token }
2807 }).then(function(r) {
2808 if (r.status === 200) {
2809 document.dispatchEvent(new CustomEvent('uci-reverted'));
2810
2811 L.ui.changes.setIndicator(0);
2812 L.ui.changes.displayStatus('notice',
2813 E('p', _('Changes have been reverted.')));
2814
2815 window.setTimeout(function() {
2816 //L.ui.changes.displayStatus(false);
2817 window.location = window.location.href.split('#')[0];
2818 }, L.env.apply_display * 1000);
2819 }
2820 else {
2821 L.ui.changes.displayStatus('warning',
2822 E('p', _('Revert request failed with status <code>%h</code>')
2823 .format(r.statusText || r.status)));
2824
2825 window.setTimeout(function() {
2826 L.ui.changes.displayStatus(false);
2827 }, L.env.apply_display * 1000);
2828 }
2829 });
2830 }
2831 }),
2832
2833 addValidator: function(field, type, optional, vfunc /*, ... */) {
2834 if (type == null)
2835 return;
2836
2837 var events = this.varargs(arguments, 3);
2838 if (events.length == 0)
2839 events.push('blur', 'keyup');
2840
2841 try {
2842 var cbiValidator = L.validation.create(field, type, optional, vfunc),
2843 validatorFn = cbiValidator.validate.bind(cbiValidator);
2844
2845 for (var i = 0; i < events.length; i++)
2846 field.addEventListener(events[i], validatorFn);
2847
2848 validatorFn();
2849
2850 return validatorFn;
2851 }
2852 catch (e) { }
2853 },
2854
2855 createHandlerFn: function(ctx, fn /*, ... */) {
2856 if (typeof(fn) == 'string')
2857 fn = ctx[fn];
2858
2859 if (typeof(fn) != 'function')
2860 return null;
2861
2862 var arg_offset = arguments.length - 2;
2863
2864 return Function.prototype.bind.apply(function() {
2865 var t = arguments[arg_offset].target;
2866
2867 t.classList.add('spinning');
2868 t.disabled = true;
2869
2870 if (t.blur)
2871 t.blur();
2872
2873 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
2874 t.classList.remove('spinning');
2875 t.disabled = false;
2876 });
2877 }, this.varargs(arguments, 2, ctx));
2878 },
2879
2880 AbstractElement: UIElement,
2881
2882 /* Widgets */
2883 Textfield: UITextfield,
2884 Textarea: UITextarea,
2885 Checkbox: UICheckbox,
2886 Select: UISelect,
2887 Dropdown: UIDropdown,
2888 DynamicList: UIDynamicList,
2889 Combobox: UICombobox,
2890 ComboButton: UIComboButton,
2891 Hiddenfield: UIHiddenfield,
2892 FileUpload: UIFileUpload
2893 });