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