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