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